Files
renovate/lib/util/exec/containerbase.spec.ts
RahulGautamSingh df201a98ff fix(util/exec): pick tool releases compatible with their parent tool version (#47090)
* fix(util/exec): pick tool releases compatible with resolved node version

* refactor(util/exec): pass resolved runtimes as an object

* refactor(util/exec): match tools against their containerbase parent

* test(util/exec): run real constraints filtering for parent versions
2026-10-16 08:13:04 +00:00

444 lines
15 KiB
TypeScript

import { GlobalConfig } from '../../config/global.ts';
import { applyConstraintsFiltering } from '../../modules/datasource/common.ts';
import * as _datasource from '../../modules/datasource/index.ts';
import * as memCache from '../cache/memory/index.ts';
import {
generateInstallCommands,
getToolConfig,
isDynamicInstall,
resolveConstraint,
} from './containerbase.ts';
import type { ToolConstraint, ToolName } from './types.ts';
vi.mock('../../modules/datasource/index.ts');
const datasource = vi.mocked(_datasource);
describe('util/exec/containerbase', () => {
describe('isDynamicInstall()', () => {
beforeEach(() => {
GlobalConfig.reset();
vi.stubEnv('CONTAINERBASE', undefined);
});
it('returns false if binarySource is not install', () => {
expect(isDynamicInstall()).toBeFalse();
});
it('returns false if not containerbase', () => {
GlobalConfig.set({ binarySource: 'install' });
expect(isDynamicInstall()).toBeFalse();
});
it('returns false if any unsupported tools', () => {
GlobalConfig.set({ binarySource: 'install' });
vi.stubEnv('CONTAINERBASE', 'true');
const toolConstraints: ToolConstraint[] = [
{ toolName: 'node' },
// @ts-expect-error -- intentionally using invalid constraint names
{ toolName: 'invalid' },
];
expect(isDynamicInstall(toolConstraints)).toBeFalse();
});
it('returns true if supported tools', () => {
GlobalConfig.set({ binarySource: 'install' });
vi.stubEnv('CONTAINERBASE', 'true');
const toolConstraints: ToolConstraint[] = [{ toolName: 'npm' }];
expect(isDynamicInstall(toolConstraints)).toBeTrue();
});
});
describe('getToolConfig()', () => {
it('returns config for a known tool', () => {
const config = getToolConfig('npm');
expect(config).toBeDefined();
expect(config).toMatchObject({
datasource: expect.toBeString(),
versioning: expect.toBeString(),
});
});
it('returns undefined for an unknown tool', () => {
// @ts-expect-error -- intentionally using invalid tool name
expect(getToolConfig('unknown-tool')).toBeUndefined();
});
});
describe('resolveConstraint()', () => {
it('returns from config', async () => {
await expect(
resolveConstraint({ toolName: 'composer', constraint: '1.1.0' }),
).resolves.toBe('1.1.0');
});
it('returns highest stable', async () => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '1.0.0' },
{ version: '1.1.0' },
{ version: '1.3.0' },
{ version: '2.0.14' },
{ version: '2.1.0' },
{ version: '2.2.0-pre.0' },
],
});
await expect(resolveConstraint({ toolName: 'composer' })).resolves.toBe(
'2.1.0',
);
});
it('returns highest unstable', async () => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [{ version: '2.0.14-b.1' }, { version: '2.1.0-a.1' }],
});
await expect(resolveConstraint({ toolName: 'composer' })).resolves.toBe(
'2.1.0-a.1',
);
});
it('respects latest', async () => {
datasource.getPkgReleases.mockResolvedValueOnce({
tags: {
latest: '2.0.14',
},
releases: [
{ version: '1.0.0' },
{ version: '1.1.0' },
{ version: '1.3.0' },
{ version: '2.0.14' },
{ version: '2.1.0' },
{ version: '2.2.0-pre.0' },
],
});
await expect(resolveConstraint({ toolName: 'composer' })).resolves.toBe(
'2.0.14',
);
});
it('supports rust release channels', async () => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '1' },
{ version: '1.65' },
{ version: '1.65.0' },
],
});
await expect(resolveConstraint({ toolName: 'rust' })).resolves.toBe(
'1.65.0',
);
});
it('supports nightly rust release channels', async () => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [{ version: 'nightly' }, { version: 'nightly-2026-06-19' }],
});
await expect(resolveConstraint({ toolName: 'rust' })).resolves.toBe(
'nightly-2026-06-19',
);
});
it('throws for unknown tools', async () => {
await expect(
resolveConstraint({ toolName: 'whoops' as ToolName }),
).rejects.toThrow('Invalid tool to install: whoops');
});
it('throws no releases', async () => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [],
});
await expect(resolveConstraint({ toolName: 'composer' })).rejects.toThrow(
'No tool releases found.',
);
});
it('falls back to latest version if no compatible release', async () => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [{ version: '1.2.3' }],
});
await expect(
resolveConstraint({ toolName: 'composer', constraint: '^3.1.0' }),
).resolves.toBe('1.2.3');
});
it('falls back to latest version if invalid constraint', async () => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [{ version: '1.2.3' }],
});
await expect(
resolveConstraint({ toolName: 'composer', constraint: 'whoops' }),
).resolves.toBe('1.2.3');
});
it.each`
version | expected
${'^3.9.0'} | ${'3.10.4'}
${'^3.9'} | ${'3.10.4'}
${'3.9.*'} | ${'3.9.1'}
${'>3.8,<3.10'} | ${'3.9.1'}
${'==3.9.*'} | ${'3.9.1'}
`(
'supports python ranges "$version" => "$expected"',
async ({ version: constraint, expected }) => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '3.9.0' },
{ version: '3.9.1' },
{ version: '3.10.0' },
{ version: '3.10.4' },
],
});
await expect(
resolveConstraint({ toolName: 'python', constraint }),
).resolves.toBe(expected);
},
);
it('removes pep440 ==', async () => {
await expect(
resolveConstraint({
toolName: 'pipenv',
constraint: '==2020.8.13',
}),
).resolves.toBe('2020.8.13');
});
it.each`
version | expected
${'>=2.5.0 <2.6.0'} | ${'2.5.1'}
${'>=2.6.0-0.0.pre <2.7.0-0.0.pre'} | ${'2.6.0-0.0.pre'}
${'>=2.8.0'} | ${'2.8.1'}
${'<2.9.0-0.1.pre'} | ${'2.8.1'}
${'2.10.0-0.2.pre'} | ${'2.10.0-0.2.pre'}
${'>=2.11.0-0.1.pre'} | ${'2.12.0-4.1.pre'}
${'<=2.10.0'} | ${'2.8.1'}
`(
'supports flutter ranges "$version" => "$expected"',
async ({ version: constraint, expected }) => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '2.5.0-1.0.pre', isStable: false },
{ version: '2.5.0', isStable: true },
{ version: '2.5.1', isStable: true },
{ version: '2.6.0-0.0.pre', isStable: false },
{ version: '2.8.0', isStable: true },
{ version: '2.8.1', isStable: true },
{ version: '2.9.0-0.1.pre', isStable: false },
{ version: '2.12.0-4.1.pre', isStable: false },
],
});
await expect(
resolveConstraint({ toolName: 'flutter', constraint }),
).resolves.toBe(expected);
},
);
it.each`
version | expected
${'>2.17.0 <2.17.8'} | ${'2.17.7'}
${'>2.19.0'} | ${'2.19.0-81.0.dev'}
${'<=2.17.5'} | ${'2.17.5'}
${'<2.17.5'} | ${'2.19.0-81.0.dev'}
${'<2.17.6'} | ${'2.17.5'}
`(
'supports dart ranges "$version" => "$expected"',
async ({ version: constraint, expected }) => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '2.17.0-69.2.beta', isStable: false },
{ version: '2.17.0-7.0.dev', isStable: false },
{ version: '2.17.5', isStable: true },
{ version: '2.17.6', isStable: true },
{ version: '2.17.7', isStable: true },
{ version: '2.18.0', isStable: true },
{ version: '2.18.0-44.1.beta', isStable: false },
{ version: '2.18.0-99.0.dev', isStable: false },
{ version: '2.18.4', isStable: true },
{ version: '2.18.5', isStable: true },
{ version: '2.19.0-255.2.beta', isStable: false },
{ version: '2.19.0-81.0.dev', isStable: false },
],
});
await expect(
resolveConstraint({ toolName: 'dart', constraint }),
).resolves.toBe(expected);
},
);
});
describe('resolveConstraint() with resolved tools', () => {
// run the real constraints filtering against npm-like releases
function mockNpmReleases(...nodeConstraints: string[]): void {
datasource.getPkgReleases.mockImplementation((config) =>
Promise.resolve(
applyConstraintsFiltering(
{
releases: nodeConstraints.map((node, i) => ({
version: `${11 + i}.0.0`,
constraints: { node: [node] },
})),
},
config,
),
),
);
}
it('picks the newest release compatible with the parent version', async () => {
mockNpmReleases('^20.17.0 || >=22.9.0', '^22.22.2 || >=24.15.0');
await expect(
resolveConstraint({ toolName: 'npm' }, { node: '20.17.0' }),
).resolves.toBe('11.0.0');
});
it('picks the newest release when the parent version supports it', async () => {
mockNpmReleases('^20.17.0 || >=22.9.0', '^22.22.2 || >=24.15.0');
await expect(
resolveConstraint({ toolName: 'npm' }, { node: '24.15.0' }),
).resolves.toBe('12.0.0');
});
it('ignores the parent version when no release is compatible', async () => {
mockNpmReleases('^22.9.0', '^22.22.2 || >=24.15.0');
await expect(
resolveConstraint({ toolName: 'npm' }, { node: '20.17.0' }),
).resolves.toBe('12.0.0');
expect(datasource.getPkgReleases).toHaveBeenLastCalledWith({
datasource: 'npm',
packageName: 'npm',
versioning: 'npm',
});
});
});
describe('generateInstallCommands()', () => {
beforeEach(() => {
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '1.0.0' },
{ version: '1.1.0' },
{ version: '1.3.0' },
{ version: '2.0.14' },
{ version: '2.1.0' },
],
});
});
it('returns install commands', async () => {
const toolConstraints: ToolConstraint[] = [
{
toolName: 'composer',
},
];
await expect(generateInstallCommands(toolConstraints)).resolves.toEqual([
'install-tool composer 2.1.0',
]);
});
it('only matches a tool against its own parent', async () => {
const toolConstraints: ToolConstraint[] = [
{ toolName: 'node', constraint: '20.17.0' },
{ toolName: 'composer' },
];
await expect(generateInstallCommands(toolConstraints)).resolves.toEqual([
'install-tool node 20.17.0',
'install-tool composer 2.1.0',
]);
expect(datasource.getPkgReleases).toHaveBeenCalledExactlyOnceWith({
datasource: 'github-releases',
packageName: 'containerbase/composer-prebuild',
versioning: 'composer',
});
});
it('filters tool releases by the resolved parent version', async () => {
const toolConstraints: ToolConstraint[] = [
{ toolName: 'node', constraint: '20.17.0' },
{ toolName: 'npm', constraint: '>=1' },
];
await expect(generateInstallCommands(toolConstraints)).resolves.toEqual([
'install-tool node 20.17.0',
'install-tool npm 2.1.0',
]);
expect(datasource.getPkgReleases).toHaveBeenCalledExactlyOnceWith({
datasource: 'npm',
packageName: 'npm',
versioning: 'npm',
constraintsFiltering: 'strict',
constraints: { node: '20.17.0' },
});
});
it('repeats install commands when not memoizing', async () => {
const toolConstraints: ToolConstraint[] = [{ toolName: 'composer' }];
await generateInstallCommands(toolConstraints);
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [{ version: '2.1.0' }],
});
await expect(generateInstallCommands(toolConstraints)).resolves.toEqual([
'install-tool composer 2.1.0',
]);
});
describe('memoize', () => {
beforeEach(() => {
memCache.init();
});
afterEach(() => {
memCache.reset();
});
it('skips a tool already installed this run at the same version', async () => {
const toolConstraints: ToolConstraint[] = [{ toolName: 'composer' }];
await expect(
generateInstallCommands(toolConstraints, true),
).resolves.toEqual(['install-tool composer 2.1.0']);
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [{ version: '2.1.0' }],
});
await expect(
generateInstallCommands(toolConstraints, true),
).resolves.toEqual([]);
});
it('re-issues the install command when the resolved version changes', async () => {
const toolConstraints: ToolConstraint[] = [{ toolName: 'composer' }];
await expect(
generateInstallCommands(toolConstraints, true),
).resolves.toEqual(['install-tool composer 2.1.0']);
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [{ version: '2.2.0' }],
});
await expect(
generateInstallCommands(toolConstraints, true),
).resolves.toEqual(['install-tool composer 2.2.0']);
});
it('re-issues the install command when switching back to an earlier version', async () => {
const toolConstraints: ToolConstraint[] = [{ toolName: 'composer' }];
await expect(
generateInstallCommands(toolConstraints, true),
).resolves.toEqual(['install-tool composer 2.1.0']);
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [{ version: '2.2.0' }],
});
await expect(
generateInstallCommands(toolConstraints, true),
).resolves.toEqual(['install-tool composer 2.2.0']);
datasource.getPkgReleases.mockResolvedValueOnce({
releases: [{ version: '2.1.0' }],
});
await expect(
generateInstallCommands(toolConstraints, true),
).resolves.toEqual(['install-tool composer 2.1.0']);
});
});
});
});