diff --git a/packages/docusaurus/bin/__tests__/beforeCli.test.ts b/packages/docusaurus/bin/__tests__/beforeCli.test.ts new file mode 100644 index 000000000000..9789d3f6a8aa --- /dev/null +++ b/packages/docusaurus/bin/__tests__/beforeCli.test.ts @@ -0,0 +1,276 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {logger} from '@docusaurus/logger'; +import beforeCli, {ignoreUpdate} from '../beforeCli.mjs'; +import type {UpdateInfo} from 'update-notifier'; + +const {mockNotifierConfig, mockState} = vi.hoisted(() => ({ + mockNotifierConfig: { + get: vi.fn(), + set: vi.fn(), + }, + mockState: { + update: undefined as UpdateInfo | undefined, + }, +})); + +vi.mock('update-notifier', () => ({ + default: vi.fn(() => ({ + config: mockNotifierConfig, + get update() { + return mockState.update; + }, + check: vi.fn(), + })), +})); + +describe('beforeCli ignoreUpdate', () => { + it('returns false when update is undefined or current is missing', () => { + expect(ignoreUpdate(undefined)).toBe(false); + expect(ignoreUpdate({} as UpdateInfo)).toBe(false); + }); + + it('ignores legacy 0.0.0 canary releases', () => { + expect( + ignoreUpdate({ + current: '0.0.0-1234', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'major', + }), + ).toBe(true); + }); + + it('ignores versions with canary suffix (closes #11013)', () => { + expect( + ignoreUpdate({ + current: '3.7.0-canary-6264', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'patch', + }), + ).toBe(true); + expect( + ignoreUpdate({ + current: '3.8.0-canary-7123+7b6826b', + latest: '3.8.0', + name: '@docusaurus/core', + type: 'patch', + }), + ).toBe(true); + }); + + it('ignores prerelease versions', () => { + expect( + ignoreUpdate({ + current: '4.0.0-alpha.0', + latest: '4.0.0', + name: '@docusaurus/core', + type: 'major', + }), + ).toBe(true); + expect( + ignoreUpdate({ + current: '4.0.0-beta.1', + latest: '4.0.0', + name: '@docusaurus/core', + type: 'major', + }), + ).toBe(true); + }); + + it('does not ignore regular stable versions', () => { + expect( + ignoreUpdate({ + current: '3.6.0', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'minor', + }), + ).toBe(false); + expect( + ignoreUpdate({ + current: '3.7.0', + latest: '3.7.1', + name: '@docusaurus/core', + type: 'patch', + }), + ).toBe(false); + }); +}); + +describe('beforeCli control flow', () => { + const originalNodeVersion = process.version; + + beforeEach(() => { + vi.clearAllMocks(); + mockState.update = undefined; + Object.defineProperty(process, 'version', { + value: 'v26.0.0', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'version', { + value: originalNodeVersion, + configurable: true, + }); + }); + + it('suppresses update notification for canary installations while passing Node.js check', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as ( + code?: string | number | null | undefined, + ) => never); + + mockState.update = { + current: '3.7.0-canary-6264', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'patch', + }; + + await beforeCli(); + + expect(mockNotifierConfig.set).toHaveBeenCalledWith( + 'update', + mockState.update, + ); + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('displays update notification for stable outdated installations while passing Node.js check', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as ( + code?: string | number | null | undefined, + ) => never); + + mockState.update = { + current: '3.6.0', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'minor', + }; + + await beforeCli(); + + expect(mockNotifierConfig.set).toHaveBeenCalledWith( + 'update', + mockState.update, + ); + expect(consoleLogSpy).toHaveBeenCalled(); + expect(consoleLogSpy.mock.calls[0]?.[0]).toContain('Update available'); + expect(consoleLogSpy.mock.calls[0]?.[0]).toContain('3.6.0'); + expect(consoleLogSpy.mock.calls[0]?.[0]).toContain('3.7.0'); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('does not bypass minimum Node.js version validation when canary update is suppressed', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const loggerErrorSpy = vi + .spyOn(logger, 'error') + .mockImplementation(() => {}); + const loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as ( + code?: string | number | null | undefined, + ) => never); + + mockState.update = { + current: '3.7.0-canary-6264', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'patch', + }; + + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + configurable: true, + }); + + await beforeCli(); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Minimum Node.js version not met :(', + ); + expect(loggerInfoSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('does not bypass minimum Node.js version validation when prerelease update is suppressed', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const loggerErrorSpy = vi + .spyOn(logger, 'error') + .mockImplementation(() => {}); + const loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as ( + code?: string | number | null | undefined, + ) => never); + + mockState.update = { + current: '4.0.0-alpha.0', + latest: '4.0.0', + name: '@docusaurus/core', + type: 'major', + }; + + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + configurable: true, + }); + + await beforeCli(); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Minimum Node.js version not met :(', + ); + expect(loggerInfoSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('triggers error and exit on unsupported Node.js versions even when no update is available', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const loggerErrorSpy = vi + .spyOn(logger, 'error') + .mockImplementation(() => {}); + const loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as ( + code?: string | number | null | undefined, + ) => never); + + mockState.update = undefined; + + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + configurable: true, + }); + + await beforeCli(); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Minimum Node.js version not met :(', + ); + expect(loggerInfoSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/docusaurus/bin/beforeCli.mjs b/packages/docusaurus/bin/beforeCli.mjs index bb076c340553..e4a66aa2e4eb 100644 --- a/packages/docusaurus/bin/beforeCli.mjs +++ b/packages/docusaurus/bin/beforeCli.mjs @@ -35,6 +35,24 @@ const { engines: {node: requiredVersion}, } = packageJson; +/** + * We don't want to display update message for canary or prerelease versions. + * See https://github.com/facebook/docusaurus/issues/5378 + * and https://github.com/facebook/docusaurus/issues/11013 + * @param {import('update-notifier').UpdateInfo | undefined} update + */ +export function ignoreUpdate(update) { + const current = update?.current; + if (!current) { + return false; + } + const isCanaryRelease = + current.startsWith('0.0.0') || + current.includes('canary') || + Boolean(semver.prerelease(current)); + return isCanaryRelease; +} + /** * Notify user if `@docusaurus` packages are outdated * @@ -75,16 +93,6 @@ export default async function beforeCli() { logger.error(err); } - /** - * We don't want to display update message for canary releases. - * See https://github.com/facebook/docusaurus/issues/5378 - * @param {import('update-notifier').UpdateInfo} update - */ - function ignoreUpdate(update) { - const isCanaryRelease = update?.current?.startsWith('0.0.0'); - return isCanaryRelease; - } - if ( notifier.config && notifier.update && @@ -95,74 +103,72 @@ export default async function beforeCli() { // See https://github.com/yeoman/update-notifier/issues/209 notifier.config.set('update', notifier.update); - if (ignoreUpdate(notifier.update)) { - return; - } - - const siteDocusaurusPackagesForUpdate = Object.keys({ - ...sitePkg.dependencies, - ...sitePkg.devDependencies, - }) - .filter((p) => p.startsWith('@docusaurus')) - .map((p) => p.concat('@latest')) - .join(' '); + if (!ignoreUpdate(notifier.update)) { + const siteDocusaurusPackagesForUpdate = Object.keys({ + ...sitePkg.dependencies, + ...sitePkg.devDependencies, + }) + .filter((p) => p.startsWith('@docusaurus')) + .map((p) => p.concat('@latest')) + .join(' '); + + const getYarnVersion = async () => { + if (!(await fs.pathExists(path.resolve('yarn.lock')))) { + return undefined; + } + + const yarnVersionResult = await execa`yarn --version`; + const majorVersion = parseInt( + yarnVersionResult.stdout?.trim().split('.')[0] ?? '', + 10, + ); + if (!Number.isNaN(majorVersion)) { + return majorVersion; + } - const getYarnVersion = async () => { - if (!(await fs.pathExists(path.resolve('yarn.lock')))) { return undefined; - } - - const yarnVersionResult = await execa`yarn --version`; - const majorVersion = parseInt( - yarnVersionResult.stdout?.trim().split('.')[0] ?? '', - 10, - ); - if (!Number.isNaN(majorVersion)) { - return majorVersion; - } - - return undefined; - }; - - const getUpgradeCommand = async () => { - const yarnVersion = await getYarnVersion(); - if (!yarnVersion) { - return `npm i ${siteDocusaurusPackagesForUpdate}`; - } - return yarnVersion >= 2 - ? `yarn up ${siteDocusaurusPackagesForUpdate}` - : `yarn upgrade ${siteDocusaurusPackagesForUpdate}`; - }; - - /** @type {import('boxen').Options} */ - const boxenOptions = { - padding: 1, - margin: 1, - align: 'center', - borderColor: 'yellow', - borderStyle: { - topLeft: ' ', - topRight: ' ', - bottomLeft: ' ', - bottomRight: ' ', - top: '-', - bottom: '-', - left: ' ', - right: ' ', - }, - }; - - const docusaurusUpdateMessage = boxen( - `Update available ${logger.dim( - `${notifier.update.current}`, - )} → ${logger.green(`${notifier.update.latest}`)} + }; + + const getUpgradeCommand = async () => { + const yarnVersion = await getYarnVersion(); + if (!yarnVersion) { + return `npm i ${siteDocusaurusPackagesForUpdate}`; + } + return yarnVersion >= 2 + ? `yarn up ${siteDocusaurusPackagesForUpdate}` + : `yarn upgrade ${siteDocusaurusPackagesForUpdate}`; + }; + + /** @type {import('boxen').Options} */ + const boxenOptions = { + padding: 1, + margin: 1, + align: 'center', + borderColor: 'yellow', + borderStyle: { + topLeft: ' ', + topRight: ' ', + bottomLeft: ' ', + bottomRight: ' ', + top: '-', + bottom: '-', + left: ' ', + right: ' ', + }, + }; + + const docusaurusUpdateMessage = boxen( + `Update available ${logger.dim( + `${notifier.update.current}`, + )} → ${logger.green(`${notifier.update.latest}`)} To upgrade Docusaurus packages with the latest version, run the following command: ${logger.code(await getUpgradeCommand())}`, - boxenOptions, - ); + boxenOptions, + ); - console.log(docusaurusUpdateMessage); + console.log(docusaurusUpdateMessage); + } } // Notify user if node version needs to be updated