diff --git a/packages/ckeditor5-dev-release-tools/lib/tasks/publishpackages.js b/packages/ckeditor5-dev-release-tools/lib/tasks/publishpackages.js index 5b28d58d7..9e7c5327d 100644 --- a/packages/ckeditor5-dev-release-tools/lib/tasks/publishpackages.js +++ b/packages/ckeditor5-dev-release-tools/lib/tasks/publishpackages.js @@ -15,19 +15,22 @@ import checkVersionAvailability from '../utils/checkversionavailability.js'; import findPathsToPackages from '../utils/findpathstopackages.js'; /** - * The purpose of the script is to validate the packages prepared for the release and then release them on npm. + * The purpose of the script is to publish the prepared packages. However, before, it executes a few checks that + * prevent from publishing an incomplete package. * - * The validation contains the following steps in each package: - * - User must be logged to npm on the specified account. - * - The package directory must contain `package.json` file. - * - All other files expected to be released must exist in the package directory. - * - The npm tag must match the tag calculated from the package version. + * The validation contains the following steps: + * + * - A user (a CLI session) must be logged to npm on the specified account (`npmOwner`). + * - A package directory must contain `package.json` file. + * - All files defined in the `optionalEntryPointPackages` option must exist in a package directory. + * - An npm tag (dist-tag) must match the tag calculated from the package version. + * A stable release can be also published as `next` or `staging. * * When the validation for each package passes, packages are published on npm. Optional callback is called for confirmation whether to * continue. * * If a package has already been published, the script does not try to publish it again. Instead, it treats the package as published. - * Whenever a communication between the script and npm fails, it tries to re-publish a package (up to three attempts). + * Whenever a communication between the script and npm fails, it tries to re-publish a package (up to five attempts). * * @param {object} options * @param {string} options.packagesDirectory Relative path to a location of packages to release. @@ -63,17 +66,36 @@ export default async function publishPackages( options ) { optionalEntryPointPackages = [], cwd = process.cwd(), concurrency = 2, - attempts = 3 + attempts = 5 } = options; - const remainingAttempts = attempts - 1; await assertNpmAuthorization( npmOwner ); + // Find packages that would be published... const packagePaths = await findPathsToPackages( cwd, packagesDirectory ); - await assertPackages( packagePaths, { requireEntryPoint, optionalEntryPointPackages } ); - await assertFilesToPublish( packagePaths, optionalEntries ); - await assertNpmTag( packagePaths, npmTag ); + // ...and filter out those that have already been processed. + // In other words, check whether a version per package (it's read from a `package.json` file) + // is not available. Otherwise, a package is ignored. + await removeAlreadyPublishedPackages( packagePaths ); + + // Once again, find packages to publish after the filtering operation. + const packagesToProcess = await findPathsToPackages( cwd, packagesDirectory ); + + if ( !packagesToProcess.length ) { + listrTask.output = 'All packages have been published.'; + + return Promise.resolve(); + } + + // No more attempts. Abort. + if ( attempts <= 0 ) { + throw new Error( 'Some packages could not be published.' ); + } + + await assertPackages( packagesToProcess, { requireEntryPoint, optionalEntryPointPackages } ); + await assertFilesToPublish( packagesToProcess, optionalEntries ); + await assertNpmTag( packagesToProcess, npmTag ); const shouldPublishPackages = confirmationCallback ? await confirmationCallback() : true; @@ -81,8 +103,6 @@ export default async function publishPackages( options ) { return Promise.resolve(); } - await removeAlreadyPublishedPackages( packagePaths ); - await executeInParallel( { cwd, packagesDirectory, @@ -95,39 +115,17 @@ export default async function publishPackages( options ) { concurrency } ); - const packagePathsAfterPublishing = await findPathsToPackages( cwd, packagesDirectory ); - - // All packages have been published. No need for re-executing. - if ( !packagePathsAfterPublishing.length ) { - return Promise.resolve(); - } - - // No more attempts. Abort. - if ( remainingAttempts <= 0 ) { - throw new Error( 'Some packages could not be published.' ); - } - listrTask.output = 'Let\'s give an npm a moment for taking a breath (~10 sec)...'; - // Let's give an npm a moment for taking a breath... await wait( 1000 * 10 ); - listrTask.output = 'Done. Let\'s continue.'; + listrTask.output = 'Done. Let\'s continue. Re-executing.'; // ...and try again. return publishPackages( { - packagesDirectory, - npmOwner, - listrTask, - signal, - npmTag, - optionalEntries, - requireEntryPoint, - optionalEntryPointPackages, - cwd, - concurrency, + ...options, confirmationCallback: null, // Do not ask again if already here. - attempts: remainingAttempts + attempts: attempts - 1 } ); } diff --git a/packages/ckeditor5-dev-release-tools/lib/utils/checkversionavailability.js b/packages/ckeditor5-dev-release-tools/lib/utils/checkversionavailability.js index 47d634686..a44057f27 100644 --- a/packages/ckeditor5-dev-release-tools/lib/utils/checkversionavailability.js +++ b/packages/ckeditor5-dev-release-tools/lib/utils/checkversionavailability.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md. */ -import pacote from 'pacote'; +import { manifest } from './pacotecacheless.js'; /** * Checks if the provided version for the package exists in the npm registry. @@ -15,9 +15,9 @@ import pacote from 'pacote'; * @returns {Promise} */ export default async function checkVersionAvailability( version, packageName ) { - return pacote.manifest( `${ packageName }@${ version }`, { cache: null, preferOnline: true } ) + return manifest( `${ packageName }@${ version }` ) .then( () => { - // If `pacote.manifest` resolves, a package with the given version exists. + // If `manifest` resolves, a package with the given version exists. return false; } ) .catch( () => { diff --git a/packages/ckeditor5-dev-release-tools/lib/utils/isversionpublishablefortag.js b/packages/ckeditor5-dev-release-tools/lib/utils/isversionpublishablefortag.js index 4c12983f9..a95fc6c77 100644 --- a/packages/ckeditor5-dev-release-tools/lib/utils/isversionpublishablefortag.js +++ b/packages/ckeditor5-dev-release-tools/lib/utils/isversionpublishablefortag.js @@ -4,7 +4,7 @@ */ import semver from 'semver'; -import pacote from 'pacote'; +import { manifest } from './pacotecacheless.js'; /** * This util aims to verify if the given `packageName` can be published with the given `version` on the `npmTag`. @@ -15,7 +15,7 @@ import pacote from 'pacote'; * @returns {Promise.} */ export default async function isVersionPublishableForTag( packageName, version, npmTag ) { - const npmVersion = await pacote.manifest( `${ packageName }@${ npmTag }`, { cache: null, preferOnline: true } ) + const npmVersion = await manifest( `${ packageName }@${ npmTag }` ) .then( ( { version } ) => version ) // An `npmTag` does not exist, or it's a first release of a package. .catch( () => null ); diff --git a/packages/ckeditor5-dev-release-tools/lib/utils/pacotecacheless.js b/packages/ckeditor5-dev-release-tools/lib/utils/pacotecacheless.js new file mode 100644 index 000000000..f9581e7c0 --- /dev/null +++ b/packages/ckeditor5-dev-release-tools/lib/utils/pacotecacheless.js @@ -0,0 +1,33 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import os from 'os'; +import { randomUUID } from 'crypto'; +import upath from 'upath'; +import fs from 'fs-extra'; +import pacote from 'pacote'; + +export const manifest = cacheLessPacoteFactory( pacote.manifest ); +export const packument = cacheLessPacoteFactory( pacote.packument ); + +function cacheLessPacoteFactory( callback ) { + return async ( description, options = {} ) => { + const uuid = randomUUID(); + const cacheDir = upath.join( os.tmpdir(), `pacote--${ uuid }` ); + + await fs.ensureDir( cacheDir ); + + try { + return await callback( description, { + ...options, + cache: cacheDir, + memoize: false, + preferOnline: true + } ); + } finally { + await fs.remove( cacheDir ); + } + }; +} diff --git a/packages/ckeditor5-dev-release-tools/lib/utils/versions.js b/packages/ckeditor5-dev-release-tools/lib/utils/versions.js index 56a401863..004dbbd9f 100644 --- a/packages/ckeditor5-dev-release-tools/lib/utils/versions.js +++ b/packages/ckeditor5-dev-release-tools/lib/utils/versions.js @@ -4,7 +4,7 @@ */ import { tools } from '@ckeditor/ckeditor5-dev-utils'; -import pacote from 'pacote'; +import { packument } from './pacotecacheless.js'; import getChangelog from './getchangelog.js'; import getPackageJson from './getpackagejson.js'; @@ -38,7 +38,7 @@ export function getLastFromChangelog( cwd = process.cwd() ) { export function getLastPreRelease( releaseIdentifier, cwd = process.cwd() ) { const packageName = getPackageJson( cwd ).name; - return pacote.packument( packageName, { cache: null, preferOnline: true } ) + return packument( packageName ) .then( result => { const lastVersion = Object.keys( result.versions ) .filter( version => version.startsWith( releaseIdentifier ) ) diff --git a/packages/ckeditor5-dev-release-tools/tests/tasks/publishpackages.js b/packages/ckeditor5-dev-release-tools/tests/tasks/publishpackages.js index a1bd12a09..100a41173 100644 --- a/packages/ckeditor5-dev-release-tools/tests/tasks/publishpackages.js +++ b/packages/ckeditor5-dev-release-tools/tests/tasks/publishpackages.js @@ -30,7 +30,17 @@ describe( 'publishPackages()', () => { beforeEach( () => { vi.spyOn( process, 'cwd' ).mockReturnValue( '/work/project' ); - vi.mocked( findPathsToPackages ).mockResolvedValue( [] ); + vi.mocked( findPathsToPackages ) + .mockResolvedValueOnce( [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ] ) + .mockResolvedValueOnce( [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ] ) + .mockResolvedValue( [] ); + vi.mocked( assertNpmAuthorization ).mockResolvedValue(); vi.mocked( assertPackages ).mockResolvedValue(); vi.mocked( assertNpmTag ).mockResolvedValue(); @@ -40,408 +50,444 @@ describe( 'publishPackages()', () => { vi.mocked( fs ).readJson.mockResolvedValue( { name: '', version: '' } ); vi.mocked( checkVersionAvailability ).mockResolvedValue( true ); + + vi.useFakeTimers(); } ); - it( 'should not throw if all assertion passes', async () => { - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' - } ); + afterEach( () => { + vi.useRealTimers(); } ); - it( 'should read the package directory (default `cwd`)', async () => { - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' + describe( 'a package verification', () => { + it( 'should not throw if all assertion passes', async () => { + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ); + + await vi.advanceTimersToNextTimerAsync(); + await promise; } ); - expect( vi.mocked( findPathsToPackages ) ).toHaveBeenCalledWith( '/work/project', 'packages' ); - } ); + it( 'should read the package directory (default `cwd`)', async () => { + vi.mocked( findPathsToPackages ).mockReset().mockResolvedValue( [] ); + + await publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ); - it( 'should read the package directory (custom `cwd`)', async () => { - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe', - cwd: '/work/custom-dir' + expect( vi.mocked( findPathsToPackages ) ).toHaveBeenCalledWith( '/work/project', 'packages' ); } ); - expect( vi.mocked( findPathsToPackages ) ).toHaveBeenCalledWith( '/work/custom-dir', 'packages' ); - } ); + it( 'should read the package directory (custom `cwd`)', async () => { + vi.mocked( findPathsToPackages ).mockReset().mockResolvedValue( [] ); + + await publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {}, + cwd: '/work/custom-dir' + } ); - it( 'should assert npm authorization', async () => { - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' + expect( vi.mocked( findPathsToPackages ) ).toHaveBeenCalledWith( '/work/custom-dir', 'packages' ); } ); - expect( vi.mocked( assertNpmAuthorization ) ).toHaveBeenCalledExactlyOnceWith( 'pepe' ); - } ); + it( 'should assert npm authorization', async () => { + vi.mocked( findPathsToPackages ).mockReset().mockResolvedValue( [] ); - it( 'should throw if npm authorization assertion failed', async () => { - vi.mocked( assertNpmAuthorization ).mockRejectedValue( - new Error( 'You must be logged to npm as "pepe" to execute this release step.' ) - ); + await publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ); - await expect( publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'fake-pepe' - } ) ).rejects.toThrow( 'You must be logged to npm as "pepe" to execute this release step.' ); - } ); + expect( vi.mocked( assertNpmAuthorization ) ).toHaveBeenCalledExactlyOnceWith( 'pepe' ); + } ); - it( 'should assert that each found directory is a package', async () => { - vi.mocked( findPathsToPackages ) - .mockResolvedValueOnce( [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ] ) - .mockResolvedValue( [] ); + it( 'should throw if npm authorization assertion failed', async () => { + vi.mocked( assertNpmAuthorization ).mockRejectedValue( + new Error( 'You must be logged to npm as "pepe" to execute this release step.' ) + ); + + await expect( publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'fake-pepe' + } ) ).rejects.toThrow( 'You must be logged to npm as "pepe" to execute this release step.' ); - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' + expect( vi.mocked( assertNpmAuthorization ) ).toHaveBeenCalledExactlyOnceWith( 'fake-pepe' ); } ); - expect( vi.mocked( assertPackages ) ).toHaveBeenCalledExactlyOnceWith( - [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ], - { - requireEntryPoint: false, - optionalEntryPointPackages: [] - } - ); - } ); + it( 'should assert that each found directory is a package', async () => { + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ); - // See: https://github.com/ckeditor/ckeditor5/issues/15127. - it( 'should allow enabling the "package entry point" validator', async () => { - vi.mocked( findPathsToPackages ) - .mockResolvedValueOnce( [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ] ) - .mockResolvedValue( [] ); + await vi.advanceTimersToNextTimerAsync(); + await promise; - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe', - requireEntryPoint: true, - optionalEntryPointPackages: [ - 'ckeditor5-foo' - ] + expect( vi.mocked( assertPackages ) ).toHaveBeenCalledExactlyOnceWith( + [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ], + { + requireEntryPoint: false, + optionalEntryPointPackages: [] + } + ); } ); - expect( vi.mocked( assertPackages ) ).toHaveBeenCalledExactlyOnceWith( - [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ], - { + // See: https://github.com/ckeditor/ckeditor5/issues/15127. + it( 'should allow enabling the "package entry point" validator', async () => { + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {}, requireEntryPoint: true, optionalEntryPointPackages: [ 'ckeditor5-foo' ] - } - ); - } ); + } ); - it( 'should throw if package assertion failed', async () => { - vi.mocked( assertPackages ).mockRejectedValue( - new Error( 'The "package.json" file is missing in the "ckeditor5-foo" package.' ) - ); + await vi.advanceTimersToNextTimerAsync(); + await promise; - await expect( publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' - } ) ).rejects.toThrow( 'The "package.json" file is missing in the "ckeditor5-foo" package.' ); - } ); + expect( vi.mocked( assertPackages ) ).toHaveBeenCalledExactlyOnceWith( + [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ], + { + requireEntryPoint: true, + optionalEntryPointPackages: [ + 'ckeditor5-foo' + ] + } + ); + } ); - it( 'should assert that each required file exists in the package directory (no optional entries)', async () => { - vi.mocked( findPathsToPackages ) - .mockResolvedValueOnce( [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ] ) - .mockResolvedValue( [] ); + it( 'should throw if package assertion failed', async () => { + vi.mocked( findPathsToPackages ) + .mockResolvedValue( [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ] ); + + vi.mocked( assertPackages ).mockRejectedValue( + new Error( 'The "package.json" file is missing in the "ckeditor5-foo" package.' ) + ); - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' + await expect( publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ) ).rejects.toThrow( 'The "package.json" file is missing in the "ckeditor5-foo" package.' ); } ); - expect( vi.mocked( assertFilesToPublish ) ).toHaveBeenCalledExactlyOnceWith( - [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ], - null - ); - } ); + it( 'should assert that each required file exists in the package directory (no optional entries)', async () => { + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ); - it( 'should assert that each required file exists in the package directory (with optional entries)', async () => { - vi.mocked( findPathsToPackages ) - .mockResolvedValueOnce( [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ] ) - .mockResolvedValue( [] ); + await vi.advanceTimersToNextTimerAsync(); + await promise; - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe', - optionalEntries: { - 'ckeditor5-foo': [ 'src' ] - } + expect( vi.mocked( assertFilesToPublish ) ).toHaveBeenCalledExactlyOnceWith( + [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ], + null + ); } ); - expect( vi.mocked( assertFilesToPublish ) ).toHaveBeenCalledExactlyOnceWith( - [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ], - { - 'ckeditor5-foo': [ 'src' ] - } - ); - } ); + it( 'should assert that each required file exists in the package directory (with optional entries)', async () => { + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {}, + optionalEntries: { + 'ckeditor5-foo': [ 'src' ] + } + } ); - it( 'should throw if not all required files exist in the package directory', async () => { - vi.mocked( assertFilesToPublish ).mockRejectedValue( - new Error( 'Missing files in "ckeditor5-foo" package for entries: "src"' ) - ); + await vi.advanceTimersToNextTimerAsync(); + await promise; - await expect( publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' - } ) ).rejects.toThrow( 'Missing files in "ckeditor5-foo" package for entries: "src"' ); - } ); + expect( vi.mocked( assertFilesToPublish ) ).toHaveBeenCalledExactlyOnceWith( + [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ], + { + 'ckeditor5-foo': [ 'src' ] + } + ); + } ); - it( 'should assert that version tag matches the npm tag (default npm tag)', async () => { - vi.mocked( findPathsToPackages ) - .mockResolvedValueOnce( [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ] ) - .mockResolvedValue( [] ); + it( 'should throw if not all required files exist in the package directory', async () => { + vi.mocked( assertFilesToPublish ).mockRejectedValue( + new Error( 'Missing files in "ckeditor5-foo" package for entries: "src"' ) + ); - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' + await expect( publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ) ).rejects.toThrow( 'Missing files in "ckeditor5-foo" package for entries: "src"' ); } ); - expect( vi.mocked( assertNpmTag ) ).toHaveBeenCalledExactlyOnceWith( - [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ], - 'staging' - ); - } ); + it( 'should assert that version tag matches the npm tag (default npm tag)', async () => { + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ); - it( 'should assert that version tag matches the npm tag (custom npm tag)', async () => { - vi.mocked( findPathsToPackages ) - .mockResolvedValueOnce( [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ] ) - .mockResolvedValue( [] ); + await vi.advanceTimersToNextTimerAsync(); + await promise; - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe', - npmTag: 'nightly' + expect( vi.mocked( assertNpmTag ) ).toHaveBeenCalledExactlyOnceWith( + [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ], + 'staging' + ); } ); - expect( vi.mocked( assertNpmTag ) ).toHaveBeenCalledExactlyOnceWith( - [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ], - 'nightly' - ); - } ); + it( 'should assert that version tag matches the npm tag (custom npm tag)', async () => { + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {}, + npmTag: 'nightly' + } ); - it( 'should throw if version tag does not match the npm tag', async () => { - vi.mocked( assertNpmTag ).mockRejectedValue( - new Error( 'The version tag "rc" from "ckeditor5-foo" package does not match the npm tag "staging".' ) - ); + await vi.advanceTimersToNextTimerAsync(); + await promise; - await expect( publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' - } ) ).rejects.toThrow( 'The version tag "rc" from "ckeditor5-foo" package does not match the npm tag "staging".' ); - } ); + expect( vi.mocked( assertNpmTag ) ).toHaveBeenCalledExactlyOnceWith( + [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ], + 'nightly' + ); + } ); - it( 'should use two threads by default when publishing packages', async () => { - await publishPackages( {} ); + it( 'should throw if version tag does not match the npm tag', async () => { + vi.mocked( assertNpmTag ).mockRejectedValue( + new Error( 'The version tag "rc" from "ckeditor5-foo" package does not match the npm tag "staging".' ) + ); - expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledExactlyOnceWith( expect.objectContaining( { - concurrency: 2 - } ) ); + await expect( publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ) ).rejects.toThrow( 'The version tag "rc" from "ckeditor5-foo" package does not match the npm tag "staging".' ); + } ); } ); - it( 'should pass parameters for publishing packages', async () => { - const listrTask = {}; - const abortController = new AbortController(); - - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe', - npmTag: 'nightly', - listrTask, - signal: abortController.signal, - concurrency: 3, - cwd: '/home/cwd' - } ); - - expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledExactlyOnceWith( { - packagesDirectory: 'packages', - listrTask, - taskToExecute: publishPackageOnNpmCallback, - taskOptions: { npmTag: 'nightly' }, - signal: abortController.signal, - concurrency: 3, - cwd: '/home/cwd' + describe( 'publishing packages', () => { + it( 'should use two threads by default when publishing packages', async () => { + const promise = publishPackages( { + listrTask: {} + } ); + + await vi.advanceTimersToNextTimerAsync(); + await promise; + + expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledExactlyOnceWith( expect.objectContaining( { + concurrency: 2 + } ) ); } ); - } ); - it( 'should publish packages on npm if confirmation callback is not set', async () => { - const listrTask = {}; + it( 'should pass parameters for publishing packages', async () => { + const listrTask = {}; + const abortController = new AbortController(); - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe', - listrTask + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + npmTag: 'nightly', + listrTask, + signal: abortController.signal, + concurrency: 3, + cwd: '/home/cwd' + } ); + + await vi.advanceTimersToNextTimerAsync(); + await promise; + + expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledExactlyOnceWith( { + packagesDirectory: 'packages', + listrTask, + taskToExecute: publishPackageOnNpmCallback, + taskOptions: { npmTag: 'nightly' }, + signal: abortController.signal, + concurrency: 3, + cwd: '/home/cwd' + } ); } ); - expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledOnce(); - } ); + it( 'should publish packages on npm if confirmation callback is not set', async () => { + const listrTask = {}; + + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask + } ); - it( 'should publish packages on npm if synchronous confirmation callback returns truthy value', async () => { - const confirmationCallback = vi.fn().mockReturnValue( true ); - const listrTask = {}; + await vi.advanceTimersToNextTimerAsync(); + await promise; - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe', - confirmationCallback, - listrTask + expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledOnce(); } ); - expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledOnce(); - } ); + it( 'should publish packages on npm if synchronous confirmation callback returns truthy value', async () => { + const confirmationCallback = vi.fn().mockReturnValue( true ); + const listrTask = {}; + + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + confirmationCallback, + listrTask + } ); - it( 'should publish packages on npm if asynchronous confirmation callback returns truthy value', async () => { - const confirmationCallback = vi.fn().mockResolvedValue( true ); - const listrTask = {}; + await vi.advanceTimersToNextTimerAsync(); + await promise; - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe', - confirmationCallback, - listrTask + expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledOnce(); } ); - expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledOnce(); - } ); + it( 'should publish packages on npm if asynchronous confirmation callback returns truthy value', async () => { + const confirmationCallback = vi.fn().mockResolvedValue( true ); + const listrTask = {}; + + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + confirmationCallback, + listrTask + } ); - it( 'should not publish packages on npm if synchronous confirmation callback returns falsy value', async () => { - const confirmationCallback = vi.fn().mockReturnValue( false ); + await vi.advanceTimersToNextTimerAsync(); + await promise; - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe', - confirmationCallback + expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledOnce(); } ); - expect( vi.mocked( executeInParallel ) ).not.toHaveBeenCalledOnce(); - expect( confirmationCallback ).toHaveBeenCalledOnce(); - } ); + it( 'should not publish packages on npm if synchronous confirmation callback returns falsy value', async () => { + const confirmationCallback = vi.fn().mockReturnValue( false ); - it( 'should not publish packages on npm if asynchronous confirmation callback returns falsy value', async () => { - const confirmationCallback = vi.fn().mockResolvedValue( false ); + await publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + confirmationCallback + } ); - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe', - confirmationCallback + expect( vi.mocked( executeInParallel ) ).not.toHaveBeenCalledOnce(); + expect( confirmationCallback ).toHaveBeenCalledOnce(); } ); - expect( vi.mocked( executeInParallel ) ).not.toHaveBeenCalledOnce(); - expect( confirmationCallback ).toHaveBeenCalledOnce(); - } ); + it( 'should not publish packages on npm if asynchronous confirmation callback returns falsy value', async () => { + const confirmationCallback = vi.fn().mockResolvedValue( false ); - it( 'should throw if publishing packages on npm failed', async () => { - vi.mocked( executeInParallel ).mockRejectedValue( - new Error( 'Unable to publish "ckeditor5-foo" package.' ) - ); - - await expect( publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' - } ) ).rejects.toThrow( - 'Unable to publish "ckeditor5-foo" package.' - ); - } ); + await publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + confirmationCallback + } ); - it( 'should verify if given package can be published', async () => { - vi.mocked( findPathsToPackages ) - .mockResolvedValueOnce( [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ] ) - .mockResolvedValue( [] ); + expect( vi.mocked( executeInParallel ) ).not.toHaveBeenCalledOnce(); + expect( confirmationCallback ).toHaveBeenCalledOnce(); + } ); - vi.mocked( fs ).readJson - .mockResolvedValueOnce( { name: '@ckeditor/ckeditor5-foo', version: '1.0.0' } ) - .mockResolvedValueOnce( { name: '@ckeditor/ckeditor5-bar', version: '1.0.0' } ); + it( 'should throw if publishing packages on npm failed', async () => { + vi.mocked( executeInParallel ).mockRejectedValue( + new Error( 'Unable to publish "ckeditor5-foo" package.' ) + ); - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' + await expect( publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe' + } ) ).rejects.toThrow( + 'Unable to publish "ckeditor5-foo" package.' + ); } ); - expect( vi.mocked( fs ).readJson ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( fs ).readJson ).toHaveBeenCalledWith( '/work/project/packages/ckeditor5-foo/package.json' ); - expect( vi.mocked( fs ).readJson ).toHaveBeenCalledWith( '/work/project/packages/ckeditor5-bar/package.json' ); + it( 'should verify if given package can be published', async () => { + vi.mocked( findPathsToPackages ) + .mockReset() + .mockResolvedValueOnce( [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ] ) + .mockResolvedValue( [] ); - expect( vi.mocked( checkVersionAvailability ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( checkVersionAvailability ) ).toHaveBeenCalledWith( '1.0.0', '@ckeditor/ckeditor5-foo' ); - expect( vi.mocked( checkVersionAvailability ) ).toHaveBeenCalledWith( '1.0.0', '@ckeditor/ckeditor5-bar' ); - } ); + vi.mocked( fs ).readJson + .mockResolvedValueOnce( { name: '@ckeditor/ckeditor5-foo', version: '1.0.0' } ) + .mockResolvedValueOnce( { name: '@ckeditor/ckeditor5-bar', version: '1.0.0' } ); - it( 'should remove a package if is already published', async () => { - vi.mocked( findPathsToPackages ) - .mockResolvedValueOnce( [ - '/work/project/packages/ckeditor5-foo', - '/work/project/packages/ckeditor5-bar' - ] ) - .mockResolvedValue( [] ); + const promise = publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ); - vi.mocked( fs ).readJson - .mockResolvedValueOnce( { name: '@ckeditor/ckeditor5-foo', version: '1.0.0' } ) - .mockResolvedValueOnce( { name: '@ckeditor/ckeditor5-bar', version: '1.0.0' } ); + await vi.advanceTimersToNextTimerAsync(); + await promise; - vi.mocked( checkVersionAvailability ) - .mockResolvedValueOnce( false ) - .mockResolvedValueOnce( true ); + expect( vi.mocked( fs ).readJson ).toHaveBeenCalledTimes( 2 ); + expect( vi.mocked( fs ).readJson ).toHaveBeenCalledWith( '/work/project/packages/ckeditor5-foo/package.json' ); + expect( vi.mocked( fs ).readJson ).toHaveBeenCalledWith( '/work/project/packages/ckeditor5-bar/package.json' ); - await publishPackages( { - packagesDirectory: 'packages', - npmOwner: 'pepe' + expect( vi.mocked( checkVersionAvailability ) ).toHaveBeenCalledTimes( 2 ); + expect( vi.mocked( checkVersionAvailability ) ).toHaveBeenCalledWith( '1.0.0', '@ckeditor/ckeditor5-foo' ); + expect( vi.mocked( checkVersionAvailability ) ).toHaveBeenCalledWith( '1.0.0', '@ckeditor/ckeditor5-bar' ); } ); - expect( vi.mocked( fs ).remove ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( fs ).remove ).toHaveBeenCalledWith( '/work/project/packages/ckeditor5-foo' ); + it( 'should remove a package if is already published', async () => { + vi.mocked( findPathsToPackages ) + .mockReset() + .mockResolvedValueOnce( [ + '/work/project/packages/ckeditor5-foo', + '/work/project/packages/ckeditor5-bar' + ] ) + .mockResolvedValue( [] ); + + vi.mocked( fs ).readJson + .mockResolvedValueOnce( { name: '@ckeditor/ckeditor5-foo', version: '1.0.0' } ) + .mockResolvedValueOnce( { name: '@ckeditor/ckeditor5-bar', version: '1.0.0' } ); + + vi.mocked( checkVersionAvailability ) + .mockResolvedValueOnce( false ) + .mockResolvedValueOnce( true ); + + await publishPackages( { + packagesDirectory: 'packages', + npmOwner: 'pepe', + listrTask: {} + } ); + + expect( vi.mocked( fs ).remove ).toHaveBeenCalledTimes( 1 ); + expect( vi.mocked( fs ).remove ).toHaveBeenCalledWith( '/work/project/packages/ckeditor5-foo' ); + } ); } ); describe( 're-publish packages that could not be published', () => { beforeEach( () => { - vi.useFakeTimers(); - } ); - - afterEach( () => { - vi.useRealTimers(); + vi.mocked( findPathsToPackages ).mockReset(); } ); it( 'should not execute the specified `confirmationCallback` when re-publishing packages', async () => { @@ -531,8 +577,11 @@ describe( 'publishPackages()', () => { vi.mocked( fs ).readJson.mockResolvedValue( {} ); + const messages = []; const listrTask = { - output: '' + set output( value ) { + messages.push( value ); + } }; const confirmationCallback = vi.fn().mockReturnValue( true ); @@ -543,38 +592,48 @@ describe( 'publishPackages()', () => { listrTask } ); - await vi.advanceTimersByTimeAsync( 0 ); - expect( listrTask.output ).not.toEqual( '' ); - await vi.advanceTimersToNextTimerAsync(); - expect( listrTask.output ).toEqual( 'Done. Let\'s continue.' ); - await promise; + + expect( messages[ 0 ] ).toEqual( 'Let\'s give an npm a moment for taking a breath (~10 sec)...' ); + expect( messages[ 1 ] ).toEqual( 'Done. Let\'s continue. Re-executing.' ); } ); - it( 'should try to publish packages thrice before rejecting a promise', async () => { + it( 'should try to publish packages five times before rejecting a promise', async () => { + // We want to simulate that the last call published the package. The previous attempts had failed. + for ( let i = 0; i < 5; ++i ) { + vi.mocked( findPathsToPackages ) + .mockResolvedValueOnce( [ '/work/project/packages/ckeditor5-bar' ] ) + .mockResolvedValueOnce( [ '/work/project/packages/ckeditor5-bar' ] ); + } + + // The last call before throwing an error. After comparing results with the registry, + // there is nothing to publish. vi.mocked( findPathsToPackages ) - .mockResolvedValueOnce( [ '/work/project/packages/ckeditor5-bar' ] ) - .mockResolvedValueOnce( [ '/work/project/packages/ckeditor5-bar' ] ) - .mockResolvedValueOnce( [ '/work/project/packages/ckeditor5-bar' ] ) - .mockResolvedValueOnce( [ '/work/project/packages/ckeditor5-bar' ] ) .mockResolvedValueOnce( [ '/work/project/packages/ckeditor5-bar' ] ) .mockResolvedValue( [] ); vi.mocked( fs ).readJson.mockResolvedValue( {} ); + const listrTask = { + output: '' + }; + const promise = publishPackages( { packagesDirectory: 'packages', npmOwner: 'pepe', - listrTask: {} + listrTask } ); - // Needed twice because the third attempt does not setup a timeout. - await vi.advanceTimersToNextTimerAsync(); - await vi.advanceTimersToNextTimerAsync(); + // Each execution sets its own timer. + for ( let i = 0; i < 5; ++i ) { + await vi.advanceTimersToNextTimerAsync(); + } + await promise; - expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledTimes( 3 ); + expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledTimes( 5 ); + expect( listrTask.output ).toEqual( 'All packages have been published.' ); } ); it( 'should execute itself and publish the non-published packages again (integration)', async () => { @@ -584,7 +643,7 @@ describe( 'publishPackages()', () => { '/work/project/packages/ckeditor5-foo', '/work/project/packages/ckeditor5-bar' ] ) - // Check for failed packages. + // All packages must be published. .mockResolvedValueOnce( [ '/work/project/packages/ckeditor5-foo', '/work/project/packages/ckeditor5-bar' @@ -594,7 +653,11 @@ describe( 'publishPackages()', () => { '/work/project/packages/ckeditor5-foo', '/work/project/packages/ckeditor5-bar' ] ) - // Repeat execution: Check for failed packages. + // Check for failed packages. + .mockResolvedValueOnce( [ + '/work/project/packages/ckeditor5-foo' + ] ) + // Repeat execution: look for packages to release. .mockResolvedValue( [] ); vi.mocked( fs ).readJson.mockImplementation( input => { @@ -621,6 +684,7 @@ describe( 'publishPackages()', () => { listrTask: {} } ); + await vi.advanceTimersToNextTimerAsync(); await vi.advanceTimersToNextTimerAsync(); await promise; @@ -628,10 +692,10 @@ describe( 'publishPackages()', () => { expect( vi.mocked( fs ).remove ).toHaveBeenCalledWith( '/work/project/packages/ckeditor5-foo' ); expect( vi.mocked( executeInParallel ) ).toHaveBeenCalledTimes( 2 ); - expect( vi.mocked( findPathsToPackages ) ).toHaveBeenCalledTimes( 4 ); + expect( vi.mocked( findPathsToPackages ) ).toHaveBeenCalledTimes( 6 ); } ); - it( 'should reject a promise if cannot publish packages and there is no more attempting', async () => { + it( 'should reject a promise if cannot publish packages and there are no more attempts', async () => { vi.mocked( findPathsToPackages ).mockResolvedValue( [ '/work/project/packages/ckeditor5-bar' ] ); vi.mocked( fs ).readJson.mockResolvedValue( { @@ -639,11 +703,18 @@ describe( 'publishPackages()', () => { version: '1.0.0' } ); - await expect( publishPackages( { + const promise = safeReject( publishPackages( { packagesDirectory: 'packages', npmOwner: 'pepe', - attempts: 1 - } ) ).rejects.toThrow( 'Some packages could not be published.' ); + attempts: 1, + listrTask: {} + } ) ); + + await vi.advanceTimersToNextTimerAsync(); + + await expect( promise ).rejects.toThrow( 'Some packages could not be published.' ); + + expect( vi.mocked( findPathsToPackages ) ).toHaveBeenCalledTimes( 4 ); } ); it( 'should reject a promise if cannot publish packages and there is no more attempting (a negative attempts value)', async () => { @@ -659,6 +730,15 @@ describe( 'publishPackages()', () => { npmOwner: 'pepe', attempts: -5 } ) ).rejects.toThrow( 'Some packages could not be published.' ); + + expect( vi.mocked( findPathsToPackages ) ).toHaveBeenCalledTimes( 2 ); } ); } ); } ); + +// To mute the "PromiseRejectionHandledWarning" warning. +function safeReject( promise ) { + promise.catch( vi.fn() ); + + return promise; +} diff --git a/packages/ckeditor5-dev-release-tools/tests/utils/checkversionavailability.js b/packages/ckeditor5-dev-release-tools/tests/utils/checkversionavailability.js index a5831fea7..0a0badc4c 100644 --- a/packages/ckeditor5-dev-release-tools/tests/utils/checkversionavailability.js +++ b/packages/ckeditor5-dev-release-tools/tests/utils/checkversionavailability.js @@ -4,21 +4,21 @@ */ import { describe, expect, it, vi } from 'vitest'; -import pacote from 'pacote'; import checkVersionAvailability from '../../lib/utils/checkversionavailability.js'; +import { manifest } from '../../lib/utils/pacotecacheless.js'; -vi.mock( 'pacote' ); +vi.mock( '../../lib/utils/pacotecacheless.js' ); describe( 'checkVersionAvailability()', () => { it( 'should resolve to true if version does not exist', async () => { - vi.mocked( pacote.manifest ).mockRejectedValue( 'E404' ); + vi.mocked( manifest ).mockRejectedValue( 'E404' ); await expect( checkVersionAvailability( '1.0.1', 'stub-package' ) ).resolves.toBe( true ); - expect( pacote.manifest ).toHaveBeenCalledExactlyOnceWith( 'stub-package@1.0.1', expect.any( Object ) ); + expect( manifest ).toHaveBeenCalledExactlyOnceWith( 'stub-package@1.0.1' ); } ); it( 'should resolve to false if version exists', async () => { - pacote.manifest.mockResolvedValue( '1.0.1' ); + manifest.mockResolvedValue( '1.0.1' ); await expect( checkVersionAvailability( '1.0.1', 'stub-package' ) ).resolves.toBe( false ); } ); diff --git a/packages/ckeditor5-dev-release-tools/tests/utils/isversionpublishablefortag.js b/packages/ckeditor5-dev-release-tools/tests/utils/isversionpublishablefortag.js index 6ca3572f7..e64d34cab 100644 --- a/packages/ckeditor5-dev-release-tools/tests/utils/isversionpublishablefortag.js +++ b/packages/ckeditor5-dev-release-tools/tests/utils/isversionpublishablefortag.js @@ -4,18 +4,18 @@ */ import { describe, expect, it, vi } from 'vitest'; -import pacote from 'pacote'; import semver from 'semver'; import isVersionPublishableForTag from '../../lib/utils/isversionpublishablefortag.js'; +import { manifest } from '../../lib/utils/pacotecacheless.js'; -vi.mock( 'pacote' ); +vi.mock( '../../lib/utils/pacotecacheless.js' ); vi.mock( 'semver' ); describe( 'isVersionPublishableForTag()', () => { it( 'should return false if given version is not available', async () => { vi.mocked( semver.lte ).mockReturnValue( true ); - vi.mocked( pacote.manifest ).mockResolvedValue( ( { + vi.mocked( manifest ).mockResolvedValue( ( { version: '1.0.0' } ) ); @@ -23,13 +23,13 @@ describe( 'isVersionPublishableForTag()', () => { expect( result ).to.equal( false ); expect( semver.lte ).toHaveBeenCalledExactlyOnceWith( '1.0.0', '1.0.0' ); - expect( pacote.manifest ).toHaveBeenCalledExactlyOnceWith( 'package-name@latest', expect.any( Object ) ); + expect( manifest ).toHaveBeenCalledExactlyOnceWith( 'package-name@latest' ); } ); it( 'should return false if given version is not higher than the latest published', async () => { vi.mocked( semver.lte ).mockReturnValue( true ); - vi.mocked( pacote.manifest ).mockResolvedValue( ( { + vi.mocked( manifest ).mockResolvedValue( ( { version: '1.0.1' } ) ); @@ -40,12 +40,12 @@ describe( 'isVersionPublishableForTag()', () => { } ); it( 'should return true if given npm tag is not published yet', async () => { - vi.mocked( pacote.manifest ).mockRejectedValue( 'E404' ); + vi.mocked( manifest ).mockRejectedValue( 'E404' ); const result = await isVersionPublishableForTag( 'package-name', '1.0.0', 'alpha' ); expect( result ).to.equal( true ); expect( semver.lte ).not.toHaveBeenCalled(); - expect( pacote.manifest ).toHaveBeenCalledExactlyOnceWith( 'package-name@alpha', expect.any( Object ) ); + expect( manifest ).toHaveBeenCalledExactlyOnceWith( 'package-name@alpha' ); } ); } ); diff --git a/packages/ckeditor5-dev-release-tools/tests/utils/pacotecacheless.js b/packages/ckeditor5-dev-release-tools/tests/utils/pacotecacheless.js new file mode 100644 index 000000000..d8ec6d429 --- /dev/null +++ b/packages/ckeditor5-dev-release-tools/tests/utils/pacotecacheless.js @@ -0,0 +1,198 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import fs from 'fs-extra'; +import pacote from 'pacote'; +import { manifest, packument } from '../../lib/utils/pacotecacheless.js'; + +vi.mock( 'os' ); +vi.mock( 'crypto' ); +vi.mock( 'fs-extra' ); +vi.mock( 'pacote' ); + +describe( 'pacote (no cache)', () => { + beforeEach( () => { + vi.mocked( os ).tmpdir.mockReturnValue( '/tmp' ); + vi.mocked( randomUUID ).mockReturnValue( 'a-1-b-2' ); + + vi.mocked( fs ).ensureDir.mockResolvedValue(); + vi.mocked( fs ).remove.mockResolvedValue(); + } ); + + describe( 'manifest()', () => { + it( 'should be a function', () => { + expect( manifest ).toBeTypeOf( 'function' ); + } ); + + it( 'should create a temporary cache directory', async () => { + await manifest( 'foo' ); + + expect( vi.mocked( os ).tmpdir ).toHaveBeenCalledOnce(); + expect( vi.mocked( randomUUID ) ).toHaveBeenCalledOnce(); + expect( vi.mocked( fs ).ensureDir ).toHaveBeenCalledExactlyOnceWith( '/tmp/pacote--a-1-b-2' ); + } ); + + it( 'must create a cache directory before executing `pacote.manifest()`', async () => { + await manifest( 'foo' ); + + expect( vi.mocked( fs ).ensureDir ).toHaveBeenCalledBefore( vi.mocked( pacote ).manifest ); + } ); + + it( 'should pass a temporary cache directory to `pacote.manifest()`', async () => { + await manifest( 'foo' ); + + expect( vi.mocked( pacote ).manifest ).toHaveBeenCalledExactlyOnceWith( + expect.any( String ), + expect.objectContaining( { + cache: '/tmp/pacote--a-1-b-2', + memoize: false, + preferOnline: true + } ) ); + } ); + + it( 'should pass arguments to `pacote.manifest()`', async () => { + await manifest( 'foo', { foo: true, bar: false } ); + + expect( vi.mocked( pacote ).manifest ).toHaveBeenCalledExactlyOnceWith( + 'foo', + expect.objectContaining( { + foo: true, + bar: false + } ) ); + } ); + + it( 'should not allow overriding the cache parameters when executing `pacote.manifest()`', async () => { + await manifest( 'foo', { + cache: null, + memoize: 1, + preferOnline: 'never' + } ); + + expect( vi.mocked( pacote ).manifest ).toHaveBeenCalledExactlyOnceWith( + expect.any( String ), + expect.not.objectContaining( { + cache: null, + memoize: 1, + preferOnline: 'never' + } ) ); + } ); + + it( 'should resolve with a value returned by `pacote.manifest()', async () => { + const value = { + status: 'success', + data: { + done: true + } + }; + + vi.mocked( pacote ).manifest.mockResolvedValue( value ); + + await expect( manifest( 'foo' ) ).resolves.toEqual( value ); + } ); + + it( 'must remove a cache directory after executing `pacote.manifest()` (when resolved)', async () => { + await manifest( 'foo' ); + + expect( vi.mocked( fs ).remove ).toHaveBeenCalledAfter( vi.mocked( pacote ).manifest ); + } ); + + it( 'must remove a cache directory after executing `pacote.manifest()` (when rejected)', async () => { + vi.mocked( pacote ).manifest.mockRejectedValue( 'null' ); + + await expect( manifest( 'foo' ) ).rejects.toThrow(); + + expect( vi.mocked( fs ).remove ).toHaveBeenCalledAfter( vi.mocked( pacote ).manifest ); + } ); + } ); + + describe( 'packument()', () => { + it( 'should be a function', () => { + expect( packument ).toBeTypeOf( 'function' ); + } ); + + it( 'should create a temporary cache directory', async () => { + await packument( 'foo' ); + + expect( vi.mocked( os ).tmpdir ).toHaveBeenCalledOnce(); + expect( vi.mocked( randomUUID ) ).toHaveBeenCalledOnce(); + expect( vi.mocked( fs ).ensureDir ).toHaveBeenCalledExactlyOnceWith( '/tmp/pacote--a-1-b-2' ); + } ); + + it( 'must create a cache directory before executing `pacote.packument()`', async () => { + await packument( 'foo' ); + + expect( vi.mocked( fs ).ensureDir ).toHaveBeenCalledBefore( vi.mocked( pacote ).packument ); + } ); + + it( 'should pass a temporary cache directory to `pacote.packument()`', async () => { + await packument( 'foo' ); + + expect( vi.mocked( pacote ).packument ).toHaveBeenCalledExactlyOnceWith( + expect.any( String ), + expect.objectContaining( { + cache: '/tmp/pacote--a-1-b-2', + memoize: false, + preferOnline: true + } ) ); + } ); + + it( 'should pass arguments to `pacote.packument()`', async () => { + await packument( 'foo', { foo: true, bar: false } ); + + expect( vi.mocked( pacote ).packument ).toHaveBeenCalledExactlyOnceWith( + 'foo', + expect.objectContaining( { + foo: true, + bar: false + } ) ); + } ); + + it( 'should not allow overriding the cache parameters when executing `pacote.packument()`', async () => { + await packument( 'foo', { + cache: null, + memoize: 1, + preferOnline: 'never' + } ); + + expect( vi.mocked( pacote ).packument ).toHaveBeenCalledExactlyOnceWith( + expect.any( String ), + expect.not.objectContaining( { + cache: null, + memoize: 1, + preferOnline: 'never' + } ) ); + } ); + + it( 'should resolve with a value returned by `pacote.packument()', async () => { + const value = { + status: 'success', + data: { + done: true + } + }; + + vi.mocked( pacote ).packument.mockResolvedValue( value ); + + await expect( packument( 'foo' ) ).resolves.toEqual( value ); + } ); + + it( 'must remove a cache directory after executing `pacote.packument()` (when resolved)', async () => { + await packument( 'foo' ); + + expect( vi.mocked( fs ).remove ).toHaveBeenCalledAfter( vi.mocked( pacote ).packument ); + } ); + + it( 'must remove a cache directory after executing `pacote.packument()` (when rejected)', async () => { + vi.mocked( pacote ).packument.mockRejectedValue( 'null' ); + + await expect( packument( 'foo' ) ).rejects.toThrow(); + + expect( vi.mocked( fs ).remove ).toHaveBeenCalledAfter( vi.mocked( pacote ).packument ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-dev-release-tools/tests/utils/versions.js b/packages/ckeditor5-dev-release-tools/tests/utils/versions.js index f4c62e412..b04d75cd4 100644 --- a/packages/ckeditor5-dev-release-tools/tests/utils/versions.js +++ b/packages/ckeditor5-dev-release-tools/tests/utils/versions.js @@ -5,9 +5,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { tools } from '@ckeditor/ckeditor5-dev-utils'; -import pacote from 'pacote'; import getChangelog from '../../lib/utils/getchangelog.js'; import getPackageJson from '../../lib/utils/getpackagejson.js'; +import { packument } from '../../lib/utils/pacotecacheless.js'; import { getLastFromChangelog, @@ -21,7 +21,7 @@ import { } from '../../lib/utils/versions.js'; vi.mock( '@ckeditor/ckeditor5-dev-utils' ); -vi.mock( 'pacote' ); +vi.mock( '../../lib/utils/pacotecacheless.js' ); vi.mock( '../../lib/utils/getchangelog.js' ); vi.mock( '../../lib/utils/getpackagejson.js' ); @@ -106,20 +106,20 @@ describe( 'versions', () => { } ); it( 'asks npm for all versions of a package', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: {} } ); return getLastPreRelease( '42.0.0-alpha' ) .then( () => { - expect( vi.mocked( pacote ).packument ).toHaveBeenCalledTimes( 1 ); - expect( vi.mocked( pacote ).packument ).toHaveBeenCalledWith( 'ckeditor5', expect.any( Object ) ); + expect( vi.mocked( packument ) ).toHaveBeenCalledTimes( 1 ); + expect( vi.mocked( packument ) ).toHaveBeenCalledWith( 'ckeditor5' ); } ); } ); it( 'returns null if there is no version for a package', () => { - vi.mocked( pacote ).packument.mockRejectedValue(); + vi.mocked( packument ).mockRejectedValue(); return getLastPreRelease( '42.0.0-alpha' ) .then( result => { @@ -128,7 +128,7 @@ describe( 'versions', () => { } ); it( 'returns null if there is no pre-release version matching the release identifier', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230615.0': {}, @@ -146,7 +146,7 @@ describe( 'versions', () => { } ); it( 'returns last pre-release version matching the release identifier', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230615.0': {}, @@ -164,7 +164,7 @@ describe( 'versions', () => { } ); it( 'returns last pre-release version matching the release identifier (non-chronological versions order)', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230615.0': {}, @@ -183,7 +183,7 @@ describe( 'versions', () => { } ); it( 'returns last pre-release version matching the release identifier (sequence numbers greater than 10)', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230615.0': {}, @@ -203,7 +203,7 @@ describe( 'versions', () => { } ); it( 'returns last nightly version', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230614.0': {}, @@ -225,7 +225,7 @@ describe( 'versions', () => { } ); it( 'returns last nightly version from a specified day', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230614.0': {}, @@ -253,7 +253,7 @@ describe( 'versions', () => { } ); it( 'returns last nightly pre-release version', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230613.0': {}, @@ -279,7 +279,7 @@ describe( 'versions', () => { } ); it( 'returns pre-release version with id = 0 if pre-release version was never published for the package yet', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230615.0': {}, @@ -295,7 +295,7 @@ describe( 'versions', () => { } ); it( 'returns pre-release version with incremented id if older pre-release version was already published', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230615.0': {}, @@ -311,7 +311,7 @@ describe( 'versions', () => { } ); it( 'returns nightly version with incremented id if older nightly version was already published', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230615.5': {}, @@ -340,7 +340,7 @@ describe( 'versions', () => { } ); it( 'asks for a last nightly pre-release version', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-nightly-20230615.0': {}, @@ -369,7 +369,7 @@ describe( 'versions', () => { } ); it( 'asks for a last internal pre-release version', () => { - vi.mocked( pacote ).packument.mockResolvedValue( { + vi.mocked( packument ).mockResolvedValue( { name: 'ckeditor5', versions: { '0.0.0-internal-20230615.0': {},