Files
renovate/lib/modules/manager/util.spec.ts
Sebastian Poxhofer 4220c0fe36 feat(manager): share the .npmrc host-rule wrapper between bun, deno and npm (#46131)
* refactor(manager): share the .npmrc host-rule wrapper between bun, deno and npm

Adds `withNpmrcHostRules()` to the npm manager utils: it injects the host-rule lines into a directory's `.npmrc`, runs the callback, and restores the original `.npmrc` from a `finally` block. bun, deno, the corepack `updateArtifacts()` and the three lock file loops in `post-update/index.ts` now share it instead of repeating getNpmrcContent / updateNpmrcContent / resetNpmrcContent.

Adds `readUpdatedBinaryLockFile()` to the shared manager utils for the `Buffer.compare` plus file-addition block that bun and deno both carried.

Behaviour changes:

- bun and deno now restore `.npmrc` when `exec()` throws. The reset previously ran only after a successful install, so a failed lock file update left the injected registry credentials in the working tree. Covered by "restores .npmrc when the install fails" in `bun/artifacts.spec.ts` and `deno/artifacts.spec.ts`.
- The npm, yarn and pnpm loops in `post-update/index.ts` now restore `.npmrc` when the loop body throws an `ExternalHostError`, for the same reason.
- deno reads `config.isLockFileMaintenance` instead of deriving the flag from `config.updateType === 'lockFileMaintenance'`. Grouped branches overwrite `updateType` while `isLockFileMaintenance` is set explicitly, so the flag is the reliable source. The deno specs were updated to set it.

* refactor(manager): let updateLockFile() compare binary lock files

A Buffer as the existing content makes the skeleton read, compare and
return the new lock file as bytes, so bun and deno use it instead of a
separate readUpdatedBinaryLockFile() helper.

* refactor(manager/devbox): update the lock file through updateLockFile()

devbox kept the same Buffer compare as bun and deno; the trace log before
returning the lock file goes away with it.

* fix(manager/devbox): type the command list explicitly

The list is now only read inside the run() closure, where TypeScript
cannot resolve an evolving array type, so tsc reported an implicit any.
2026-09-18 09:30:27 +00:00

456 lines
13 KiB
TypeScript

import { fs } from '~test/util.ts';
import * as hostRules from '../../util/host-rules.ts';
import { GitRefsDatasource } from '../datasource/git-refs/index.ts';
import { GitTagsDatasource } from '../datasource/git-tags/index.ts';
import { GithubTagsDatasource } from '../datasource/github-tags/index.ts';
import { GitlabTagsDatasource } from '../datasource/gitlab-tags/index.ts';
import { type PackageDependency } from './types.ts';
import {
applyGitSource,
artifactError,
artifactErrorMessageFromExecError,
artifactErrorResult,
fileAddition,
fileChangesToArtifactResults,
resolveToolConstraint,
updateLockFile,
} from './util.ts';
vi.mock('../../util/fs/index.ts');
describe('modules/manager/util', () => {
beforeEach(() => {
hostRules.clear();
});
it('applies GitHub source for tag', () => {
const dependency: PackageDependency = {};
const git = 'https://github.com/foo/bar';
const tag = 'v1.2.3';
applyGitSource(dependency, git, undefined, tag, undefined);
expect(dependency).toStrictEqual({
datasource: GithubTagsDatasource.id,
registryUrls: ['https://github.com'],
packageName: 'foo/bar',
currentValue: tag,
skipReason: undefined,
});
});
it('applies GitLab source for tag', () => {
const dependency: PackageDependency = {};
const git = 'https://gitlab.com/foo/bar';
const tag = 'v1.2.3';
applyGitSource(dependency, git, undefined, tag, undefined);
expect(dependency).toStrictEqual({
datasource: GitlabTagsDatasource.id,
registryUrls: ['https://gitlab.com'],
packageName: 'foo/bar',
currentValue: tag,
skipReason: undefined,
});
});
it('applies other git source for tag', () => {
const dependency: PackageDependency = {};
const git = 'https://a-git-source.com/foo/bar';
const tag = 'v1.2.3';
applyGitSource(dependency, git, undefined, tag, undefined);
expect(dependency).toStrictEqual({
datasource: GitTagsDatasource.id,
packageName: git,
currentValue: tag,
skipReason: undefined,
});
});
it('applies git source with subdomain', () => {
const dependency: PackageDependency = {};
const git = 'https://git.example.com/foo/bar';
const tag = 'v1.2.3';
hostRules.add({
hostType: 'github',
matchHost: 'git.example.com',
});
applyGitSource(dependency, git, undefined, tag, undefined);
expect(dependency).toStrictEqual({
datasource: GithubTagsDatasource.id,
packageName: 'foo/bar',
currentValue: tag,
registryUrls: ['https://git.example.com'],
skipReason: undefined,
});
});
it('applies GitHub source for tag with SSH URL', () => {
const dependency: PackageDependency = {};
const git = 'ssh://git@github.com/foo/bar';
const tag = 'v1.2.3';
applyGitSource(dependency, git, undefined, tag, undefined);
expect(dependency).toStrictEqual({
datasource: GithubTagsDatasource.id,
registryUrls: ['https://github.com'],
packageName: 'foo/bar',
currentValue: tag,
skipReason: undefined,
});
});
it('applies GitLab source for tag with SSH URL', () => {
const dependency: PackageDependency = {};
const git = 'ssh://git@gitlab.com/foo/bar';
const tag = 'v1.2.3';
applyGitSource(dependency, git, undefined, tag, undefined);
expect(dependency).toStrictEqual({
datasource: GitlabTagsDatasource.id,
registryUrls: ['https://gitlab.com'],
packageName: 'foo/bar',
currentValue: tag,
skipReason: undefined,
});
});
it('applies GitHub source for tag with HTTPS URL', () => {
const dependency: PackageDependency = {};
const git = 'https://github.com/foo/bar';
const tag = 'v1.2.3';
applyGitSource(dependency, git, undefined, tag, undefined);
expect(dependency).toStrictEqual({
datasource: GithubTagsDatasource.id,
registryUrls: ['https://github.com'],
packageName: 'foo/bar',
currentValue: tag,
skipReason: undefined,
});
});
it('applies git source for rev', () => {
const dependency: PackageDependency = {};
const git = 'https://github.com/foo/bar';
const rev = 'abc1234';
applyGitSource(dependency, git, rev, undefined, undefined);
expect(dependency).toStrictEqual({
datasource: GitRefsDatasource.id,
packageName: git,
currentDigest: rev,
replaceString: rev,
skipReason: undefined,
});
});
it('skips git source for branch', () => {
const dependency: PackageDependency = {};
const git = 'https://github.com/foo/bar';
const branch = 'main';
applyGitSource(dependency, git, undefined, undefined, branch);
expect(dependency).toStrictEqual({
datasource: GitRefsDatasource.id,
packageName: git,
currentValue: branch,
skipReason: 'git-dependency',
});
});
it('skips git source for git only', () => {
const dependency: PackageDependency = {};
const git = 'https://github.com/foo/bar';
applyGitSource(dependency, git, undefined, undefined, undefined);
expect(dependency).toStrictEqual({
datasource: GitRefsDatasource.id,
packageName: git,
currentValue: undefined,
skipReason: 'unspecified-version',
});
});
});
describe('modules/manager/util', () => {
it('returns stderr when present', () => {
const message = artifactErrorMessageFromExecError(
{ stderr: 'some error', stdout: 'some output' },
'fallback message',
);
expect(message).toBe('some error');
});
it('returns stdout when stderr is empty', () => {
const message = artifactErrorMessageFromExecError(
{ stderr: '', stdout: 'some output' },
'fallback message',
);
expect(message).toBe('some output');
});
it('returns stdout when stderr is only whitespace', () => {
const message = artifactErrorMessageFromExecError(
{ stderr: ' ', stdout: 'some output' },
'fallback message',
);
expect(message).toBe('some output');
});
it('returns stdout when stderr is undefined', () => {
const message = artifactErrorMessageFromExecError(
{ stdout: 'some output' },
'fallback message',
);
expect(message).toBe('some output');
});
it('returns fallback message when neither stderr nor stdout are present', () => {
const message = artifactErrorMessageFromExecError({}, 'fallback message');
expect(message).toBe('fallback message');
});
it('returns fallback message when stderr and stdout are only whitespace', () => {
const message = artifactErrorMessageFromExecError(
{ stderr: ' ', stdout: ' ' },
'fallback message',
);
expect(message).toBe('fallback message');
});
it('wraps file changes into artifact results', () => {
expect(
fileChangesToArtifactResults([
{ type: 'addition', path: 'foo', contents: 'bar' },
{ type: 'deletion', path: 'baz' },
]),
).toEqual([
{ file: { type: 'addition', path: 'foo', contents: 'bar' } },
{ file: { type: 'deletion', path: 'baz' } },
]);
});
describe('resolveToolConstraint()', () => {
it('prefers the user configured constraint', async () => {
const constraint = await resolveToolConstraint(
{
constraints: { python: '==3.12' },
extractedConstraints: { python: '==3.10' },
},
'python',
() => '==3.11',
);
expect(constraint).toBe('==3.12');
});
it('prefers the derived constraint over the extracted one', async () => {
const constraint = await resolveToolConstraint(
{ extractedConstraints: { python: '==3.10' } },
'python',
() => Promise.resolve('==3.11'),
);
expect(constraint).toBe('==3.11');
});
it('falls back to the extracted constraint', async () => {
const constraint = await resolveToolConstraint(
{ extractedConstraints: { python: '==3.10' } },
'python',
() => null,
);
expect(constraint).toBe('==3.10');
});
it('returns the extracted constraint when nothing can be derived', async () => {
const constraint = await resolveToolConstraint(
{ extractedConstraints: { python: '==3.10' } },
'python',
);
expect(constraint).toBe('==3.10');
});
it('returns undefined when no constraint is known', async () => {
const constraint = await resolveToolConstraint({}, 'python', () => null);
expect(constraint).toBeUndefined();
});
it('treats an empty string as not set', async () => {
const constraint = await resolveToolConstraint(
{
constraints: { python: '' },
extractedConstraints: { python: '==3.10' },
},
'python',
() => '',
);
expect(constraint).toBe('==3.10');
});
it('accepts the null constraints of a post-update config', async () => {
const constraint = await resolveToolConstraint(
{ constraints: null, extractedConstraints: null },
'node',
);
expect(constraint).toBeUndefined();
});
});
it('builds an addition result', () => {
expect(fileAddition('foo.lock', 'new')).toEqual({
file: { type: 'addition', path: 'foo.lock', contents: 'new' },
});
});
it('builds an artifact error result', () => {
expect(artifactError('foo.lock', 'boom')).toEqual({
artifactError: { fileName: 'foo.lock', stderr: 'boom' },
});
});
it('builds an artifact error result from an error', () => {
expect(
artifactErrorResult('foo.lock', Object.assign(new Error('msg'), {})),
).toEqual([{ artifactError: { fileName: 'foo.lock', stderr: 'msg' } }]);
expect(
artifactErrorResult(
'foo.lock',
Object.assign(new Error('msg'), { stderr: 'from stderr' }),
),
).toEqual([
{ artifactError: { fileName: 'foo.lock', stderr: 'from stderr' } },
]);
});
describe('updateLockFile', () => {
it('writes the package file, deletes the lock file and returns the update', async () => {
fs.readLocalFile.mockResolvedValueOnce('new content');
const run = vi.fn().mockResolvedValue(undefined);
const res = await updateLockFile({
lockFileName: 'foo.lock',
existingLockFileContent: 'old content',
packageFile: { path: 'foo.json', contents: 'new package file' },
deleteLockFile: true,
run,
});
expect(res).toEqual([
{
file: {
type: 'addition',
path: 'foo.lock',
contents: 'new content',
},
},
]);
expect(fs.writeLocalFile).toHaveBeenCalledWith(
'foo.json',
'new package file',
);
expect(fs.deleteLocalFile).toHaveBeenCalledWith('foo.lock');
expect(fs.readLocalFile).toHaveBeenCalledWith('foo.lock', 'utf8');
expect(run).toHaveBeenCalledOnce();
});
it('keeps the package and lock file when not asked to touch them', async () => {
fs.readLocalFile.mockResolvedValueOnce('new content');
await updateLockFile({
lockFileName: 'foo.lock',
existingLockFileContent: 'old content',
run: () => Promise.resolve(),
});
expect(fs.writeLocalFile).not.toHaveBeenCalled();
expect(fs.deleteLocalFile).not.toHaveBeenCalled();
});
it('returns null when the lock file is gone', async () => {
fs.readLocalFile.mockResolvedValueOnce(null);
await expect(
updateLockFile({
lockFileName: 'foo.lock',
existingLockFileContent: 'old content',
run: () => Promise.resolve(),
}),
).resolves.toBeNull();
});
it('returns null when the lock file is unchanged', async () => {
fs.readLocalFile.mockResolvedValueOnce('old content');
await expect(
updateLockFile({
lockFileName: 'foo.lock',
existingLockFileContent: 'old content',
run: () => Promise.resolve(),
}),
).resolves.toBeNull();
});
it('reads and returns the lock file as bytes when given a Buffer', async () => {
fs.readLocalFile.mockResolvedValueOnce(
Buffer.from('new content') as never,
);
const res = await updateLockFile({
lockFileName: 'foo.lockb',
existingLockFileContent: Buffer.from('old content'),
run: () => Promise.resolve(),
});
expect(res).toEqual([
{
file: {
type: 'addition',
path: 'foo.lockb',
contents: Buffer.from('new content'),
},
},
]);
expect(fs.readLocalFile).toHaveBeenCalledWith('foo.lockb');
});
it('returns null when a binary lock file is unchanged', async () => {
fs.readLocalFile.mockResolvedValueOnce(
Buffer.from('old content') as never,
);
await expect(
updateLockFile({
lockFileName: 'foo.lockb',
existingLockFileContent: Buffer.from('old content'),
run: () => Promise.resolve(),
}),
).resolves.toBeNull();
});
});
});