// oxlint-disable renovate/prefer-stub-env -- these tests assert on the *whole* // env handed to the child process, so they need `process.env` to hold exactly // the fixture and nothing else. `vi.stubEnv()` cannot express that: it refuses // to delete `PROD`, `DEV` and `SSR`, setting them to '' instead, and those // would then show up in the `exposeAllEnv` expectations. Nothing here stubs, // so the two styles are not mixed. import { mockDeep } from 'vitest-mock-extended'; import { exec as cpExec, envMock } from '~test/exec-util.ts'; import { logger } from '~test/util.ts'; import { GlobalConfig } from '../../config/global.ts'; import type { RepoGlobalConfig } from '../../config/types.ts'; import { TEMPORARY_ERROR } from '../../constants/error-messages.ts'; import * as _datasource from '../../modules/datasource/index.ts'; import type { UpdateArtifactsConfig } from '../../modules/manager/types.ts'; import * as memCache from '../cache/memory/index.ts'; import { setCustomEnv } from '../env.ts'; import { coerceObject } from '../object.ts'; import * as dockerModule from './docker/index.ts'; import { hardcodedProcessEnv } from './env.ts'; import { getHermitEnvs } from './hermit.ts'; import { exec, getToolSettingsOptions, gradleJvmArg } from './index.ts'; import type { CommandWithOptions, ExecOptions, ExecResult, RawExecOptions, VolumeOption, } from './types.ts'; import { asRawCommand } from './utils.ts'; const getHermitEnvsMock = vi.mocked(getHermitEnvs); vi.mock('./hermit.ts', async () => ({ ...(await vi.importActual('./hermit.ts')), getHermitEnvs: vi.fn(), })); vi.mock('../../modules/datasource/index.ts', () => mockDeep()); const datasource = vi.mocked(_datasource); interface TestInput { processEnv: Record; inCmd: string | string[]; inOpts?: ExecOptions; outCmd: string[]; outOpts: RawExecOptions[]; adminConfig?: Partial; hermitEnvs?: Record; } describe('util/exec/index', () => { let processEnvOrig: NodeJS.ProcessEnv; const cacheDir = '/tmp/renovate/cache/'; const containerbaseDir = '/tmp/renovate/cache/containerbase'; const cwd = '/tmp/renovate/github/some/repo/'; const defaultCwd = `-w "${cwd}"`; const defaultCacheVolume = `-v "${cacheDir}":"${cacheDir}"`; const defaultVolumes = `-v "${cwd}":"${cwd}" ${defaultCacheVolume}`; const globalConfig: RepoGlobalConfig = { cacheDir, containerbaseDir, dockerSidecarImage: 'ghcr.io/renovatebot/base-image', }; beforeEach(() => { dockerModule.resetPrefetchedImages(); vi.restoreAllMocks(); processEnvOrig = process.env; GlobalConfig.reset(); setCustomEnv({}); }); afterEach(() => { process.env = processEnvOrig; memCache.reset(); }); const sideCarName = dockerModule.sideCarName; const fullImage = `ghcr.io/renovatebot/base-image`; const name = `renovate_${sideCarName}`; const inCmd = 'echo hello'; const outCmd = ['echo hello']; const volume_1 = '/path/to/volume-1'; const volume_2_from = '/path/to/volume-2'; const volume_2_to = '/path/to/volume-3'; const volumes: VolumeOption[] = [ volume_1, null, undefined, [volume_2_from, volume_2_to], ]; const docker = {}; const processEnv = envMock.full; const dockerPullCmd = `docker pull ${fullImage}`; const dockerRemoveCmd = `docker ps --filter name=${name} -aq`; const dockerPullOpts = {}; const dockerRemoveOpts = dockerPullOpts; const containerbaseEnv = { ...envMock.basic, CONTAINERBASE_CACHE_DIR: `${cacheDir}containerbase`, }; const containerbaseEnvFiltered = { ...envMock.filtered, CONTAINERBASE_CACHE_DIR: `${cacheDir}containerbase`, }; const testInputs: [string, TestInput][] = [ [ 'Single command', { processEnv, inCmd, inOpts: {}, outCmd, outOpts: [ { cwd, env: envMock.basic, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], }, ], [ 'Command without options', { processEnv, inCmd, inOpts: undefined, outCmd, outOpts: [ { cwd, env: envMock.basic, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], }, ], [ 'Multiple commands', { processEnv, inCmd: ['echo "begin"', inCmd, "echo 'end'"], inOpts: {}, outCmd: ['echo "begin"', ...outCmd, "echo 'end'"], outOpts: [ { cwd, env: envMock.basic, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, { cwd, env: envMock.basic, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, { cwd, env: envMock.basic, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], }, ], [ 'Explicit env option', { processEnv, inCmd, inOpts: { env: { FOO: 'BAR' } }, outCmd, outOpts: [ { cwd, env: { ...envMock.basic, FOO: 'BAR' }, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], }, ], [ 'By default, exposeAllEnv=false', { processEnv, inCmd, inOpts: {}, outCmd, outOpts: [ { cwd, env: envMock.basic, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], }, ], [ 'When exposeAllEnv=true, all environment variables are passed to child', { processEnv: envMock.full, inCmd, inOpts: {}, outCmd, outOpts: [ { cwd, env: envMock.full, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { exposeAllEnv: true }, }, ], [ 'Docker', { processEnv, inCmd, inOpts: { docker, cwd }, outCmd: [ dockerPullCmd, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child ${defaultVolumes} -e CI -e CONTAINERBASE_CACHE_DIR ${defaultCwd} ${fullImage} bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: containerbaseEnv, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Explicit Docker env vars', { processEnv, inCmd, inOpts: { docker: { envVars: ['FORCED_ENV_VAR'] }, env: { FORCED_ENV_VAR: 'forced' }, cwd, }, outCmd: [ dockerPullCmd, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child ${defaultVolumes} -e CI -e FORCED_ENV_VAR -e CONTAINERBASE_CACHE_DIR ${defaultCwd} ${fullImage} bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: { ...containerbaseEnv, FORCED_ENV_VAR: 'forced', }, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Extra env vars', { processEnv, inCmd, inOpts: { extraEnv: { SELECTED_ENV_VAR: envMock.full.SELECTED_ENV_VAR, FILTERED_ENV_VAR: null, FOO: null, BAR: undefined, }, }, outCmd, outOpts: [ { cwd, env: containerbaseEnvFiltered, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Extra env vars (Docker)', { processEnv, inCmd, inOpts: { docker, extraEnv: { SELECTED_ENV_VAR: envMock.full.SELECTED_ENV_VAR, FILTERED_ENV_VAR: null, FOO: null, BAR: undefined, }, cwd, }, outCmd: [ dockerPullCmd, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child ${defaultVolumes} -e CI -e SELECTED_ENV_VAR -e CONTAINERBASE_CACHE_DIR ${defaultCwd} ${fullImage} bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: containerbaseEnvFiltered, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Extra env vars defaults', { processEnv: envMock.basic, inCmd, inOpts: { cwd, extraEnv: { SELECTED_ENV_VAR: 'Default value' } }, outCmd, outOpts: [ { cwd, env: { ...containerbaseEnv, SELECTED_ENV_VAR: 'Default value' }, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Extra env vars defaults (Docker)', { processEnv: envMock.basic, inCmd, inOpts: { docker, extraEnv: { SELECTED_ENV_VAR: 'Default value' }, cwd, }, outCmd: [ dockerPullCmd, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child ${defaultVolumes} -e CI -e SELECTED_ENV_VAR -e CONTAINERBASE_CACHE_DIR ${defaultCwd} ${fullImage} bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: { ...containerbaseEnv, SELECTED_ENV_VAR: 'Default value' }, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Docker volumes', { processEnv, inCmd, inOpts: { cwd, docker: { volumes } }, outCmd: [ dockerPullCmd, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child ${defaultVolumes} -v "${volume_1}":"${volume_1}" -v "${volume_2_from}":"${volume_2_to}" -e CI -e CONTAINERBASE_CACHE_DIR -w "${cwd}" ${fullImage} bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: containerbaseEnv, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Docker user', { processEnv, inCmd, inOpts: { docker }, outCmd: [ dockerPullCmd, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child --user=foobar ${defaultVolumes} -e CI -e CONTAINERBASE_CACHE_DIR -w "${cwd}" ${fullImage} bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: containerbaseEnv, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { dockerUser: 'foobar', binarySource: 'docker', }, }, ], [ 'Docker image prefix', { processEnv, inCmd, inOpts: { docker }, outCmd: [ `docker pull ghcr.io/renovatebot/base-image`, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child ${defaultVolumes} -e CI -e CONTAINERBASE_CACHE_DIR -w "${cwd}" ghcr.io/renovatebot/base-image bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: containerbaseEnv, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { dockerSidecarImage: 'ghcr.io/renovatebot/base-image', binarySource: 'docker', }, }, ], [ 'Docker child prefix', { processEnv, inCmd, inOpts: { docker }, outCmd: [ dockerPullCmd, `docker ps --filter name=myprefix_${sideCarName} -aq`, `docker run --rm --name=myprefix_${sideCarName} --label=myprefix_child ${defaultVolumes} -e CI -e CONTAINERBASE_CACHE_DIR -w "${cwd}" ${fullImage} bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: containerbaseEnv, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { dockerChildPrefix: 'myprefix_', binarySource: 'docker', }, }, ], [ 'Docker extra commands', { processEnv, inCmd, inOpts: { docker, preCommands: ['preCommand1', 'preCommand2', null as never], }, outCmd: [ dockerPullCmd, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child ${defaultVolumes} -e CI -e CONTAINERBASE_CACHE_DIR -w "${cwd}" ${fullImage} bash -l -c 'preCommand1 && preCommand2 && ${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: containerbaseEnv, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Docker commands are nullable', { processEnv, inCmd, inOpts: { docker }, outCmd: [ dockerPullCmd, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child ${defaultVolumes} -e CI -e CONTAINERBASE_CACHE_DIR -w "${cwd}" ${fullImage} bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: containerbaseEnv, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Explicit timeout', { processEnv, inCmd, inOpts: { timeout: 20 * 60 * 1000, }, outCmd, outOpts: [ { cwd, env: containerbaseEnv, timeout: 20 * 60 * 1000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Default timeout from executionTimeout config option', { processEnv, inCmd, inOpts: {}, outCmd, outOpts: [ { cwd, env: envMock.basic, timeout: 30 * 60 * 1000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { executionTimeout: 30 }, }, ], [ 'Explicit maxBuffer', { processEnv, inCmd, inOpts: { maxBuffer: 1024, }, outCmd, outOpts: [ { cwd, env: containerbaseEnv, timeout: 900000, maxBuffer: 1024, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { binarySource: 'docker' }, }, ], [ 'Custom environment variables for child', { processEnv: envMock.basic, inCmd, inOpts: {}, outCmd, outOpts: [ { cwd, env: { ...containerbaseEnv, CUSTOM_KEY: 'CUSTOM_VALUE', }, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { customEnvVariables: { CUSTOM_KEY: 'CUSTOM_VALUE', }, binarySource: 'docker', }, }, ], [ 'Custom environment variables for child should override', { processEnv: { ...envMock.basic, CUSTOM_KEY: 'CUSTOM_VALUE' }, inCmd, inOpts: {}, outCmd, outOpts: [ { cwd, env: { ...containerbaseEnv, CUSTOM_KEY: 'CUSTOM_OVERRIDEN_VALUE', }, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { customEnvVariables: { CUSTOM_KEY: 'CUSTOM_OVERRIDEN_VALUE', }, binarySource: 'docker', }, }, ], [ 'Custom environment variables for child (Docker)', { processEnv, inCmd, inOpts: { docker, cwd }, outCmd: [ dockerPullCmd, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child ${defaultVolumes} -e CI -e CUSTOM_KEY -e CONTAINERBASE_CACHE_DIR ${defaultCwd} ${fullImage} bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: { ...containerbaseEnv, CUSTOM_KEY: 'CUSTOM_VALUE', }, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { customEnvVariables: { CUSTOM_KEY: 'CUSTOM_VALUE', }, binarySource: 'docker', }, }, ], [ 'Custom environment variables for child should override (Docker)', { processEnv: { ...envMock.basic, CUSTOM_KEY: 'CUSTOM_VALUE' }, inCmd, inOpts: { docker, cwd }, outCmd: [ dockerPullCmd, dockerRemoveCmd, `docker run --rm --name=${name} --label=renovate_child ${defaultVolumes} -e CI -e CUSTOM_KEY -e CONTAINERBASE_CACHE_DIR ${defaultCwd} ${fullImage} bash -l -c '${inCmd}'`, ], outOpts: [ dockerPullOpts, dockerRemoveOpts, { cwd, env: { ...containerbaseEnv, CUSTOM_KEY: 'CUSTOM_OVERRIDEN_VALUE', }, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], adminConfig: { customEnvVariables: { CUSTOM_KEY: 'CUSTOM_OVERRIDEN_VALUE', }, binarySource: 'docker', }, }, ], [ 'Discarded stdout if ignoreStdout=true', { processEnv, inCmd, inOpts: { ignoreStdout: true, cwdFile: '/somefile', }, outCmd, outOpts: [ { cwd, env: envMock.basic, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'ignore', stderr: 'pipe', }, ], }, ], [ 'Shell is not set if not specified', { processEnv, inCmd, inOpts: {}, outCmd, outOpts: [ { cwd, env: envMock.basic, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], }, ], [ 'Shell=true is set if specified', { processEnv, inCmd, inOpts: { shell: true, }, outCmd, outOpts: [ { cwd, env: envMock.basic, timeout: 900000, maxBuffer: 10485760, shell: true, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], }, ], [ 'Shell={string} is set if specified', { processEnv, inCmd, inOpts: { shell: '/usr/bin/another-shell', }, outCmd, outOpts: [ { cwd, env: envMock.basic, timeout: 900000, maxBuffer: 10485760, shell: '/usr/bin/another-shell', stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], }, ], [ 'Hermit', { processEnv: { ...envMock.basic, CUSTOM_KEY: 'CUSTOM_VALUE', PATH: '/home/user-a/bin;/usr/local/bin', }, inCmd, inOpts: { cwd, }, outCmd: [inCmd], outOpts: [ { cwd, env: { ...envMock.basic, CUSTOM_KEY: 'CUSTOM_OVERRIDEN_VALUE', GOBIN: '/usr/src/app/repository-a/.hermit/go/bin', PATH: '/usr/src/app/repository-a/bin/;/home/user-a/bin;/usr/local/bin;', }, timeout: 900000, maxBuffer: 10485760, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }, ], hermitEnvs: { GOBIN: '/usr/src/app/repository-a/.hermit/go/bin', PATH: '/usr/src/app/repository-a/bin/;/home/user-a/bin;/usr/local/bin;', }, adminConfig: { customEnvVariables: { CUSTOM_KEY: 'CUSTOM_OVERRIDEN_VALUE', }, binarySource: 'hermit', }, }, ], ]; it.each(testInputs)('%s', async (_msg, testOpts) => { const { processEnv: procEnv, inCmd: cmd, inOpts, outCmd: outCommand, outOpts, adminConfig = {}, hermitEnvs, } = testOpts; process.env = procEnv; const actualCmd: string[] = []; const actualOpts: RawExecOptions[] = []; cpExec.mockImplementation((execCmd, execOpts) => { actualCmd.push(asRawCommand(execCmd)); actualOpts.push(execOpts); return Promise.resolve({ stdout: '', stderr: '' }); }); GlobalConfig.set({ ...globalConfig, localDir: cwd, ...adminConfig }); setCustomEnv(coerceObject(adminConfig.customEnvVariables)); if (hermitEnvs !== undefined) { getHermitEnvsMock.mockResolvedValue(hermitEnvs); } await exec(cmd, inOpts); expect(actualCmd).toEqual(outCommand); expect(actualOpts).toEqual(outOpts); }); it('Supports image prefetch', async () => { process.env = processEnv; const actualCmd: string[] = []; cpExec.mockImplementation((execCmd) => { actualCmd.push(asRawCommand(execCmd)); return Promise.resolve({ stdout: '', stderr: '' }); }); GlobalConfig.set({ ...globalConfig, binarySource: 'global' }); await exec(inCmd, { docker }); await exec(inCmd, { docker }); GlobalConfig.set({ ...globalConfig, binarySource: 'docker' }); await exec(inCmd, { docker }); await exec(inCmd, { docker }); GlobalConfig.set({ ...globalConfig, binarySource: 'global' }); await exec(inCmd, { docker }); await exec(inCmd, { docker }); GlobalConfig.set({ ...globalConfig, binarySource: 'docker' }); await exec(inCmd, { docker }); await exec(inCmd, { docker }); expect(actualCmd).toEqual([ `echo hello`, `echo hello`, `docker pull ${fullImage}`, `docker ps --filter name=renovate_${sideCarName} -aq`, `docker run --rm --name=renovate_${sideCarName} --label=renovate_child ${defaultCacheVolume} -e CI -e CONTAINERBASE_CACHE_DIR ${fullImage} bash -l -c 'echo hello'`, `docker ps --filter name=renovate_${sideCarName} -aq`, `docker run --rm --name=renovate_${sideCarName} --label=renovate_child ${defaultCacheVolume} -e CI -e CONTAINERBASE_CACHE_DIR ${fullImage} bash -l -c 'echo hello'`, `echo hello`, `echo hello`, `docker ps --filter name=renovate_${sideCarName} -aq`, `docker run --rm --name=renovate_${sideCarName} --label=renovate_child ${defaultCacheVolume} -e CI -e CONTAINERBASE_CACHE_DIR ${fullImage} bash -l -c 'echo hello'`, `docker ps --filter name=renovate_${sideCarName} -aq`, `docker run --rm --name=renovate_${sideCarName} --label=renovate_child ${defaultCacheVolume} -e CI -e CONTAINERBASE_CACHE_DIR ${fullImage} bash -l -c 'echo hello'`, ]); }); it.each(Object.keys(hardcodedProcessEnv))( 'always forwards %s into the Docker sidecar', async (envVar) => { process.env = processEnv; const actualCmd: string[] = []; cpExec.mockImplementation((execCmd) => { actualCmd.push(asRawCommand(execCmd)); return Promise.resolve({ stdout: '', stderr: '' }); }); GlobalConfig.set({ ...globalConfig, localDir: cwd, binarySource: 'docker', }); await exec(inCmd, { docker }); expect(actualCmd.at(-1)).toContain(`-e ${envVar} `); }, ); it('throws when an error is thrown', async () => { process.env = processEnv; cpExec.mockImplementation(() => { throw new Error('some error occurred'); }); GlobalConfig.set({ ...globalConfig, binarySource: 'install' }); const promise = exec('foobar'); await expect(promise).rejects.toThrow('some error occurred'); }); it('rejects and throws if an error is thrown, even if we specify ignoreFailure=true', async () => { process.env = processEnv; cpExec.mockImplementation(() => { throw new Error('some error occurred'); }); GlobalConfig.set({ ...globalConfig }); const promise = exec([ { command: ['foobar'], ignoreFailure: true, }, ]); await expect(promise).rejects.toThrow('some error occurred'); }); it('does not reject and throw if rawExec returns an exit code, and we specify ignoreFailure=true', async () => { process.env = processEnv; const stdout = 'out'; const stderr = 'err'; cpExec.mockImplementation((): Promise => // NOTE that this only makes sense as a return value when `ignoreFailure=true` is set Promise.resolve({ stdout, stderr, exitCode: 10, }), ); GlobalConfig.set({ ...globalConfig }); const promise = exec([ { command: ['foobar'], // NOTE that the implementation would only work if `ignoreFailure: true` ignoreFailure: true, }, ]); await expect(promise).resolves.toEqual({ stdout, stderr, exitCode: 10, }); }); it('exec takes an array with both `string`s and `CommandWithOptions` as an argument', async () => { const command: CommandWithOptions = { command: ['exit 1'], ignoreFailure: true, }; cpExec.mockImplementationOnce(() => { return Promise.resolve({ stdout: '', stderr: '' }); }); cpExec.mockImplementationOnce(() => { return Promise.resolve({ stdout: 'out', stderr: 'err', exitCode: 5 }); }); await expect(exec(['ls', command])).resolves.toEqual({ stdout: 'out', stderr: 'err', exitCode: 5, }); }); it('exec takes CommandWithOptions as an argument', async () => { const command: CommandWithOptions = { command: ['exit 1'], ignoreFailure: true, }; cpExec.mockImplementation(() => { return Promise.resolve({ stdout: 'out', stderr: 'err', exitCode: 5 }); }); await expect(exec([command])).resolves.toEqual({ stdout: 'out', stderr: 'err', exitCode: 5, }); }); it('Supports binarySource=install', async () => { process.env = processEnv; cpExec.mockImplementation(() => { throw new Error('some error occurred'); }); GlobalConfig.set({ ...globalConfig, binarySource: 'install' }); process.env.CONTAINERBASE = 'true'; const promise = exec('foobar', { toolConstraints: [{ toolName: 'npm' }] }); await expect(promise).rejects.toThrow('No tool releases found.'); }); it('logs "Executing command" with the command and environment variable names', async () => { process.env = processEnv; cpExec.mockResolvedValue({ stdout: '', stderr: '' }); GlobalConfig.set({ ...globalConfig, localDir: cwd }); await exec(inCmd); expect(logger.logger.debug).toHaveBeenCalledWith( { command: inCmd, env: [ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'HOME', 'PATH', 'LC_ALL', 'LANG', 'CI', ], }, 'Executing command', ); }); it('logs ignored tool constraints for binarySource=global', async () => { process.env = processEnv; cpExec.mockResolvedValue({ stdout: '', stderr: '' }); GlobalConfig.set({ ...globalConfig, binarySource: 'global' }); await exec('foobar', { toolConstraints: [{ toolName: 'npm' }] }); expect(logger.logger.once.debug).toHaveBeenCalledWith( { toolConstraints: [{ toolName: 'npm' }] }, 'Ignoring tool contraints because of `binarySource=global`', ); }); it('Supports binarySource=install preCommands', async () => { process.env = processEnv; const actualCmd: string[] = []; cpExec.mockImplementation((execCmd) => { actualCmd.push(asRawCommand(execCmd)); return Promise.resolve({ stdout: '', stderr: '' }); }); GlobalConfig.set({ ...globalConfig, binarySource: 'install' }); process.env.CONTAINERBASE = 'true'; await exec('foobar', { preCommands: ['install-pip foobar'] }); expect(actualCmd).toEqual([`install-pip foobar`, `foobar`]); }); it('only installs a tool once per run for binarySource=install', async () => { process.env = processEnv; memCache.init(); datasource.getPkgReleases.mockResolvedValue({ releases: [{ version: '1.2.3' }], }); const actualCmds: string[] = []; cpExec.mockImplementation((execCmd) => { actualCmds.push(asRawCommand(execCmd)); return Promise.resolve({ stdout: '', stderr: '' }); }); GlobalConfig.set({ ...globalConfig, binarySource: 'install' }); process.env.CONTAINERBASE = 'true'; const toolConstraints = [{ toolName: 'npm' as const }]; await exec('foobar', { toolConstraints }); await exec('foobar', { toolConstraints }); expect(actualCmds).toEqual([`install-tool npm 1.2.3`, `foobar`, `foobar`]); }); it('installs a tool on every exec for binarySource=docker', async () => { process.env = processEnv; memCache.init(); datasource.getPkgReleases.mockResolvedValue({ releases: [{ version: '1.2.3' }], }); cpExec.mockResolvedValue({ stdout: '', stderr: '' }); GlobalConfig.set({ ...globalConfig, binarySource: 'docker' }); const toolConstraints = [{ toolName: 'npm' as const }]; await exec('foobar', { docker, toolConstraints }); await exec('foobar', { docker, toolConstraints }); const dockerRunCmds = cpExec.mock.calls .map((call) => asRawCommand(call[0])) .filter((cmd) => cmd.startsWith('docker run')); expect(dockerRunCmds).toHaveLength(2); expect(dockerRunCmds.every((cmd) => cmd.includes('install-tool npm'))).toBe( true, ); }); it('only calls removeDockerContainer in catch block is useDocker is set', async () => { cpExec.mockImplementation(() => { throw new Error('some error occurred'); }); const removeDockerContainerSpy = vi.spyOn( dockerModule, 'removeDockerContainer', ); const promise = exec('foobar', {}); await expect(promise).rejects.toThrow('some error occurred'); expect(removeDockerContainerSpy).toHaveBeenCalledTimes(0); }); it('wraps error if removeDockerContainer throws an error', async () => { GlobalConfig.set({ ...globalConfig, binarySource: 'docker' }); cpExec.mockImplementation(() => { throw new Error('some error occurred'); }); vi.spyOn(dockerModule, 'generateDockerCommand').mockImplementation( (): any => 'asdf', ); // The `removeDockerContainer` function is called once before it's used in the `catch` block. // We want it to fail in the catch block so we can assert the error is wrapped. let calledOnce = false; const removeDockerContainerSpy = vi.spyOn( dockerModule, 'removeDockerContainer', ); removeDockerContainerSpy.mockImplementation((): Promise => { if (!calledOnce) { calledOnce = true; return Promise.resolve(); } return Promise.reject(new Error('removeDockerContainer failed')); }); const promise = exec('foobar', { docker }); await expect(promise).rejects.toThrow( new Error( 'Error: "removeDockerContainer failed" - Original Error: "some error occurred"', ), ); expect(removeDockerContainerSpy).toHaveBeenCalledTimes(2); }); it('converts to TEMPORARY_ERROR', async () => { cpExec.mockImplementation(() => { class ErrorSignal extends Error { signal?: string; } const error = new ErrorSignal(); error.signal = 'SIGTERM'; throw error; }); const removeDockerContainerSpy = vi.spyOn( dockerModule, 'removeDockerContainer', ); const promise = exec('foobar', {}); await expect(promise).rejects.toThrow(TEMPORARY_ERROR); expect(removeDockerContainerSpy).toHaveBeenCalledTimes(0); }); describe('getToolSettingsOptions()', () => { const config: UpdateArtifactsConfig = { newValue: '5.6.4', }; describe('for JVM settings', () => { beforeEach(() => { GlobalConfig.set({ toolSettings: { jvmMemory: 768, jvmMaxMemory: 800 }, }); // remove any test-specific overrides delete config.toolSettings; }); it('returns default values if no global or repo config', () => { GlobalConfig.set({}); const res = getToolSettingsOptions(undefined); expect(res).toMatchObject({ jvmMemory: 512, jvmMaxMemory: 512, }); }); it('returns default values if empty repo config', () => { GlobalConfig.set({}); const res = getToolSettingsOptions({}); expect(res).toMatchObject({ jvmMemory: 512, jvmMaxMemory: 512, }); }); it('returns default values if empty global config', () => { GlobalConfig.set({ toolSettings: {}, }); const res = getToolSettingsOptions(undefined); expect(res).toMatchObject({ jvmMemory: 512, jvmMaxMemory: 512, }); }); describe('does not allow floating point numbers', () => { it('in global config', () => { GlobalConfig.set({ toolSettings: { jvmMemory: 512.5, jvmMaxMemory: 600.2 }, }); const res = getToolSettingsOptions(undefined); expect(res).toMatchObject({ jvmMemory: 512, jvmMaxMemory: 600, }); }); it('in repo config', () => { GlobalConfig.set({ toolSettings: { jvmMemory: 1024, jvmMaxMemory: 1024 }, }); config.toolSettings = { jvmMemory: 556.8, jvmMaxMemory: 600.4, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ jvmMemory: 556, jvmMaxMemory: 600, }); }); }); describe('when using repo config to override memory limits', () => { it('when below global settings, repo settings are used', () => { config.toolSettings = { jvmMemory: 512, jvmMaxMemory: 700, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ jvmMemory: 512, jvmMaxMemory: 700, }); }); it('when repo settings are the same as global settings, they are used', () => { config.toolSettings = { jvmMemory: 512, jvmMaxMemory: 600, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ jvmMemory: 512, jvmMaxMemory: 600, }); }); it('when repo jvmMemory setting is higher than global setting, but lower than global jvmMaxMemory, the repo config is used', () => { config.toolSettings = { jvmMemory: 600, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ jvmMemory: 600, }); }); it('when repo jvmMaxMemory setting is lower than global settings, it is applied', () => { config.toolSettings = { jvmMaxMemory: 680, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ jvmMaxMemory: 680, }); }); it('when repo jvmMaxMemory setting is lower than global jvmMemory, jvmMemory is set to the same value', () => { config.toolSettings = { jvmMaxMemory: 600, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ jvmMemory: 600, jvmMaxMemory: 600, }); }); it('when repo jvmMaxMemory setting is lower than repo jvmMemory, jvmMemory is set to the same value', () => { config.toolSettings = { jvmMemory: 600, jvmMaxMemory: 600, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ jvmMemory: 600, jvmMaxMemory: 600, }); }); it('when repo jvmMaxMemory setting is higher than global settings, they are ignored', () => { config.toolSettings = { jvmMaxMemory: 8192, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ jvmMemory: 768, jvmMaxMemory: 800, }); }); it('when repo jvmMaxMemory setting is higher than global settings, a debug log is logged', () => { config.toolSettings = { jvmMaxMemory: 8192, }; const res = getToolSettingsOptions(config.toolSettings); expect(logger.logger.once.debug).toHaveBeenCalledWith( 'A higher jvmMaxMemory (8192) than the global configuration (800) is not permitted for Java VM invocations. Using global configuration instead', ); expect(res).toMatchObject({ jvmMemory: 768, jvmMaxMemory: 800, }); }); }); // to provide a bit more safety to users, so they can't specify too little memory for Gradle describe('a minimum of 512M is enforced', () => { it('when global settings are lower than 512M, they are overridden to 512M', () => { GlobalConfig.set({ toolSettings: { jvmMemory: 100, jvmMaxMemory: 127 }, }); const res = getToolSettingsOptions(undefined); expect(res).toMatchObject({ jvmMemory: 512, jvmMaxMemory: 512, }); }); it('when global settings are lower than 512M, a debug log is logged', () => { GlobalConfig.set({ toolSettings: { jvmMemory: 200, jvmMaxMemory: 255 }, }); getToolSettingsOptions(undefined); expect(logger.logger.once.debug).toHaveBeenCalledWith( 'Overriding low memory settings for Java VM invocations to a minimum of 512M', ); }); it('when repo settings are lower than 512M, they are overridden to 512M', () => { config.toolSettings = { jvmMemory: 500, jvmMaxMemory: 511, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ jvmMemory: 512, jvmMaxMemory: 512, }); }); it('when repo settings are lower than 512M, a debug log is logged', () => { config.toolSettings = { jvmMemory: 500, jvmMaxMemory: 511, }; getToolSettingsOptions(config.toolSettings); expect(logger.logger.once.debug).toHaveBeenCalledWith( 'Overriding low memory settings for Java VM invocations to a minimum of 512M', ); }); }); }); describe('for Node settings', () => { beforeEach(() => { GlobalConfig.set({ toolSettings: { nodeMaxMemory: 1024 }, }); // remove any test-specific overrides delete config.toolSettings; }); it('does not return a default value if no global or repo config', () => { GlobalConfig.set({}); const res = getToolSettingsOptions(undefined); expect(res).toMatchObject({ nodeMaxMemory: undefined, }); }); it('does not return default values if empty global config', () => { GlobalConfig.set({ toolSettings: {}, }); const res = getToolSettingsOptions(undefined); expect(res).toMatchObject({ nodeMaxMemory: undefined, }); }); describe('does not allow floating point numbers', () => { it('in global config', () => { GlobalConfig.set({ toolSettings: { nodeMaxMemory: 1024.1536 }, }); const res = getToolSettingsOptions(undefined); expect(res).toMatchObject({ nodeMaxMemory: 1024, }); }); it('in repo config', () => { GlobalConfig.set({}); config.toolSettings = { nodeMaxMemory: 1024.1536, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ nodeMaxMemory: 1024, }); }); }); describe('when using repo config to override memory limits', () => { it('when below global settings, repo settings are used', () => { config.toolSettings = { nodeMaxMemory: 700, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ nodeMaxMemory: 700, }); }); it('when repo settings are the same as global settings, they are used', () => { config.toolSettings = { nodeMaxMemory: 1024, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ nodeMaxMemory: 1024, }); }); it('when repo nodeMaxMemory setting is lower than global settings, it is applied', () => { config.toolSettings = { nodeMaxMemory: 128, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ nodeMaxMemory: 128, }); }); it('when repo nodeMaxMemory setting is higher than global settings, they are ignored', () => { config.toolSettings = { nodeMaxMemory: 8192, }; const res = getToolSettingsOptions(config.toolSettings); expect(res).toMatchObject({ nodeMaxMemory: 1024, }); }); it('when repo nodeMaxMemory setting is higher than global settings, a debug log is logged', () => { config.toolSettings = { nodeMaxMemory: 8192, }; const res = getToolSettingsOptions(config.toolSettings); expect(logger.logger.once.debug).toHaveBeenCalledWith( 'A higher nodeMaxMemory (8192) than the global configuration (1024) is not permitted for Node invocations. Using global configuration instead', ); expect(res).toMatchObject({ nodeMaxMemory: 1024, }); }); }); }); }); describe('gradleJvmArg()', () => { it('takes the values given to it, and returns the JVM arguments', () => { const result = gradleJvmArg({ jvmMemory: 256, jvmMaxMemory: 768 }); expect(result).toBe(' -Dorg.gradle.jvmargs="-Xms256m -Xmx768m"'); }); }); });