import fs from 'node:fs'; import { codeBlock } from 'common-tags'; import fsExtra from 'fs-extra'; import type { DirectoryResult } from 'tmp-promise'; import { dir } from 'tmp-promise'; import upath from 'upath'; import { expect } from 'vitest'; import { logger } from '../../../../logger/index.ts'; import customConfig from './__fixtures__/config.cjs'; import * as file from './file.ts'; describe('workers/global/config/parse/file', () => { const processExitSpy = vi.spyOn(process, 'exit'); const fsPathExistsSpy = vi.spyOn(fsExtra, 'pathExists'); const fsRemoveSpy = vi.spyOn(fsExtra, 'remove'); let tmp: DirectoryResult; beforeAll(async () => { tmp = await dir({ unsafeCleanup: true }); }); afterAll(async () => { await tmp.cleanup(); }); describe('.getConfig()', () => { it.each([ ['custom js config file (esm)', 'config.js'], ['custom js config file (cjs)', 'config-cjs.js'], ['custom cjs config file', 'config.cjs'], ['custom mjs config file', 'config.mjs'], ['custom ts config file (esm)', 'config.ts'], ['custom ts config file (cjs)', 'config-cjs.ts'], ['custom mts config file', 'config.mts'], ['custom cts config file', 'config.cts'], ['custom js config file exporting a Promise', 'config-promise.js'], ['custom js config file exporting a function', 'config-function.js'], // The next two are different syntactic ways of expressing the same thing [ 'custom js config file exporting a function returning a Promise', 'config-function-promise.js', ], [ 'custom js config file exporting an async function', 'config-async-function.js', ], ['.renovaterc', '.renovaterc'], ['JSON5 config file', 'config.json5'], ['YAML config file', 'config.yaml'], ])('parses %s > %s', async (_fileType, filePath) => { const configFile = upath.resolve( import.meta.dirname, './__fixtures__/', filePath, ); await expect( file.getConfig({ RENOVATE_CONFIG_FILE: configFile }), ).resolves.toEqual(customConfig); }); it('migrates', async () => { const configFile = upath.resolve( import.meta.dirname, './__fixtures__/config2.js', ); // for coverage const relativePath = upath.relative(process.cwd(), configFile); const res = await file.getConfig({ RENOVATE_CONFIG_FILE: relativePath }); expect(res).toEqual({ rangeStrategy: 'bump', }); }); it('warns if config is invalid', async () => { const configFile = upath.resolve(tmp.path, 'config.js'); const fileContent = codeBlock` module.exports = { "enabled": "invalid-value", "prTitle":"something", }; `; await fs.promises.writeFile(configFile, fileContent, { encoding: 'utf8', }); await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); expect(logger.warn).toHaveBeenCalledTimes(2); await fs.promises.unlink(configFile); }); it('parse and returns empty config if there is no RENOVATE_CONFIG_FILE in env', async () => { await expect(file.getConfig({})).resolves.toBeDefined(); }); it.each([ [ 'config.invalid.js', codeBlock` module.exports = { "platform": "github", "token":"abcdef", "onboarding": false, "gitAuthor": "Renovate Bot " "onboardingConfig": { "extends": ["config:recommended"], }, "repositories": [ "test/test" ], }; `, ], ['config.invalid.json5', `"invalid":`], ['config.invalid.yaml', `clearly: "invalid" "yaml"`], ['config.invalid.ts', `export default {{{`], ])( 'fatal error and exit if error in parsing %s', async (fileName, fileContent) => { processExitSpy.mockImplementationOnce(() => undefined as never); const configFile = upath.resolve(tmp.path, fileName); await fs.promises.writeFile(configFile, fileContent, { encoding: 'utf8', }); await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); expect(processExitSpy).toHaveBeenCalledExactlyOnceWith(1); await fs.promises.unlink(configFile); }, ); it('fatal error and exit if custom config file does not exist', async () => { processExitSpy .mockImplementationOnce(() => undefined as never) .mockImplementationOnce(() => undefined as never); const configFile = upath.resolve(tmp.path, './file4.js'); await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); // TODO this should be called exactly once, but is called twice // expect(processExitSpy).toHaveBeenCalledExactlyOnceWith(1); expect(processExitSpy).toHaveBeenCalledWith(1); }); it('fatal error and exit if config.js contains unresolved env var', async () => { processExitSpy.mockImplementationOnce(() => undefined as never); const configFile = upath.resolve( import.meta.dirname, './__fixtures__/config-ref-error.js-invalid', ); const tmpDir = tmp.path; await fsExtra.ensureDir(tmpDir); const tmpConfigFile = upath.resolve(tmpDir, 'config-ref-error.js'); await fsExtra.copy(configFile, tmpConfigFile); await file.getConfig({ RENOVATE_CONFIG_FILE: tmpConfigFile }); expect(logger.fatal).toHaveBeenCalledWith( { err: expect.objectContaining({ message: 'CI_API_V4_URL is not defined', }), }, 'Error parsing config file due to unresolved variable(s)', ); expect(processExitSpy).toHaveBeenCalledExactlyOnceWith(1); }); it.each([ ['invalid config file type', './file.txt'], ['missing config file type', './file'], ])('fatal error and exit if %s', async (fileType, filePath) => { processExitSpy.mockImplementationOnce(() => undefined as never); const configFile = upath.resolve(tmp.path, filePath); await fs.promises.writeFile(configFile, `{"token": "abc"}`, { encoding: 'utf8', }); await file.getConfig({ RENOVATE_CONFIG_FILE: configFile }); expect(processExitSpy).toHaveBeenCalledExactlyOnceWith(1); expect(logger.fatal).toHaveBeenCalledWith('Unsupported file type'); await fs.promises.unlink(configFile); }); it('exports env variables to environment from processEnv object', async () => { const configFile = upath.resolve(tmp.path, 'config2.js'); const fileContent1 = codeBlock` module.exports = { "processEnv": { "SOME_KEY": "SOME_VALUE" }, "labels": ["renovate"] } `; await fs.promises.writeFile(configFile, fileContent1, { encoding: 'utf8', }); const fileConfig = await file.getConfig({ RENOVATE_CONFIG_FILE: configFile, }); expect(fileConfig).toMatchObject({ labels: ['renovate'], }); expect(fileConfig.processEnv).toBeUndefined(); expect(process.env.SOME_KEY).toBe('SOME_VALUE'); await fs.promises.unlink(configFile); vi.stubEnv('SOME_KEY', undefined); }); it('does not export env variables to environment from processEnv object if key/value is invalid', async () => { const configFile = upath.resolve(tmp.path, 'config3.js'); const fileContent1 = codeBlock` module.exports = { "processEnv": { "SOME_KEY": "SOME_VALUE", "SOME_OTHER_KEY": true, "valid_Key": "true", }, "labels": ["renovate"] } `; await fs.promises.writeFile(configFile, fileContent1, { encoding: 'utf8', }); const fileConfig = await file.getConfig({ RENOVATE_CONFIG_FILE: configFile, }); expect(fileConfig).toMatchObject({ labels: ['renovate'], }); expect(fileConfig.processEnv).toBeUndefined(); expect(process.env.SOME_KEY).toBe('SOME_VALUE'); expect(process.env.valid_Key).toBe('true'); expect(process.env.SOME_OTHER_KEY).toBeUndefined(); await fs.promises.unlink(configFile); vi.stubEnv('SOME_KEY', undefined); vi.stubEnv('valid_Key', undefined); }); }); describe('deleteConfigFile()', () => { it.each([[undefined], [' ']])( 'skip when RENOVATE_CONFIG_FILE is not set ("%s")', async (configFile) => { await file.deleteNonDefaultConfig( { RENOVATE_CONFIG_FILE: configFile }, true, ); expect(fsRemoveSpy).toHaveBeenCalledTimes(0); }, ); it('skip when config file does not exist', async () => { fsPathExistsSpy.mockResolvedValueOnce(false as never); await file.deleteNonDefaultConfig( { RENOVATE_CONFIG_FILE: 'path', }, true, ); expect(fsRemoveSpy).toHaveBeenCalledTimes(0); }); it.each([['false'], [' ']])( 'skip if deleteConfigFile is not set ("%s")', async (deleteConfig) => { fsPathExistsSpy.mockResolvedValueOnce(true as never); await file.deleteNonDefaultConfig( { RENOVATE_CONFIG_FILE: '/path/to/config.js', }, deleteConfig === 'true', ); expect(fsRemoveSpy).toHaveBeenCalledTimes(0); }, ); it('removes the specified config file', async () => { fsRemoveSpy.mockImplementationOnce(() => { // no-op }); fsPathExistsSpy.mockResolvedValueOnce(true as never); const configFile = '/path/to/config.js'; await file.deleteNonDefaultConfig( { RENOVATE_CONFIG_FILE: configFile, }, true, ); expect(fsRemoveSpy).toHaveBeenCalledTimes(1); expect(fsRemoveSpy).toHaveBeenCalledExactlyOnceWith(configFile); expect(logger.trace).toHaveBeenCalledWith( expect.anything(), 'config file successfully deleted', ); }); it('fails silently when attempting to delete the config file', async () => { fsRemoveSpy.mockImplementationOnce(() => { throw new Error(); }); fsPathExistsSpy.mockResolvedValueOnce(true as never); const configFile = '/path/to/config.js'; await file.deleteNonDefaultConfig( { RENOVATE_CONFIG_FILE: configFile, }, true, ); expect(fsRemoveSpy).toHaveBeenCalledTimes(1); expect(fsRemoveSpy).toHaveBeenCalledExactlyOnceWith(configFile); expect(logger.warn).toHaveBeenCalledWith( expect.anything(), 'error deleting config file', ); }); }); });