import { exec, mockExecAll } from '~test/exec-util.ts'; import { hostRules, partial } from '~test/util.ts'; import { GlobalConfig } from '../../../config/global.ts'; import { ExecError } from '../../../util/exec/exec-error.ts'; import { localPathIsSymbolicLink, readLocalSymlink, } from '../../../util/fs/index.ts'; import { getRepoStatus } from '../../../util/git/index.ts'; import type { StatusResult } from '../../../util/git/types.ts'; import type { UpdateArtifact } from '../types.ts'; import { updateArtifacts } from './index.ts'; vi.mock('../../../util/fs/index.ts'); const getRepoStatusMock = vi.mocked(getRepoStatus); const lstatsMock = vi.mocked(localPathIsSymbolicLink); const readlinkMock = vi.mocked(readLocalSymlink); describe('modules/manager/hermit/artifacts', () => { describe('updateArtifacts', () => { it('should run hermit install for packages and return updated files', async () => { lstatsMock.mockResolvedValue(true); readlinkMock.mockResolvedValue('hermit'); GlobalConfig.set({ localDir: '', binarySource: 'global' }); const execSnapshots = mockExecAll(); getRepoStatusMock.mockResolvedValue( partial({ not_added: ['bin/go-1.17.1'], deleted: ['bin/go-1.17'], modified: ['bin/go', 'bin/jq'], created: ['bin/jq-extra'], renamed: [ { from: 'bin/jq-1.5', to: 'bin/jq-1.6', }, ], }), ); const res = await updateArtifacts( partial({ updatedDeps: [ { depName: 'go', currentVersion: '1.17', newValue: '1.17.1', }, { depName: 'jq', currentVersion: '1.5', newValue: '1.6', }, ], packageFileName: 'go/bin/hermit', }), ); expect(execSnapshots).toMatchObject([ { cmd: './hermit install go-1.17.1 jq-1.6' }, ]); expect(res).toStrictEqual([ { file: { path: 'bin/jq-1.5', type: 'deletion', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/jq-1.6', type: 'addition', }, }, { file: { path: 'bin/go', type: 'deletion', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/go', type: 'addition', }, }, { file: { path: 'bin/jq', type: 'deletion', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/jq', type: 'addition', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/jq-extra', type: 'addition', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/go-1.17.1', type: 'addition', }, }, { file: { path: 'bin/go-1.17', type: 'deletion', }, }, ]); }); it('should uninstall old package for name replacement', async () => { lstatsMock.mockResolvedValue(true); readlinkMock.mockResolvedValue('hermit'); GlobalConfig.set({ localDir: '', binarySource: 'global' }); const execSnapshots = mockExecAll(); getRepoStatusMock.mockResolvedValue( partial({ not_added: [], deleted: [], modified: ['bin/java', 'bin/javac', 'bin/jar'], renamed: [ { from: 'bin/.openjdk-17.0.3', to: 'bin/.openjdk-17.0.4.1_1', }, ], created: [], }), ); const res = await updateArtifacts( partial({ updatedDeps: [ { depName: 'openjdk', newName: 'openjre', currentVersion: '17.0.3', newValue: '17.0.4.1_1', updateType: 'replacement', }, ], packageFileName: 'go/bin/hermit', }), ); expect(execSnapshots).toMatchObject([ { cmd: './hermit uninstall openjdk' }, { cmd: './hermit install openjre-17.0.4.1_1' }, ]); expect(res).toStrictEqual([ { file: { path: 'bin/.openjdk-17.0.3', type: 'deletion', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/.openjdk-17.0.4.1_1', type: 'addition', }, }, { file: { path: 'bin/java', type: 'deletion', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/java', type: 'addition', }, }, { file: { path: 'bin/javac', type: 'deletion', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/javac', type: 'addition', }, }, { file: { path: 'bin/jar', type: 'deletion', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/jar', type: 'addition', }, }, ]); }); it('should not uninstall package for version only replcaement', async () => { lstatsMock.mockResolvedValue(true); readlinkMock.mockResolvedValue('hermit'); GlobalConfig.set({ localDir: '', binarySource: 'global' }); const execSnapshots = mockExecAll(); getRepoStatusMock.mockResolvedValue( partial({ not_added: [], deleted: [], modified: ['bin/go'], renamed: [ { from: 'bin/.go-1.19', to: 'bin/.go-1.18', }, ], created: [], }), ); const res = await updateArtifacts( partial({ updatedDeps: [ { depName: 'go', newName: 'go', currentVersion: '1.19', newValue: '1.18', updateType: 'replacement', }, ], packageFileName: 'go/bin/hermit', }), ); expect(execSnapshots).toMatchObject([ { cmd: './hermit install go-1.18' }, ]); expect(res).toStrictEqual([ { file: { path: 'bin/.go-1.19', type: 'deletion', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/.go-1.18', type: 'addition', }, }, { file: { path: 'bin/go', type: 'deletion', }, }, { file: { contents: 'hermit', isSymlink: true, isExecutable: undefined, path: 'bin/go', type: 'addition', }, }, ]); }); it.each([ { name: 'returns an update error when uninstallation execution fails', error: new ExecError('', { stdout: '', stderr: 'error executing hermit uninstall', cmd: '', options: {}, }), artifactError: { fileName: 'from: openjdk-17.0.3, to: openjdk', stderr: 'error executing hermit uninstall', }, }, { name: 'returns a generic error when uninstallation setup fails', error: new Error('unexpected environment preparation failure'), artifactError: { stderr: 'unexpected environment preparation failure', }, }, ])('$name', async ({ error, artifactError }) => { lstatsMock.mockResolvedValue(true); readlinkMock.mockResolvedValue(null); GlobalConfig.set({ localDir: '', binarySource: 'global' }); mockExecAll(error); getRepoStatusMock.mockResolvedValue( partial({ not_added: [], deleted: [], modified: [], created: [], renamed: [], }), ); const res = await updateArtifacts( partial({ updatedDeps: [ { depName: 'openjdk', newName: 'openjre', currentVersion: '17.0.3', newValue: '17.0.4.1_1', updateType: 'replacement', }, ], packageFileName: 'go/bin/hermit', }), ); expect(res).toEqual([ { artifactError, }, ]); }); it('should fail on error getting link content', async () => { lstatsMock.mockResolvedValue(true); readlinkMock.mockResolvedValue(null); GlobalConfig.set({ localDir: '', binarySource: 'global' }); mockExecAll(); getRepoStatusMock.mockResolvedValue( partial({ not_added: [], deleted: [], modified: [], created: [], renamed: [ { from: 'bin/jq-1.5', to: 'bin/jq-1.6', }, ], }), ); const res = await updateArtifacts( partial({ updatedDeps: [ { depName: 'go', currentVersion: '1.17', newValue: '1.17.1', }, { depName: 'jq', currentVersion: '1.5', newValue: '1.6', }, ], packageFileName: 'go/bin/hermit', }), ); expect(res).toEqual([ { artifactError: { stderr: 'error getting content for bin/jq-1.6', }, }, ]); }); it('should return error on installation error', async () => { mockExecAll( new ExecError('', { stdout: '', stderr: 'error executing hermit install', cmd: '', options: {}, }), ); const res = await updateArtifacts( partial({ updatedDeps: [ { depName: 'go', currentVersion: '1.17', newValue: '1.17.1', }, { depName: 'jq', currentVersion: '1.5', newValue: '1.6', }, ], packageFileName: 'go/bin/hermit', }), ); expect(res).toStrictEqual([ { artifactError: { fileName: 'from: go-1.17 jq-1.5, to: go-1.17.1 jq-1.6', stderr: 'error executing hermit install', }, }, ]); }); it('should return error on invalid update information', async () => { let res = await updateArtifacts( partial({ updatedDeps: [ { currentVersion: '1.17', newValue: '1.17.1', }, ], packageFileName: 'go/bin/hermit', }), ); expect(res).toStrictEqual([ { artifactError: { fileName: 'from: -1.17, to: -1.17.1', stderr: `invalid package to update`, }, }, ]); res = await updateArtifacts( partial({ updatedDeps: [ { depName: 'go', newValue: '1.17.1', }, ], packageFileName: 'go/bin/hermit', }), ); expect(res).toStrictEqual([ { artifactError: { fileName: 'from: go-, to: go-1.17.1', stderr: `invalid package to update`, }, }, ]); res = await updateArtifacts( partial({ updatedDeps: [ { depName: 'go', currentVersion: '1.17', }, ], packageFileName: 'go/bin/hermit', }), ); expect(res).toStrictEqual([ { artifactError: { fileName: 'from: go-1.17, to: go-', stderr: `invalid package to update`, }, }, ]); }); it('prevents injections', async () => { lstatsMock.mockResolvedValue(true); readlinkMock.mockResolvedValue('hermit'); GlobalConfig.set({ localDir: '', binarySource: 'global' }); const execSnapshots = mockExecAll(); await updateArtifacts({ updatedDeps: [ { depName: '|| date; echo ', currentVersion: '1.0.0', newValue: '1.0.1', }, { depName: '|| whoami', currentVersion: '1.18.0', newName: 'go', newValue: '1.24.1', updateType: 'replacement', }, ], packageFileName: './bin/hermit', newPackageFileContent: '', config: {}, }); expect(execSnapshots).toMatchObject([ { cmd: "./hermit uninstall '|| whoami'", }, { cmd: "./hermit install '|| date; echo -1.0.1' go-1.24.1", }, ]); }); it('propagates git credentials from hostRules to hermit install', async () => { lstatsMock.mockResolvedValue(true); readlinkMock.mockResolvedValue('hermit'); GlobalConfig.set({ localDir: '', binarySource: 'global' }); hostRules.add({ hostType: 'github', matchHost: 'github.com', token: 'abc123', }); hostRules.add({ hostType: 'hermit', matchHost: 'hermit.example.com', username: 'hermit-user', password: 'hermit-pass', }); const execSnapshots = mockExecAll(); getRepoStatusMock.mockResolvedValue( partial({ not_added: [], deleted: [], modified: [], created: [], renamed: [], }), ); await updateArtifacts( partial({ updatedDeps: [ { depName: 'go', currentVersion: '1.17', newValue: '1.17.1', }, ], packageFileName: 'go/bin/hermit', }), ); expect(execSnapshots).toMatchObject([ { cmd: './hermit install go-1.17.1', options: { env: { GIT_CONFIG_COUNT: '6', GIT_CONFIG_KEY_0: 'url.https://ssh:abc123@github.com/.insteadOf', GIT_CONFIG_KEY_1: 'url.https://git:abc123@github.com/.insteadOf', GIT_CONFIG_KEY_2: 'url.https://abc123@github.com/.insteadOf', GIT_CONFIG_KEY_3: 'url.https://hermit-user:hermit-pass@hermit.example.com/.insteadOf', GIT_CONFIG_KEY_4: 'url.https://hermit-user:hermit-pass@hermit.example.com/.insteadOf', GIT_CONFIG_KEY_5: 'url.https://hermit-user:hermit-pass@hermit.example.com/.insteadOf', GIT_CONFIG_VALUE_0: 'ssh://git@github.com/', GIT_CONFIG_VALUE_1: 'git@github.com:', GIT_CONFIG_VALUE_2: 'https://github.com/', GIT_CONFIG_VALUE_3: 'ssh://git@hermit.example.com/', GIT_CONFIG_VALUE_4: 'git@hermit.example.com:', GIT_CONFIG_VALUE_5: 'https://hermit.example.com/', }, }, }, ]); }); it('returns generic error when a non-UpdateHermitError propagates from updateHermitPackage', async () => { mockExecAll(new Error('unexpected execution failure')); const res = await updateArtifacts( partial({ updatedDeps: [ { depName: 'go', currentVersion: '1.17', newValue: '1.17.1', }, ], packageFileName: 'go/bin/hermit', }), ); expect(res).toStrictEqual([ { artifactError: { stderr: 'unexpected execution failure', }, }, ]); }); it('stringifies non-Error thrown values in the generic fallback', async () => { exec.mockImplementationOnce(() => { // eslint-disable-next-line @typescript-eslint/only-throw-error -- deliberately testing non-Error throw path throw 'raw string failure'; }); const res = await updateArtifacts( partial({ updatedDeps: [ { depName: 'go', currentVersion: '1.17', newValue: '1.17.1', }, ], packageFileName: 'go/bin/hermit', }), ); expect(res).toStrictEqual([ { artifactError: { stderr: 'raw string failure', }, }, ]); }); }); });