Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/@o3r/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> => ({
pid: 0,
output: [stdout],
stdout,
stderr: '',
status,
signal: null
});

const mockSpawnError = (): SpawnSyncReturns<string> => ({
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
Loading