import { mockDeep } from 'vitest-mock-extended'; import { Fixtures } from '~test/fixtures.ts'; import { logger } from '~test/util.ts'; import { CONFIG_VALIDATION, HOST_BLOCKED, PLATFORM_RATE_LIMIT_EXCEEDED, } from '../../constants/error-messages.ts'; import { ExternalHostError } from '../../types/errors/external-host-error.ts'; import * as memCache from '../../util/cache/memory/index.ts'; import * as _packageCache from '../../util/cache/package/index.ts'; import { setCustomEnv } from '../../util/env.ts'; import { GlobalConfig } from '../global.ts'; import type { AllConfig } from '../types.ts'; import * as _forgejo from './forgejo/index.ts'; import * as _gitea from './gitea/index.ts'; import * as _github from './github/index.ts'; import * as _gitlab from './gitlab/index.ts'; import * as _http from './http/index.ts'; import * as presets from './index.ts'; import * as _local from './local/index.ts'; import * as _npm from './npm/index.ts'; import { PRESET_DEP_NOT_FOUND, PRESET_INVALID_JSON, PRESET_NOT_FOUND, PRESET_RENOVATE_CONFIG_NOT_FOUND, } from './util.ts'; vi.mock('./forgejo/index.ts'); vi.mock('./gitea/index.ts'); vi.mock('./http/index.ts'); vi.mock('./npm/index.ts'); vi.mock('./github/index.ts'); vi.mock('./gitlab/index.ts'); vi.mock('./local/index.ts'); vi.mock('../../util/cache/package/index.ts', () => mockDeep()); const forgejo = vi.mocked(_forgejo); const gitea = vi.mocked(_gitea); const http = vi.mocked(_http); const npm = vi.mocked(_npm); const local = vi.mocked(_local); const gitHub = vi.mocked(_github); const gitLab = vi.mocked(_gitlab); const packageCache = vi.mocked(_packageCache); const presetIkatyang = Fixtures.getJson('renovate-config-ikatyang.json'); describe('config/presets/index', () => { describe('resolveConfigPresets', () => { let config: AllConfig; beforeEach(() => { config = {}; GlobalConfig.reset(); memCache.init(); packageCache.get.mockImplementation( (namespace: string, key: string): Promise => Promise.resolve(memCache.get(`${namespace}-${key}`)), ); packageCache.set.mockImplementation( ( namespace: string, key: string, value: unknown, _minutes: number, ): Promise => { memCache.set(`${namespace}-${key}`, value); return Promise.resolve(); }, ); npm.getPreset.mockImplementation(({ repo, presetName }) => { if (repo === 'renovate-config-ikatyang') { return presetIkatyang.versions[presetIkatyang['dist-tags'].latest][ 'renovate-config' ][presetName!]; } if (repo === 'renovate-config-notfound') { throw new Error(PRESET_DEP_NOT_FOUND); } if (repo === 'renovate-config-noconfig') { throw new Error(PRESET_RENOVATE_CONFIG_NOT_FOUND); } if (repo === 'renovate-config-throw') { throw new Error('whoops'); } if (repo === 'renovate-config-wrongpreset') { throw new Error(PRESET_NOT_FOUND); } return null; }); }); it('returns same if no presets', async () => { // @ts-expect-error -- invalid config config.foo = 1; config.extends = []; const { config: res } = await presets.resolveConfigPresets(config); expect(config).toMatchObject(res); expect(res).toEqual({ foo: 1 }); }); it('skips duplicate resolves', async () => { config.extends = ['local>some/repo:a', 'local>some/repo:b']; local.getPreset.mockResolvedValueOnce({ extends: ['local>some/repo:c'] }); local.getPreset.mockResolvedValueOnce({ extends: ['local>some/repo:c'] }); local.getPreset.mockResolvedValueOnce({ foo: 1 }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ foo: 1, }); expect(local.getPreset).toHaveBeenCalledTimes(3); expect(logger.logger.debug).toHaveBeenCalledWith( 'Already seen preset local>some/repo:c in [local>some/repo:a, local>some/repo:c]', ); }); it('throws if invalid preset file', async () => { // @ts-expect-error -- invalid config config.foo = 1; config.extends = ['local>some/repo']; local.getPreset.mockResolvedValueOnce({ extends: ['notfound'] }); let e: Error | undefined; try { await presets.resolveConfigPresets(config); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBe( "Cannot find preset's package (notfound)." + ' Note: this is a *nested* preset so please contact the preset author if you are unable to fix it yourself.', ); expect(e!.validationMessage).toBeUndefined(); }); it('throws if the preset host is blocked', async () => { config.extends = ['http://10.1.2.3/preset.json']; http.getPreset.mockRejectedValueOnce(new Error(HOST_BLOCKED)); let e: Error | undefined; try { await presets.resolveConfigPresets(config); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationError).toBe( 'Preset host is blocked by this Renovate instance (http://10.1.2.3/preset.json). If this is intended, ask your Renovate administrator to permit it with a `hostRules` entry setting `allowInternal=true`, scoped either by `hostType` (for example `preset` or `npm`) or by a URL-prefix `matchHost`', ); expect(logger.logger.warn).toHaveBeenCalledWith( { preset: 'http://10.1.2.3/preset.json', documentationUrl: expect.stringContaining( 'self-hosted-configuration/#hostrulesallowinternal', ), }, 'Preset host is blocked by this Renovate instance', ); }); it('throws if invalid preset', async () => { // @ts-expect-error -- invalid config config.foo = 1; config.extends = ['wrongpreset:invalid-preset']; let e: Error | undefined; try { await presets.resolveConfigPresets(config); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBe( 'Preset name not found within published preset config (wrongpreset:invalid-preset)', ); expect(e!.validationMessage).toBeUndefined(); }); it('throws if path + invalid syntax', async () => { // @ts-expect-error -- invalid config config.foo = 1; config.extends = ['github>user/repo//']; let e: Error | undefined; try { await presets.resolveConfigPresets(config); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBe('Preset is invalid (github>user/repo//)'); expect(e!.validationMessage).toBeUndefined(); }); it('throws if path + sub-preset', async () => { // @ts-expect-error -- invalid config config.foo = 1; config.extends = ['github>user/repo//path:subpreset']; let e: Error | undefined; try { await presets.resolveConfigPresets(config); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBe( 'Sub-presets cannot be combined with a custom path (github>user/repo//path:subpreset)', ); expect(e!.validationMessage).toBeUndefined(); }); it('throws if invalid preset json', async () => { // @ts-expect-error -- invalid config config.foo = 1; config.extends = ['org/repo']; let e: Error | undefined; local.getPreset.mockRejectedValueOnce(new Error(PRESET_INVALID_JSON)); try { await presets.resolveConfigPresets(config); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBe('Preset is invalid JSON (org/repo)'); expect(e!.validationMessage).toBeUndefined(); }); it('throws noconfig', async () => { // @ts-expect-error -- invalid config config.foo = 1; config.extends = ['noconfig:recommended']; let e: Error | undefined; try { await presets.resolveConfigPresets(config); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBe( 'Preset package is missing a renovate-config entry (noconfig:recommended)', ); expect(e!.validationMessage).toBeUndefined(); }); it('throws throw', async () => { // @ts-expect-error -- invalid config config.foo = 1; config.extends = ['throw:base']; let e: Error | undefined; try { await presets.resolveConfigPresets(config); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBe( 'Preset caused unexpected error (throw:base)', ); expect(e!.validationMessage).toBeUndefined(); }); it('works with valid', async () => { // @ts-expect-error -- invalid config config.foo = 1; config.extends = [':pinVersions']; const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ description: [ 'Use version pinning (maintain a single version only and not SemVer ranges).', ], foo: 1, rangeStrategy: 'pin', }); expect(res.rangeStrategy).toBe('pin'); }); it('replaces preset descriptions with overrideDescription', async () => { config.overrideDescription = ['Pin everything.']; config.extends = [':pinVersions']; const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ description: ['Pin everything.'], rangeStrategy: 'pin', }); }); it('ignores empty overrideDescription', async () => { config.overrideDescription = []; config.extends = [':pinVersions']; const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ description: [ 'Use version pinning (maintain a single version only and not SemVer ranges).', ], rangeStrategy: 'pin', }); }); it('throws if valid and invalid', async () => { // @ts-expect-error -- invalid config config.foo = 1; config.extends = ['wrongpreset:invalid-preset', ':pinVersions']; let e: Error | undefined; try { await presets.resolveConfigPresets(config); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBe( 'Preset name not found within published preset config (wrongpreset:invalid-preset)', ); expect(e!.validationMessage).toBeUndefined(); }); it('resolves packageRule', async () => { config.packageRules = [ { extends: ['packages:eslint'], groupName: 'eslint', }, ]; const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ packageRules: [ { groupName: 'eslint', matchPackageNames: [ '*/eslint-plugin', '@babel/eslint-parser', '@eslint/**', '@eslint-community/**', '@stylistic/eslint-plugin**', '@types/eslint', '@types/eslint__**', '@typescript-eslint/**', 'babel-eslint', 'eslint**', 'typescript-eslint', ], }, ], }); }); it('resolves pin GitHub Action digests to SemVer', async () => { config.extends = ['helpers:pinGitHubActionDigestsToSemver']; const { config: res } = await presets.resolveConfigPresets(config); expect(res.packageRules).toEqual([ { matchDepTypes: ['action', 'workflow'], pinDigests: true, }, { matchDepTypes: ['action', 'workflow'], extractVersion: '^(?v?\\d+\\.\\d+\\.\\d+)$', versioning: 'regex:^v?(?\\d+)(\\.(?\\d+)\\.(?\\d+))?$', }, ]); }); it('resolves eslint', async () => { config.extends = ['packages:eslint']; const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ matchPackageNames: [ '*/eslint-plugin', '@babel/eslint-parser', '@eslint/**', '@eslint-community/**', '@stylistic/eslint-plugin**', '@types/eslint', '@types/eslint__**', '@typescript-eslint/**', 'babel-eslint', 'eslint**', 'typescript-eslint', ], }); }); it('resolves linters', async () => { config.extends = ['packages:linters']; const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ description: ['All lint-related packages.'], matchPackageNames: [ 'ember-template-lint**', '*/eslint-plugin', '@babel/eslint-parser', '@eslint/**', '@eslint-community/**', '@stylistic/eslint-plugin**', '@types/eslint', '@types/eslint__**', '@typescript-eslint/**', 'babel-eslint', 'eslint**', 'typescript-eslint', 'friendsofphp/php-cs-fixer', 'squizlabs/php_codesniffer', 'symplify/easy-coding-standard', 'stylelint**', 'codelyzer', '/\\btslint\\b/', '@oxlint/**', 'oxlint', 'prettier', 'remark-lint', 'standard', ], }); }); it('resolves nested groups', async () => { config.extends = [':automergeLinters']; const { config: res } = await presets.resolveConfigPresets(config); expect(res.packageRules).toHaveLength(1); const rule = res.packageRules![0]; expect(rule.automerge).toBeTrue(); expect(rule.matchPackageNames).toHaveLength(23); }); it('migrates automerge in presets', async () => { config.extends = ['ikatyang:library']; const { config: res } = await presets.resolveConfigPresets(config); expect(res.automerge).toBeUndefined(); expect(res).toMatchObject({ major: { automerge: false }, minor: { automerge: true }, }); }); it('ignores presets', async () => { config.extends = ['config:recommended']; const { config: res } = await presets.resolveConfigPresets(config, {}, [ 'config:recommended', ]); expect(config).toMatchObject(res); expect(res).toBeEmptyObject(); }); it('resolves self-hosted presets without baseConfig', async () => { config.extends = ['local>username/preset-repo']; local.getPreset.mockResolvedValueOnce({ labels: ['self-hosted resolved'], }); const { config: res } = await presets.resolveConfigPresets(config); expect(local.getPreset.mock.calls).toHaveLength(1); expect(res).toEqual({ labels: ['self-hosted resolved'] }); }); it('returns the presets which have been merged into the resulting config', async () => { config.extends = ['local>username/preset-repo']; local.getPreset.mockResolvedValueOnce({ extends: ['security:openssf-scorecard'], labels: ['self-hosted resolved'], }); const { visitedPresets: { merged }, } = await presets.resolveConfigPresets(config); expect(merged).toEqual([ 'local>username/preset-repo', 'security:openssf-scorecard', ]); }); it('de-duplicates the presets which have been meregd into the resulting config', async () => { config.extends = [ 'security:openssf-scorecard', 'local>username/preset-repo', 'security:openssf-scorecard', ]; local.getPreset.mockResolvedValueOnce({ labels: ['self-hosted resolved'], extends: ['security:openssf-scorecard'], packageRules: [ { extends: ['packages:eslint'], groupName: 'eslint', }, ], }); const { visitedPresets: { merged }, } = await presets.resolveConfigPresets(config); expect(merged).toEqual( // NOTE that we're not expecting to have a strict or stable ordering expect.arrayContaining([ 'local>username/preset-repo', 'security:openssf-scorecard', 'packages:eslint', ]), ); }); it('resolves self-hosted preset with templating', async () => { setCustomEnv({ GIT_REF: 'abc123' }); config.extends = ['local>username/preset-repo#{{ env.GIT_REF }}']; local.getPreset.mockImplementationOnce(({ tag }) => tag === 'abc123' ? Promise.resolve({ labels: ['self-hosted with template resolved'] }) : Promise.reject(new Error('Failed to resolve self-hosted preset')), ); const { config: res } = await presets.resolveConfigPresets(config); expect(res.labels).toEqual(['self-hosted with template resolved']); expect(local.getPreset).toHaveBeenCalledExactlyOnceWith({ presetName: 'default', presetPath: undefined, repo: 'username/preset-repo', tag: 'abc123', }); }); it('resolves self-hosted transitive presets without baseConfig', async () => { config.platform = 'gitlab'; config.endpoint = 'https://dummy.example.com/api/v4'; config.extends = ['local>username/preset-repo']; local.getPreset .mockResolvedValueOnce({ extends: ['local>username/preset-repo//subpreset'], }) .mockResolvedValueOnce({ labels: ['self-hosted resolved'] }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ platform: 'gitlab', endpoint: 'https://dummy.example.com/api/v4', labels: ['self-hosted resolved'], }); }); it('resolves http presets', async () => { config.extends = ['https://example.com/preset.json']; http.getPreset.mockResolvedValueOnce({ labels: ['http'] }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ labels: ['http'] }); }); it('resolves forgejo presets', async () => { config.extends = ['forgejo>user/repo']; forgejo.getPreset.mockResolvedValueOnce({ labels: ['forgejo'] }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ labels: ['forgejo'] }); }); it('resolves gitea presets', async () => { config.extends = ['gitea>user/repo']; gitea.getPreset.mockResolvedValueOnce({ labels: ['gitea'] }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ labels: ['gitea'] }); }); it('resolves gitlab presets', async () => { config.extends = ['gitlab>user/repo']; gitLab.getPreset.mockResolvedValueOnce({ labels: ['gitlab'] }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ labels: ['gitlab'] }); }); it('gets preset value from cache when it has been seen', async () => { config.extends = ['github>username/preset-repo']; config.packageRules = [ { matchManagers: ['github-actions'], groupName: 'github-actions dependencies', }, ]; gitHub.getPreset.mockResolvedValueOnce({ packageRules: [ { matchDatasources: ['docker'], matchPackageNames: ['ubi'], versioning: 'regex', }, ], }); await expect(presets.resolveConfigPresets(config)).resolves.toBeDefined(); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ packageRules: [ { matchDatasources: ['docker'], matchPackageNames: ['ubi'], versioning: 'regex', }, { matchManagers: ['github-actions'], groupName: 'github-actions dependencies', }, ], }); }); it('default packageCache TTL should be 15 minutes', async () => { GlobalConfig.set({ presetCachePersistence: true, }); config.extends = ['github>username/preset-repo']; config.packageRules = [ { matchManagers: ['github-actions'], groupName: 'github-actions dependencies', }, ]; gitHub.getPreset.mockResolvedValueOnce({ packageRules: [ { matchDatasources: ['docker'], matchPackageNames: ['ubi'], versioning: 'regex', }, ], }); await expect(presets.resolveConfigPresets(config)).resolves.toBeDefined(); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ packageRules: [ { matchDatasources: ['docker'], matchPackageNames: ['ubi'], versioning: 'regex', }, { matchManagers: ['github-actions'], groupName: 'github-actions dependencies', }, ], }); expect(packageCache.set.mock.calls[0][3]).toBe(15); }); it('use packageCache when presetCachePersistence is set', async () => { GlobalConfig.set({ presetCachePersistence: true, }); config.extends = ['github>username/preset-repo']; config.packageRules = [ { matchManagers: ['github-actions'], groupName: 'github-actions dependencies', }, ]; gitHub.getPreset.mockResolvedValueOnce({ packageRules: [ { matchDatasources: ['docker'], matchPackageNames: ['ubi'], versioning: 'regex', }, ], }); await expect(presets.resolveConfigPresets(config)).resolves.toBeDefined(); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ packageRules: [ { matchDatasources: ['docker'], matchPackageNames: ['ubi'], versioning: 'regex', }, { matchManagers: ['github-actions'], groupName: 'github-actions dependencies', }, ], }); expect(packageCache.set.mock.calls[0][3]).toBe(15); }); it('throws', async () => { config.extends = ['local>username/preset-repo']; local.getPreset.mockRejectedValueOnce( new ExternalHostError(new Error('whoops')), ); await expect(presets.resolveConfigPresets(config)).rejects.toThrow( ExternalHostError, ); local.getPreset.mockRejectedValueOnce( new Error(PLATFORM_RATE_LIMIT_EXCEEDED), ); await expect(presets.resolveConfigPresets(config)).rejects.toThrow( PLATFORM_RATE_LIMIT_EXCEEDED, ); }); describe('relative presets', () => { it('resolves a relative preset against its parent', async () => { config.extends = ['github>some/repo#v1.0.0']; gitHub.getPreset.mockResolvedValueOnce({ extends: ['./system/registries'], }); gitHub.getPreset.mockResolvedValueOnce({ prCreation: 'not-pending' }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ prCreation: 'not-pending' }); expect(gitHub.getPreset).toHaveBeenCalledWith({ repo: 'some/repo', presetPath: 'system', presetName: 'registries', tag: 'v1.0.0', }); }); it('terminates on self-referencing relative presets', async () => { config.extends = ['github>some/repo#v1.0.0']; gitHub.getPreset.mockResolvedValue({ extends: ['./default'], prCreation: 'not-pending', }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ prCreation: 'not-pending' }); expect(gitHub.getPreset).toHaveBeenCalledTimes(1); }); it('fetches the root default preset only once', async () => { config.extends = ['github>some/repo#v1.0.0']; gitHub.getPreset.mockImplementation(({ presetPath, presetName }) => { if (presetPath === 'group' && presetName === 'a') { return Promise.resolve({ extends: ['/default'] }); } return Promise.resolve({ extends: ['./group/a'], packageRules: [{ matchManagers: ['npm'], automerge: true }], }); }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ packageRules: [{ matchManagers: ['npm'], automerge: true }], }); expect(gitHub.getPreset).toHaveBeenCalledTimes(2); }); it('resolves relative presets which use params', async () => { config.extends = ['github>some/repo#v1.0.0(argA)']; gitHub.getPreset.mockResolvedValueOnce({ extends: ['./{{arg0}}'], }); gitHub.getPreset.mockResolvedValueOnce({ prCreation: 'not-pending' }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ prCreation: 'not-pending' }); expect(gitHub.getPreset).toHaveBeenCalledWith({ repo: 'some/repo', presetPath: undefined, presetName: 'argA', tag: 'v1.0.0', }); }); it('throws if a relative preset has no parent preset', async () => { config.extends = ['./x']; await expect(presets.resolveConfigPresets(config)).rejects.toThrow( expect.objectContaining({ message: CONFIG_VALIDATION, validationError: 'Relative preset reference cannot be resolved (./x). Relative presets can only be used within presets from a supported source, must stay inside their repository, and cannot be templated or used outside of a preset (for example in the repository config, inherited config, or globalExtends)', }), ); }); it('throws if a relative preset resolves outside of its repository', async () => { config.extends = ['github>some/repo#v1.0.0']; gitHub.getPreset.mockResolvedValueOnce({ extends: ['github>other/repo#v2.0.0'], }); gitHub.getPreset.mockResolvedValueOnce({ extends: ['../x'] }); await expect(presets.resolveConfigPresets(config)).rejects.toThrow( expect.objectContaining({ message: CONFIG_VALIDATION, validationError: 'Relative preset reference cannot be resolved (../x). Relative presets can only be used within presets from a supported source, must stay inside their repository, and cannot be templated or used outside of a preset (for example in the repository config, inherited config, or globalExtends). Note: this is a *nested* preset so please contact the preset author if you are unable to fix it yourself.', }), ); }); it('throws if a relative preset is used by an unsupported preset source', async () => { config.extends = ['github>some/repo#v1.0.0']; gitHub.getPreset.mockResolvedValueOnce({ extends: ['somepackage:webapp'], }); npm.getPreset.mockResolvedValueOnce({ extends: ['./x'] }); await expect(presets.resolveConfigPresets(config)).rejects.toThrow( expect.objectContaining({ message: CONFIG_VALIDATION, validationError: 'Relative preset reference cannot be resolved (./x). Relative presets can only be used within presets from a supported source, must stay inside their repository, and cannot be templated or used outside of a preset (for example in the repository config, inherited config, or globalExtends). Note: this is a *nested* preset so please contact the preset author if you are unable to fix it yourself.', }), ); }); it('resolves relative ignorePresets of a preset', async () => { config.extends = ['github>some/repo#v1.0.0']; gitHub.getPreset.mockResolvedValueOnce({ extends: ['./optional'], ignorePresets: ['./optional'], prCreation: 'not-pending', }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ prCreation: 'not-pending' }); expect(gitHub.getPreset).toHaveBeenCalledTimes(1); }); it('allows ignorePresets to neutralize a broken relative preset', async () => { config.extends = ['github>some/repo#v1.0.0']; config.ignorePresets = ['../oops']; gitHub.getPreset.mockResolvedValueOnce({ extends: ['../oops'], prCreation: 'not-pending', }); const { config: res } = await presets.resolveConfigPresets(config); expect(res).toEqual({ prCreation: 'not-pending' }); }); }); describe('when using mergeInternalPresets=true', () => { describe('when resolving an internal preset', () => { it('merges `extends`', async () => { config.extends = ['security:openssf-scorecard']; config.packageRules = [ { extends: ['packages:eslint'], groupName: 'eslint', }, ]; const { config: res } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, true, ); expect(res).toEqual({ description: ['Show OpenSSF badge on pull requests.'], packageRules: [ { matchSourceUrls: ['https://github.com/**'], prBodyColumns: [ 'Package', 'Type', 'Update', 'Change', 'Pending', 'OpenSSF', ], prBodyDefinitions: { OpenSSF: '[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/{{sourceRepo}}/badge)](https://securityscorecards.dev/viewer/?uri=github.com/{{sourceRepo}})', }, }, { groupName: 'eslint', matchPackageNames: [ '*/eslint-plugin', '@babel/eslint-parser', '@eslint/**', '@eslint-community/**', '@stylistic/eslint-plugin**', '@types/eslint', '@types/eslint__**', '@typescript-eslint/**', 'babel-eslint', 'eslint**', 'typescript-eslint', ], }, ], }); }); it('does not return any unmerged presets', async () => { config.extends = ['security:openssf-scorecard']; config.packageRules = [ { extends: ['packages:eslint'], groupName: 'eslint', }, ]; const { visitedPresets: { unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, true, ); expect(unmerged).toBeEmptyArray(); }); }); // NOTE that this is a **??** describe('when resolving an internal preset which includes many other internal presets', () => { it('merges `extends`, recursively', async () => { config.extends = [':assignAndReview(jamietanna)']; const { config: res } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, true, ); expect(res).toEqual({ assignees: ['jamietanna'], description: [ 'Assign PRs to `jamietanna`.', 'Add `jamietanna` as reviewer for PRs.', ], reviewers: ['jamietanna'], }); }); it('does not return any unmerged presets', async () => { config.extends = [':assignAndReview(jamietanna)']; const { visitedPresets: { merged, unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, true, ); expect(merged).toEqual([ ':assignAndReview(jamietanna)', ':assignee(jamietanna)', ':reviewer(jamietanna)', ]); expect(unmerged).toEqual([]); }); }); describe('when resolving an external preset which references an internal preset', () => { it('merges internal `extends`', async () => { config.extends = ['local>username/preset-repo']; local.getPreset.mockResolvedValueOnce({ extends: ['security:openssf-scorecard'], labels: ['self-hosted resolved'], }); const { config: res } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, true, ); expect(res).toEqual({ description: ['Show OpenSSF badge on pull requests.'], labels: ['self-hosted resolved'], packageRules: [ { matchSourceUrls: ['https://github.com/**'], prBodyColumns: [ 'Package', 'Type', 'Update', 'Change', 'Pending', 'OpenSSF', ], prBodyDefinitions: { OpenSSF: '[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/{{sourceRepo}}/badge)](https://securityscorecards.dev/viewer/?uri=github.com/{{sourceRepo}})', }, }, ], }); }); it('does not return any unmerged presets', async () => { config.extends = ['local>username/preset-repo']; local.getPreset.mockResolvedValueOnce({ extends: ['security:openssf-scorecard'], labels: ['self-hosted resolved'], }); const { visitedPresets: { unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, true, ); expect(unmerged).toBeEmptyArray(); }); }); describe('when resolving mixed internal and external presets', () => { it('merges internal `extends`', async () => { config.extends = [ 'security:openssf-scorecard', 'local>username/preset-repo', ]; local.getPreset.mockResolvedValueOnce({ labels: ['self-hosted resolved'], }); config.packageRules = [ { extends: ['packages:eslint'], groupName: 'eslint', }, ]; const { config: res } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, true, ); expect(res).toEqual({ description: ['Show OpenSSF badge on pull requests.'], labels: ['self-hosted resolved'], packageRules: [ { matchSourceUrls: ['https://github.com/**'], prBodyColumns: [ 'Package', 'Type', 'Update', 'Change', 'Pending', 'OpenSSF', ], prBodyDefinitions: { OpenSSF: '[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/{{sourceRepo}}/badge)](https://securityscorecards.dev/viewer/?uri=github.com/{{sourceRepo}})', }, }, { groupName: 'eslint', matchPackageNames: [ '*/eslint-plugin', '@babel/eslint-parser', '@eslint/**', '@eslint-community/**', '@stylistic/eslint-plugin**', '@types/eslint', '@types/eslint__**', '@typescript-eslint/**', 'babel-eslint', 'eslint**', 'typescript-eslint', ], }, ], }); }); it('does not return any unmerged presets', async () => { config.extends = [ 'security:openssf-scorecard', 'local>username/preset-repo', ]; local.getPreset.mockResolvedValueOnce({ labels: ['self-hosted resolved'], }); config.packageRules = [ { extends: ['packages:eslint'], groupName: 'eslint', }, ]; const { visitedPresets: { unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, true, ); expect(unmerged).toBeEmptyArray(); }); }); }); describe('when using mergeInternalPresets=false', () => { describe('when resolving an internal preset', () => { it('does not merge `extends`', async () => { config.extends = ['security:openssf-scorecard']; config.packageRules = [ { extends: ['packages:eslint'], groupName: 'eslint', }, ]; const { config: res } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(res).toEqual({ packageRules: [ { groupName: 'eslint', }, ], }); }); it('returns the presets in the unmerged array', async () => { config.extends = ['security:openssf-scorecard']; config.packageRules = [ { extends: ['packages:eslint'], groupName: 'eslint', }, ]; const { visitedPresets: { unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(unmerged).toEqual([ 'security:openssf-scorecard', 'packages:eslint', ]); }); }); describe('when resolving an internal, parameterised preset', () => { it('does not merge `extends`', async () => { config.extends = [':assignee(renovate-tests)']; const { config: res } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(res).toEqual({}); }); it('returns the preset in the unmerged array', async () => { config.extends = [':assignee(renovate-tests)']; const { visitedPresets: { unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(unmerged).toEqual([':assignee(renovate-tests)']); }); }); describe('when resolving an internal preset which includes many other internal presets', () => { it('does not merge `extends`', async () => { config.extends = ['config:recommended']; const { config: res } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(res).toEqual({}); }); it('returns the unmerged internal presets', async () => { config.extends = ['config:recommended']; const { visitedPresets: { merged, unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(merged).toEqual([]); expect(unmerged).toEqual(['config:recommended']); }); }); describe('when resolving an external preset which references an internal preset', () => { it('does not merge `extends`', async () => { config.extends = ['local>username/preset-repo']; local.getPreset.mockResolvedValueOnce({ extends: ['security:openssf-scorecard'], labels: ['self-hosted resolved'], }); const { config: res } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(res).toEqual({ labels: ['self-hosted resolved'], }); }); it('returns the unmerged internal presets', async () => { config.extends = ['local>username/preset-repo']; local.getPreset.mockResolvedValueOnce({ extends: ['security:openssf-scorecard'], labels: ['self-hosted resolved'], }); const { visitedPresets: { unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(unmerged).toEqual(['security:openssf-scorecard']); }); }); describe('when resolving mixed internal and external presets', () => { it('does not expand internal `extends`', async () => { config.extends = [ 'security:openssf-scorecard', 'local>username/preset-repo', ]; local.getPreset.mockResolvedValueOnce({ labels: ['self-hosted resolved'], }); config.packageRules = [ { extends: ['packages:eslint'], groupName: 'eslint', }, ]; const { config: res } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(res).toEqual({ labels: ['self-hosted resolved'], packageRules: [ { groupName: 'eslint', }, ], }); }); it('returns the unmerged internal presets', async () => { config.extends = [ 'security:openssf-scorecard', 'local>username/preset-repo', ]; local.getPreset.mockResolvedValueOnce({ labels: ['self-hosted resolved'], }); config.packageRules = [ { extends: ['packages:eslint'], groupName: 'eslint', }, ]; const { visitedPresets: { unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(unmerged).toEqual([ 'security:openssf-scorecard', 'packages:eslint', ]); }); }); describe('when resolving an internal preset inside a nested object config value', () => { it('returns the unmerged internal presets from a datasource', async () => { config.extends = ['local>username/preset-repo']; local.getPreset.mockResolvedValueOnce({ artifactory: { extends: ['security:openssf-scorecard'], enabled: true, }, }); const { visitedPresets: { unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(unmerged).toEqual(['security:openssf-scorecard']); }); }); describe('when duplicate internal presets are found', () => { it('they are de-duplicated when returned as unmerged', async () => { config.extends = [ 'security:openssf-scorecard', 'local>username/preset-repo', ]; local.getPreset.mockResolvedValueOnce({ extends: ['security:openssf-scorecard'], }); config.packageRules = [ { extends: ['security:openssf-scorecard'], }, ]; const { visitedPresets: { unmerged }, } = await presets.resolveConfigPresets( config, undefined, undefined, undefined, false, ); expect(unmerged).toEqual(['security:openssf-scorecard']); }); }); }); }); describe('replaceArgs', () => { const argMappings = { arg0: 'a', arg1: 'b', arg2: 'c', }; it('replaces args in strings', () => { const str = '{{arg2}} foo {{arg0}}{{arg1}}'; const res = presets.replaceArgs(str, argMappings); expect(res).toBe('c foo ab'); }); it('replaces args twice in same string', () => { const str = '{{arg2}}{{arg0}} foo {{arg0}}{{arg1}}'; const res = presets.replaceArgs(str, argMappings); expect(res).toBe('ca foo ab'); }); it('replaces objects', () => { const obj = { foo: 'ha {{arg0}}', bar: { baz: '{{arg1}} boo', aaa: { bbb: 'woo {{arg2}}', }, }, }; const res = presets.replaceArgs(obj, argMappings); expect(res).toEqual({ bar: { aaa: { bbb: 'woo c' }, baz: 'b boo' }, foo: 'ha a', }); }); it('replaces arrays', () => { const obj = { foo: ['{{arg0}}', { bar: '{{arg1}}', baz: 5 }], }; const res = presets.replaceArgs(obj, argMappings); expect(res).toEqual({ foo: ['a', { bar: 'b', baz: 5 }], }); }); }); describe('getPreset', () => { beforeEach(() => { memCache.init(); }); it('does not use cache for internal presets', async () => { const memCacheGetSpy = vi.spyOn(memCache, 'get'); await expect( presets.getPreset(':dependencyDashboard', {}), ).resolves.toBeDefined(); expect(memCacheGetSpy).not.toHaveBeenCalled(); expect(packageCache.get).not.toHaveBeenCalled(); }); it('handles removed presets with a migration', async () => { const res = await presets.getPreset(':base', {}); expect(res).toEqual({ extends: [ ':dependencyDashboard', ':semanticPrefixFixDepsChoreOthers', ':ignoreModulesAndTests', 'group:monorepos', 'group:recommended', 'mergeConfidence:age-confidence-badges', 'replacements:all', 'workarounds:all', 'helpers:forgejoDigestChangelogs', 'helpers:giteaDigestChangelogs', 'helpers:githubDigestChangelogs', 'helpers:gitlabDigestChangelogs', 'helpers:goXPackagesChangelogLink', 'helpers:goXPackagesNameLink', 'helpers:renovateChangelog', ], }); }); it('handles removed presets with no migration', async () => { const res = await presets.getPreset('helpers:oddIsUnstable', {}); expect(res).toEqual({}); }); it('handles renamed monorepos', async () => { const res = await presets.getPreset('monorepo:opentelemetry', {}); expect(res).toMatchInlineSnapshot(` { "description": [ "opentelemetry-js monorepo", ], "matchSourceUrls": [ "https://github.com/open-telemetry/opentelemetry-js", ], } `); }); it('handles renamed monorepo groups', async () => { const res = await presets.getPreset('group:opentelemetryMonorepo', {}); expect(res).toMatchInlineSnapshot(` { "packageRules": [ { "description": [ "Group packages from opentelemetry-js monorepo together.", ], "extends": [ "monorepo:opentelemetry-js", ], "groupName": "opentelemetry-js monorepo", "matchUpdateTypes": [ "digest", "patch", "minor", "major", ], }, ], } `); }); it('handles renamed regexManagers presets', async () => { const res = await presets.getPreset( 'regexManagers:dockerfileVersions', {}, ); expect(res.customManagers).toHaveLength(1); }); it('gets linters', async () => { const res = await presets.getPreset('packages:linters', {}); expect(res).toEqual({ description: ['All lint-related packages.'], extends: [ 'packages:emberTemplateLint', 'packages:eslint', 'packages:phpLinters', 'packages:stylelint', 'packages:tslint', ], matchPackageNames: [ '@oxlint/**', 'oxlint', 'prettier', 'remark-lint', 'standard', ], }); }); it('gets parameterised configs', async () => { const res = await presets.getPreset( ':group(packages:eslint, eslint)', {}, ); expect(res).toEqual({ description: ['Group `eslint` packages into same branch/PR.'], packageRules: [ { extends: ['packages:eslint'], groupName: 'eslint', }, ], }); }); it('handles missing params', async () => { const res = await presets.getPreset(':group()', {}); expect(res).toEqual({ description: ['Group `{{arg1}}` packages into same branch/PR.'], packageRules: [ { extends: [], groupName: '{{arg1}}', }, ], }); }); it('ignores irrelevant params', async () => { const res = await presets.getPreset(':pinVersions(foo, bar)', {}); expect(res).toEqual({ description: [ 'Use version pinning (maintain a single version only and not SemVer ranges).', ], rangeStrategy: 'pin', }); }); it('substitutes {{args}}', async () => { local.getPreset.mockResolvedValueOnce({ customManagers: [ { customType: 'regex', managerFilePatterns: ['{{args}}'], matchStrings: ['# renovate: ...'], }, ], }); const res = await presets.getPreset( 'local>customManager(**/{*.py, *.yaml})', {}, ); expect(res).toEqual({ customManagers: [ { customType: 'regex', // The space after comma is obviously incorrect here. // But the test must ensure that spaces aren't removed. managerFilePatterns: ['**/{*.py, *.yaml}'], matchStrings: ['# renovate: ...'], }, ], }); }); it('handles 404 packages', async () => { let e: Error | undefined; try { await presets.getPreset('notfound:foo', {}); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBeUndefined(); expect(e!.validationMessage).toBeUndefined(); }); it('handles no config', async () => { let e: Error | undefined; try { await presets.getPreset('noconfig:foo', {}); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBeUndefined(); expect(e!.validationMessage).toBeUndefined(); }); it('handles throw errors', async () => { let e: Error | undefined; try { await presets.getPreset('throw:foo', {}); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBeUndefined(); expect(e!.validationMessage).toBeUndefined(); }); it('canonicalizes relative presets', async () => { gitHub.getPreset.mockResolvedValueOnce({ extends: ['./system/registries', 'github>other/repo'], packageRules: [{ extends: ['./rules/docker'] }], }); const res = await presets.getPreset('github>some/repo#v1.0.0', {}); expect(res).toEqual({ extends: [ 'github>some/repo//system/registries#v1.0.0', 'github>other/repo', ], packageRules: [{ extends: ['github>some/repo//rules/docker#v1.0.0'] }], }); }); it('leaves relative presets of npm presets unchanged', async () => { npm.getPreset.mockResolvedValueOnce({ extends: ['./system/registries'], ignorePresets: ['./optional'], }); const res = await presets.getPreset('somepackage:webapp', {}); expect(res).toEqual({ extends: ['./system/registries'], ignorePresets: ['./optional'], }); }); it('handles preset not found', async () => { let e: Error | undefined; try { await presets.getPreset('wrongpreset:foo', {}); } catch (err) { e = err; } expect(e).toBeDefined(); expect(e!.validationSource).toBeUndefined(); expect(e!.validationError).toBeUndefined(); expect(e!.validationMessage).toBeUndefined(); }); }); });