From 20a187836c73e20c0a8626e2f4e061fe2c498cc7 Mon Sep 17 00:00:00 2001 From: Florian PAUL Date: Wed, 22 Apr 2026 09:58:13 +0200 Subject: [PATCH 1/2] fix(extractors): versions are not sorted properly for metadata checks --- .../custom-npm-semver-resolver.spec.ts | 110 ++++++++++++++++ .../custom-npm-semver-resolver.ts | 4 +- .../npm-file-extractor-helper.spec.ts | 117 ++++++++++++++++++ .../npm-file-extractor-helper.ts | 2 +- 4 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 packages/@o3r/extractors/src/core/comparator/package-managers-extractors/custom-npm-semver-resolver.spec.ts create mode 100644 packages/@o3r/extractors/src/core/comparator/package-managers-extractors/npm-file-extractor-helper.spec.ts diff --git a/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/custom-npm-semver-resolver.spec.ts b/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/custom-npm-semver-resolver.spec.ts new file mode 100644 index 0000000000..dca7b72351 --- /dev/null +++ b/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/custom-npm-semver-resolver.spec.ts @@ -0,0 +1,110 @@ +import { + Locator, + ResolveOptions, + structUtils, +} from '@yarnpkg/core'; +import { + npmHttpUtils, +} from '@yarnpkg/plugin-npm'; +import { + CustomNpmSemverResolver, +} from './custom-npm-semver-resolver'; + +jest.mock('@yarnpkg/plugin-npm', () => { + const actual = jest.requireActual('@yarnpkg/plugin-npm'); + return { + ...actual, + npmHttpUtils: { + getPackageMetadata: jest.fn() + }, + NpmSemverFetcher: { + isConventionalTarballUrl: jest.fn().mockReturnValue(true) + } + }; +}); + +describe('CustomNpmSemverResolver', () => { + let resolver: CustomNpmSemverResolver; + + beforeEach(() => { + resolver = new CustomNpmSemverResolver(); + jest.clearAllMocks(); + }); + + it('should return candidates sorted in descending order', async () => { + const descriptor = structUtils.makeDescriptor( + structUtils.makeIdent(null, 'test-pkg'), + 'npm:^1.0.0' + ); + + (npmHttpUtils.getPackageMetadata as jest.Mock).mockResolvedValue({ + versions: { + '1.0.0': { dist: { tarball: 'https://registry.npmjs.org/test-pkg/-/test-pkg-1.0.0.tgz' } }, + '1.2.0': { dist: { tarball: 'https://registry.npmjs.org/test-pkg/-/test-pkg-1.2.0.tgz' } }, + '1.1.0': { dist: { tarball: 'https://registry.npmjs.org/test-pkg/-/test-pkg-1.1.0.tgz' } }, + '2.0.0': { dist: { tarball: 'https://registry.npmjs.org/test-pkg/-/test-pkg-2.0.0.tgz' } } + } + }); + + const mockOpts = { + project: { configuration: {} }, + fetchOptions: { cache: undefined } + } as unknown as ResolveOptions; + + const candidates = await resolver.getCandidates(descriptor, {}, mockOpts); + + // Only ^1.0.0 matches: 1.0.0, 1.1.0, 1.2.0 (not 2.0.0) + // Should be sorted descending: 1.2.0, 1.1.0, 1.0.0 + expect(candidates).toHaveLength(3); + const versions = candidates.map((c: Locator) => structUtils.parseLocator(structUtils.stringifyLocator(c)).reference.replace('npm:', '')); + expect(versions).toEqual(['1.2.0', '1.1.0', '1.0.0']); + }); + + it('should include prerelease versions', async () => { + const descriptor = structUtils.makeDescriptor( + structUtils.makeIdent(null, 'test-pkg'), + 'npm:>=1.0.0-alpha.0' + ); + + (npmHttpUtils.getPackageMetadata as jest.Mock).mockResolvedValue({ + versions: { + '1.0.0-alpha.1': { dist: { tarball: 'https://registry.npmjs.org/test-pkg/-/test-pkg-1.0.0-alpha.1.tgz' } }, + '1.0.0-alpha.2': { dist: { tarball: 'https://registry.npmjs.org/test-pkg/-/test-pkg-1.0.0-alpha.2.tgz' } }, + '1.0.0': { dist: { tarball: 'https://registry.npmjs.org/test-pkg/-/test-pkg-1.0.0.tgz' } } + } + }); + + const mockOpts = { + project: { configuration: {} }, + fetchOptions: { cache: undefined } + } as unknown as ResolveOptions; + + const candidates = await resolver.getCandidates(descriptor, {}, mockOpts); + + // All 3 should match with includePrerelease, sorted desc + expect(candidates).toHaveLength(3); + }); + + it('should filter out deprecated versions if non-deprecated exist', async () => { + const descriptor = structUtils.makeDescriptor( + structUtils.makeIdent(null, 'test-pkg'), + 'npm:^1.0.0' + ); + + (npmHttpUtils.getPackageMetadata as jest.Mock).mockResolvedValue({ + versions: { + '1.0.0': { deprecated: 'old', dist: { tarball: 'https://registry.npmjs.org/test-pkg/-/test-pkg-1.0.0.tgz' } }, + '1.1.0': { dist: { tarball: 'https://registry.npmjs.org/test-pkg/-/test-pkg-1.1.0.tgz' } } + } + }); + + const mockOpts = { + project: { configuration: {} }, + fetchOptions: { cache: undefined } + } as unknown as ResolveOptions; + + const candidates = await resolver.getCandidates(descriptor, {}, mockOpts); + + expect(candidates).toHaveLength(1); + }); +}); diff --git a/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/custom-npm-semver-resolver.ts b/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/custom-npm-semver-resolver.ts index 5ac6458323..303374c19c 100644 --- a/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/custom-npm-semver-resolver.ts +++ b/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/custom-npm-semver-resolver.ts @@ -53,9 +53,7 @@ export class CustomNpmSemverResolver extends NpmSemverResolver { ? noDeprecatedCandidates : candidates; - finalCandidates.toSorted((a, b) => -a.compare(b)); - - return finalCandidates.map((version) => { + return finalCandidates.toSorted((a, b) => -a.compare(b)).map((version) => { const versionLocator = structUtils.makeLocator(descriptor, `${PROTOCOL}${version.raw}`); const archiveUrl = registryData.versions[version.raw].dist.tarball; diff --git a/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/npm-file-extractor-helper.spec.ts b/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/npm-file-extractor-helper.spec.ts new file mode 100644 index 0000000000..1d029838b0 --- /dev/null +++ b/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/npm-file-extractor-helper.spec.ts @@ -0,0 +1,117 @@ +const mockSpawnSync = jest.fn(); +jest.mock('node:child_process', () => ({ + spawnSync: mockSpawnSync +})); + +const mockExistsSync = jest.fn(); +const mockMkdirSync = jest.fn(); +const mockReadFileSync = jest.fn(); +const mockRmSync = jest.fn(); +jest.mock('node:fs', () => ({ + existsSync: mockExistsSync, + mkdirSync: mockMkdirSync, + readFileSync: mockReadFileSync, + rmSync: mockRmSync +})); + +jest.mock('node:os', () => ({ + tmpdir: () => '/tmp' +})); + +jest.mock('node:crypto', () => ({ + randomBytes: () => ({ toString: () => 'abcdef' }) +})); + +// eslint-disable-next-line import/first -- needed for `jest.mock` +import type { + SpawnSyncReturns, +} from 'node:child_process'; +// eslint-disable-next-line import/first -- needed for `jest.mock` +import { + getFilesFromRegistry, +} from './npm-file-extractor-helper'; + +const mockSpawnResult = (stdout: string, status = 0): SpawnSyncReturns => ({ + pid: 0, + output: [stdout], + stdout, + stderr: '', + status, + signal: null +}); + +const mockSpawnError = (): SpawnSyncReturns => ({ + pid: 0, + output: [''], + stdout: '', + stderr: 'some error', + status: 1, + signal: null, + error: new Error('fail') +}); + +describe('getFilesFromRegistry', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should sort versions in descending order and pick the latest', async () => { + const versions = ['1.0.0', '1.3.0', '1.1.0', '1.2.0']; + + mockSpawnSync + .mockReturnValueOnce(mockSpawnResult(JSON.stringify(versions))) + .mockReturnValueOnce(mockSpawnResult('package.tgz')) + .mockReturnValueOnce(mockSpawnResult('')); + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(Buffer.from('file-content')); + + const result = await getFilesFromRegistry('@o3r/demo@^1.0.0', ['some-file.json']); + + // The npm pack call should use the latest matching version (1.3.0) + const npmPackCall = mockSpawnSync.mock.calls[1]![0] as string; + expect(npmPackCall).toContain('@o3r/demo@1.3.0'); + expect(result['some-file.json']).toBe('file-content'); + }); + + it('should handle a single version string from npm view', async () => { + mockSpawnSync + .mockReturnValueOnce(mockSpawnResult(JSON.stringify('1.0.0'))) + .mockReturnValueOnce(mockSpawnResult('package.tgz')) + .mockReturnValueOnce(mockSpawnResult('')); + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(Buffer.from('content')); + + const result = await getFilesFromRegistry('@o3r/demo', ['file.json']); + + const npmPackCall = mockSpawnSync.mock.calls[1]![0] as string; + expect(npmPackCall).toContain('@o3r/demo@1.0.0'); + expect(result['file.json']).toBe('content'); + }); + + it('should filter versions by range and sort descending', async () => { + const versions = ['1.0.0', '2.0.0', '3.0.0', '1.0.5', '1.0.3']; + + mockSpawnSync + .mockReturnValueOnce(mockSpawnResult(JSON.stringify(versions))) + .mockReturnValueOnce(mockSpawnResult('package.tgz')) + .mockReturnValueOnce(mockSpawnResult('')); + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(Buffer.from('content')); + + await getFilesFromRegistry('@o3r/demo@~1.0.0', ['file.json']); + + // ~1.0.0 matches >=1.0.0 <1.1.0: 1.0.0, 1.0.3, 1.0.5. Latest is 1.0.5 + const npmPackCall = mockSpawnSync.mock.calls[1]![0] as string; + expect(npmPackCall).toContain('@o3r/demo@1.0.5'); + }); + + it('should clean up temp directory even on error', async () => { + mockSpawnSync.mockReturnValueOnce(mockSpawnError()); + + await expect(getFilesFromRegistry('@o3r/demo', ['file.json'])).rejects.toThrow(); + expect(mockRmSync).toHaveBeenCalled(); + }); +}); diff --git a/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/npm-file-extractor-helper.ts b/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/npm-file-extractor-helper.ts index fd65a092eb..3a78fce389 100644 --- a/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/npm-file-extractor-helper.ts +++ b/packages/@o3r/extractors/src/core/comparator/package-managers-extractors/npm-file-extractor-helper.ts @@ -61,7 +61,7 @@ export async function getFilesFromRegistry(packageDescriptor: string, paths: str const range = new semver.Range(packageRange, { includePrerelease: true }); versions = versions.filter((v) => range.test(v)); } - versions.toSorted((a, b) => semver.compare(b, a)); + versions = versions.toSorted((a, b) => semver.compare(b, a)); } const latestVersion = typeof versions === 'string' ? versions : versions[0]; From 7a6110f0479797c5ebb4a16c955e3603ec338397 Mon Sep 17 00:00:00 2001 From: Kilian Panot Date: Wed, 22 Apr 2026 18:14:34 +0900 Subject: [PATCH 2/2] fix: lock node version to 24.14 --- .github/workflows/main.yml | 2 +- tools/github-actions/setup/action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c26148aa3b..e52123dc0a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -91,7 +91,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: - node-version: 24 + node-version: 24.14 - name: New Version if: github.event_name != 'merge_group' id: newVersion diff --git a/tools/github-actions/setup/action.yml b/tools/github-actions/setup/action.yml index b328f5e7d8..e10574d21d 100644 --- a/tools/github-actions/setup/action.yml +++ b/tools/github-actions/setup/action.yml @@ -11,7 +11,7 @@ runs: steps: - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: - node-version: 24 + node-version: 24.14 - name: Enable Corepack shell: bash run: corepack enable