diff --git a/packages/@o3r/core/package.json b/packages/@o3r/core/package.json index 7cafb312b6..4beee4cda3 100644 --- a/packages/@o3r/core/package.json +++ b/packages/@o3r/core/package.json @@ -162,7 +162,7 @@ "@ngrx/router-store": "~21.1.0", "@ngrx/store": "~21.1.0", "@ngrx/store-devtools": "~21.1.0", - "@nx/eslint-plugin": "~22.6.0", + "@nx/eslint-plugin": "~22.7.0", "@o3r/store-sync": "workspace:~", "@stylistic/eslint-plugin": "~5.10.0", "@types/jest": "~30.0.0", @@ -187,7 +187,7 @@ "jsdom": "~27.4.0", "jest-util": "~30.3.0", "jsonc-eslint-parser": "~2.4.0", - "nx": "~22.6.0", + "nx": "~22.7.0", "ts-jest": "~29.4.0", "typescript-eslint": "~8.58.0" }, 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];