import { ERROR, WARN } from 'bunyan'; import { codeBlock } from 'common-tags'; import type { MockedObject } from 'vitest'; import { vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { Fixtures } from '~test/fixtures.ts'; import type { RenovateConfig } from '~test/util.ts'; import { logger, platform } from '~test/util.ts'; import { getConfig } from '../../config/defaults.ts'; import { GlobalConfig } from '../../config/global.ts'; import { pkg } from '../../expose.ts'; import type { PackageDependency, PackageFile, } from '../../modules/manager/types.ts'; import { massageMarkdown } from '../../modules/platform/github/index.ts'; import type { Platform } from '../../modules/platform/index.ts'; import { clone } from '../../util/clone.ts'; import { emojify } from '../../util/emoji.ts'; import { asTimestamp } from '../../util/timestamp.ts'; import type { BranchConfig, BranchUpgradeConfig } from '../types.ts'; import * as dependencyDashboard from './dependency-dashboard.ts'; import { getDashboardMarkdownVulnerabilities } from './dependency-dashboard.ts'; import { PackageFiles } from './package-files.ts'; const createVulnerabilitiesMock = vi.fn(); vi.mock('./process/vulnerabilities.ts', () => { return { __esModule: true, Vulnerabilities: class { static create() { return createVulnerabilitiesMock(); } }, }; }); type PrUpgrade = BranchUpgradeConfig; const massageMdSpy = platform.massageMarkdown; const getIssueSpy = platform.getIssue; let config: BranchConfig; beforeEach(() => { GlobalConfig.reset(); massageMdSpy.mockImplementation(massageMarkdown); platform.maxBodyLength.mockReturnValue(60000); // Github Limit config = getConfig() as BranchConfig; config.errors = []; config.warnings = []; }); function genRandString(length: number): string { let result = ''; const chars = 'abcdefghijklmnopqrstuvwxyz'; const charsLen = chars.length; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * charsLen)); } return result; } function genRandPackageFile( depsNum: number, depNameLen: number, ): Record { const deps: PackageDependency[] = []; for (let i = 0; i < depsNum; i++) { deps.push({ depName: genRandString(depNameLen), currentValue: '1.0.0', }); } return { npm: [{ packageFile: 'package.json', deps }] }; } async function dryRun( branches: BranchConfig[], platform: MockedObject, ensureIssueClosingCalls: number, ensureIssueCalls: number, ) { GlobalConfig.set({ dryRun: 'full' }); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes( ensureIssueClosingCalls, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(ensureIssueCalls); } describe('workers/repository/dependency-dashboard', () => { describe('readDashboardBody()', () => { it('parses invalid dashboard body without throwing error', async () => { const conf: RenovateConfig = {}; conf.prCreation = 'approval'; platform.findIssue.mockResolvedValueOnce({ title: '', number: 1, body: null as never, }); await dependencyDashboard.readDashboardBody(conf); expect(conf).toEqual({ dependencyDashboardChecks: { configMigrationCheckboxState: 'no-checkbox', }, dependencyDashboardAllAwaitingSchedule: false, dependencyDashboardAllPending: false, dependencyDashboardAllRateLimited: false, dependencyDashboardIssue: 1, dependencyDashboardRebaseAllOpen: false, dependencyDashboardTitle: 'Dependency Dashboard', prCreation: 'approval', }); }); it('reads dashboard body', async () => { const conf: RenovateConfig = {}; conf.prCreation = 'approval'; platform.findIssue.mockResolvedValueOnce({ title: '', number: 1, body: `${Fixtures.get('dependency-dashboard-with-10-PR.txt').replace( '- [ ]', '- [x]', )}\n\n - [x] `, }); await dependencyDashboard.readDashboardBody(conf); expect(conf).toEqual({ dependencyDashboardAllAwaitingSchedule: false, dependencyDashboardAllPending: false, dependencyDashboardAllRateLimited: false, dependencyDashboardChecks: { branchName1: 'approve', configMigrationCheckboxState: 'no-checkbox', }, dependencyDashboardIssue: 1, dependencyDashboardRebaseAllOpen: true, dependencyDashboardTitle: 'Dependency Dashboard', prCreation: 'approval', }); }); it('reads dashboard body and apply checkedBranches', async () => { GlobalConfig.set({ checkedBranches: ['branch1', 'branch2'] }); const conf: RenovateConfig = {}; conf.prCreation = 'approval'; platform.findIssue.mockResolvedValueOnce({ title: '', number: 1, body: Fixtures.get('dependency-dashboard-with-10-PR.txt'), }); await dependencyDashboard.readDashboardBody(conf); expect(conf).toEqual({ dependencyDashboardAllAwaitingSchedule: false, dependencyDashboardAllPending: false, dependencyDashboardAllRateLimited: false, dependencyDashboardChecks: { branch1: 'global-config', branch2: 'global-config', configMigrationCheckboxState: 'no-checkbox', }, dependencyDashboardIssue: 1, dependencyDashboardRebaseAllOpen: false, dependencyDashboardTitle: 'Dependency Dashboard', prCreation: 'approval', }); }); it('reads dashboard body all pending approval', async () => { const conf: RenovateConfig = {}; conf.prCreation = 'approval'; platform.findIssue.mockResolvedValueOnce({ title: '', number: 1, body: Fixtures.get('dependency-dashboard-with-10-PR.txt').replace( '- [ ] ', '- [x] ', ), }); await dependencyDashboard.readDashboardBody(conf); expect(conf).toEqual({ dependencyDashboardChecks: { branchName1: 'approve', branchName2: 'approve', configMigrationCheckboxState: 'no-checkbox', }, dependencyDashboardIssue: 1, dependencyDashboardAllAwaitingSchedule: false, dependencyDashboardRebaseAllOpen: false, dependencyDashboardTitle: 'Dependency Dashboard', prCreation: 'approval', dependencyDashboardAllPending: true, dependencyDashboardAllRateLimited: false, }); }); it('reads dashboard body open all rate-limited', async () => { const conf: RenovateConfig = {}; conf.prCreation = 'approval'; platform.findIssue.mockResolvedValueOnce({ title: '', number: 1, body: Fixtures.get('dependency-dashboard-with-10-PR.txt').replace( '- [ ] ', '- [x] ', ), }); await dependencyDashboard.readDashboardBody(conf); expect(conf).toEqual({ dependencyDashboardChecks: { branchName5: 'unlimit', branchName6: 'unlimit', configMigrationCheckboxState: 'no-checkbox', }, dependencyDashboardIssue: 1, dependencyDashboardAllAwaitingSchedule: false, dependencyDashboardRebaseAllOpen: false, dependencyDashboardTitle: 'Dependency Dashboard', prCreation: 'approval', dependencyDashboardAllPending: false, dependencyDashboardAllRateLimited: true, }); }); it('reads dashboard body open all awaiting schedule', async () => { const conf: RenovateConfig = {}; conf.prCreation = 'approval'; platform.findIssue.mockResolvedValueOnce({ title: '', number: 1, body: Fixtures.get('dependency-dashboard-with-10-PR.txt').replace( '- [ ] ', '- [x] ', ), }); await dependencyDashboard.readDashboardBody(conf); expect(conf).toEqual({ dependencyDashboardChecks: { branchName3: 'unschedule', branchName4: 'unschedule', configMigrationCheckboxState: 'no-checkbox', }, dependencyDashboardIssue: 1, dependencyDashboardAllAwaitingSchedule: true, dependencyDashboardRebaseAllOpen: false, dependencyDashboardTitle: 'Dependency Dashboard', prCreation: 'approval', dependencyDashboardAllPending: false, dependencyDashboardAllRateLimited: false, }); }); it('reads dashboard body and config migration checkbox - checked', async () => { const conf: RenovateConfig = {}; conf.prCreation = 'approval'; platform.findIssue.mockResolvedValueOnce({ title: '', number: 1, body: '\n\n - [x] ', }); await dependencyDashboard.readDashboardBody(conf); expect(conf.dependencyDashboardChecks).toEqual({ configMigrationCheckboxState: 'checked', }); }); it('reads dashboard body and config migration checkbox - unchecked', async () => { const conf: RenovateConfig = {}; conf.prCreation = 'approval'; platform.findIssue.mockResolvedValueOnce({ title: '', number: 1, body: '\n\n - [ ] ', }); await dependencyDashboard.readDashboardBody(conf); expect(conf.dependencyDashboardChecks).toEqual({ configMigrationCheckboxState: 'unchecked', }); }); it('reads dashboard body and config migration pr link', async () => { const conf: RenovateConfig = {}; conf.prCreation = 'approval'; platform.findIssue.mockResolvedValueOnce({ title: '', number: 1, body: '\n\n ', }); await dependencyDashboard.readDashboardBody(conf); expect(conf.dependencyDashboardChecks).toEqual({ configMigrationCheckboxState: 'migration-pr-exists', }); }); it('does not read dashboard body but applies checkedBranches regardless', async () => { GlobalConfig.set({ checkedBranches: ['branch1', 'branch2'] }); const conf: RenovateConfig = {}; conf.dependencyDashboard = false; await dependencyDashboard.readDashboardBody(conf); expect(conf).toEqual({ dependencyDashboard: false, dependencyDashboardAllAwaitingSchedule: false, dependencyDashboardAllPending: false, dependencyDashboardAllRateLimited: false, dependencyDashboardChecks: { branch1: 'global-config', branch2: 'global-config', }, dependencyDashboardRebaseAllOpen: false, }); }); it('applies rebaseAllOpenBranches without dashboard', async () => { GlobalConfig.set({ rebaseAllOpenBranches: true }); const conf: RenovateConfig = {}; conf.dependencyDashboard = false; await dependencyDashboard.readDashboardBody(conf); expect(conf).toEqual({ dependencyDashboard: false, dependencyDashboardAllAwaitingSchedule: false, dependencyDashboardAllPending: false, dependencyDashboardAllRateLimited: false, dependencyDashboardChecks: {}, dependencyDashboardRebaseAllOpen: true, }); }); it('reads dashboard body and group size not met branches', async () => { const conf: RenovateConfig = {}; conf.prCreation = 'approval'; platform.findIssue.mockResolvedValueOnce({ title: '', number: 1, body: ` - [x] - [ ] `, }); await dependencyDashboard.readDashboardBody(conf); expect(conf.dependencyDashboardChecks).toMatchObject({ groupedBranch1: 'approveGroup', }); }); }); describe('ensureDependencyDashboard()', () => { beforeEach(() => { PackageFiles.add('main', null); GlobalConfig.reset(); logger.getProblems.mockReturnValue([]); }); it('does nothing if mode=silent', async () => { const branches: BranchConfig[] = []; config.mode = 'silent'; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(0); // same with dry run await dryRun(branches, platform, 0, 0); }); it('do nothing if dependencyDashboard is disabled', async () => { const branches: BranchConfig[] = []; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(1); expect(platform.ensureIssue).toHaveBeenCalledTimes(0); // same with dry run await dryRun(branches, platform, 1, 0); }); it('do nothing if it has no dependencyDashboardApproval branches', async () => { const branches = [ { ...mock(), prTitle: 'pr1', }, { ...mock(), prTitle: 'pr2', dependencyDashboardApproval: false, }, ]; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(1); expect(platform.ensureIssue).toHaveBeenCalledTimes(0); // same with dry run await dryRun(branches, platform, 1, 0); }); it('closes Dependency Dashboard when there is 0 PR opened and dependencyDashboardAutoclose is true', async () => { const branches: BranchConfig[] = []; config.dependencyDashboard = true; config.dependencyDashboardAutoclose = true; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(1); expect(platform.ensureIssueClosing.mock.calls[0][0]).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(0); // same with dry run await dryRun(branches, platform, 1, 0); }); it('closes Dependency Dashboard when all branches are automerged and dependencyDashboardAutoclose is true', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr1', result: 'automerged', }, { ...mock(), prTitle: 'pr2', result: 'automerged', dependencyDashboardApproval: false, }, ]; config.dependencyDashboard = true; config.dependencyDashboardAutoclose = true; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(1); expect(platform.ensureIssueClosing.mock.calls[0][0]).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(0); // same with dry run await dryRun(branches, platform, 1, 0); }); it('open or update Dependency Dashboard when all branches are closed and dependencyDashboardAutoclose is false', async () => { const branches: BranchConfig[] = []; config.dependencyDashboard = true; config.dependencyDashboardHeader = 'This is a header'; config.dependencyDashboardFooter = 'And this is a footer'; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toStartWith('This is a header\n'); expect(body).toContain( 'This repository currently has no open or pending branches.', ); expect(body).toContain('## Detected Dependencies\n\nNone detected'); expect(body).toEndWith('---\nAnd this is a footer\n'); // same with dry run await dryRun(branches, platform, 0, 1); }); it('open or update Dependency Dashboard when rules contain approvals', async () => { const branches: BranchConfig[] = []; config.repository = 'test'; config.packageRules = [ { dependencyDashboardApproval: true, }, {}, ]; config.dependencyDashboardHeader = 'This is a header for platform:{{platform}}'; config.dependencyDashboardFooter = 'And this is a footer for repository:{{repository}}'; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toStartWith('This is a header for platform:github\n'); expect(body).toContain( 'This repository currently has no open or pending branches.', ); expect(body).toContain('## Detected Dependencies\n\nNone detected'); expect(body).toEndWith('---\nAnd this is a footer for repository:test\n'); // same with dry run await dryRun(branches, platform, 0, 1); }); it('supports renovateVersion templating in header and footer', async () => { const branches: BranchConfig[] = []; config.dependencyDashboard = true; config.dependencyDashboardHeader = 'This is a header for renovateVersion:{{renovateVersion}}'; config.dependencyDashboardFooter = 'And this is a footer for renovateVersion:{{renovateVersion}}'; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue.mock.calls[0][0].body).toMatch( `renovateVersion:${pkg.version}`, ); }); it('checks an issue with 2 Pending Approvals, 2 not scheduled, 2 pr-hourly-limit-reached, 2 in error, 1 pending automerge and 1 other', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr1', upgrades: [{ ...mock(), depName: 'dep1' }], result: 'needs-approval', branchName: 'branchName1', }, { ...mock(), prTitle: 'pr2', upgrades: [{ ...mock(), depName: 'dep2' }], result: 'needs-approval', branchName: 'branchName2', }, { ...mock(), prTitle: 'pr3', upgrades: [{ ...mock(), depName: 'dep3' }], result: 'not-scheduled', branchName: 'branchName3', }, { ...mock(), prTitle: 'pr4', upgrades: [{ ...mock(), depName: 'dep4' }], result: 'not-scheduled', branchName: 'branchName4', }, { ...mock(), prTitle: 'pr5', upgrades: [{ ...mock(), depName: 'dep5' }], result: 'pr-limit-reached', branchName: 'branchName5', }, { ...mock(), prTitle: 'pr6', upgrades: [{ ...mock(), depName: 'dep6' }], result: 'pr-limit-reached', branchName: 'branchName6', }, { ...mock(), prTitle: 'pr7', upgrades: [{ ...mock(), depName: 'dep7' }], result: 'error', branchName: 'branchName7', }, { ...mock(), prTitle: 'pr8', upgrades: [{ ...mock(), depName: 'dep8' }], result: 'error', branchName: 'branchName8', }, { ...mock(), prTitle: 'pr9', upgrades: [{ ...mock(), depName: 'dep9' }], result: 'done', prBlockedBy: 'BranchAutomerge', branchName: 'branchName9', }, { ...mock(), prTitle: 'pr10', upgrades: [{ ...mock(), depName: 'dep10' }], result: 'done', prBlockedBy: undefined, branchName: 'branchName10', }, ]; config.dependencyDashboard = true; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue.mock.calls[0][0].body).toBe( Fixtures.get('dependency-dashboard-with-10-PR.txt'), ); // same with dry run await dryRun(branches, platform, 0, 1); }); it('checks an issue with dependency dashboard categories', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr0', upgrades: [{ ...mock(), depName: 'dep0' }], result: 'needs-approval', dependencyDashboardCategory: 'Category #2', branchName: 'branchName0', }, { ...mock(), prTitle: 'pr1', upgrades: [{ ...mock(), depName: 'dep1' }], result: 'needs-approval', branchName: 'branchName1', }, { ...mock(), prTitle: 'pr2', upgrades: [{ ...mock(), depName: 'dep2' }], result: 'needs-approval', dependencyDashboardCategory: 'Category #10', branchName: 'branchName2', }, { ...mock(), prTitle: 'pr3', upgrades: [{ ...mock(), depName: 'dep3' }], result: 'not-scheduled', branchName: 'branchName3', }, { ...mock(), prTitle: 'pr4', upgrades: [{ ...mock(), depName: 'dep4' }], result: 'not-scheduled', branchName: 'branchName4', dependencyDashboardCategory: 'Category #1', }, { ...mock(), prTitle: 'pr5', upgrades: [{ ...mock(), depName: 'dep5' }], result: 'pr-limit-reached', branchName: 'branchName5', dependencyDashboardCategory: 'Category #3', }, { ...mock(), prTitle: 'pr6', upgrades: [{ ...mock(), depName: 'dep6' }], result: 'pr-limit-reached', branchName: 'branchName6', dependencyDashboardCategory: 'Category #3', }, { ...mock(), prTitle: 'pr7', upgrades: [{ ...mock(), depName: 'dep7' }], result: 'error', branchName: 'branchName7', }, { ...mock(), prTitle: 'pr8', upgrades: [{ ...mock(), depName: 'dep8' }], result: 'error', branchName: 'branchName8', }, { ...mock(), prTitle: 'pr9', upgrades: [{ ...mock(), depName: 'dep9' }], result: 'done', prBlockedBy: 'BranchAutomerge', branchName: 'branchName9', }, ]; config.dependencyDashboard = true; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain('## Pending Approval'); expect(body).toContain(codeBlock` ### Category #2 - [ ] pr0 ### Category #10 - [ ] pr2 ### Others - [ ] pr1 ### All - [ ] 🔐 **Create all pending approval PRs at once** 🔐 `); expect(body).toContain('## Awaiting Schedule'); expect(body).toContain(codeBlock` ### Category #1 - [ ] pr4 ### Others - [ ] pr3 ### All - [ ] 🔐 **Create all awaiting schedule PRs at once** 🔐 `); expect(body).toContain('## Rate-Limited'); expect(body).toContain(codeBlock` ### Category #3 - [ ] pr5 - [ ] pr6 ### All - [ ] 🔐 **Create all rate-limited PRs at once** 🔐 `); // branches without a category are listed directly, with no `###` heading expect(body).toContain(codeBlock` ## Errored The following updates encountered an error and will be retried. To force a retry now, click on a checkbox below. - [ ] pr7 - [ ] pr8 `); expect(body).toContain(codeBlock` ## Pending Branch Automerge The following updates await pending status checks before automerging. To abort the branch automerge and create a PR instead, click on a checkbox below. - [ ] pr9 `); expect(body).toContain('## Detected Dependencies\n\nNone detected'); // same with dry run await dryRun(branches, platform, 0, 1); }); it('checks an issue with 2 PR pr-edited', async () => { const branches: BranchConfig[] = [ { ...mock(), prNo: 1, prTitle: 'pr1', upgrades: [{ ...mock(), depName: 'dep1' }], result: 'pr-edited', branchName: 'branchName1', }, { ...mock(), prNo: 2, prTitle: 'pr2', upgrades: [ { ...mock(), depName: 'dep2' }, { ...mock(), depName: 'dep3' }, ], result: 'pr-edited', branchName: 'branchName2', }, ]; config.dependencyDashboard = true; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue.mock.calls[0][0].body).toBe( Fixtures.get('dependency-dashboard-with-2-PR-edited.txt'), ); // same with dry run await dryRun(branches, platform, 0, 1); }); it('checks an issue with 3 PR in progress and rebase all option', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr1', upgrades: [{ ...mock(), depName: 'dep1' }], result: 'rebase', prNo: 1, branchName: 'branchName1', }, { ...mock(), prTitle: 'pr2', prNo: 2, upgrades: [ { ...mock(), depName: 'dep2' }, { ...mock(), depName: 'dep3' }, ], result: 'rebase', branchName: 'branchName2', }, { ...mock(), prTitle: 'pr3', prNo: 3, upgrades: [{ ...mock(), depName: 'dep3' }], result: 'rebase', branchName: 'branchName3', }, ]; config.dependencyDashboard = true; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue.mock.calls[0][0].body).toBe( Fixtures.get('dependency-dashboard-with-3-PR-in-progress.txt'), ); // same with dry run await dryRun(branches, platform, 0, 1); }); it('checks an issue with 2 PR closed / ignored', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr1', upgrades: [{ ...mock(), depName: 'dep1' }], result: 'already-existed', branchName: 'branchName1', }, { ...mock(), prTitle: 'pr2', upgrades: [ { ...mock(), depName: 'dep2' }, { ...mock(), depName: 'dep3' }, ], result: 'already-existed', branchName: 'branchName2', }, ]; config.dependencyDashboard = true; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue.mock.calls[0][0].body).toBe( Fixtures.get('dependency-dashboard-with-2-PR-closed-ignored.txt'), ); // same with dry run await dryRun(branches, platform, 0, 1); }); it('checks an issue with group size not met branches', async () => { const branches: BranchConfig[] = [ { ...mock(), upgrades: [{ ...mock(), depName: 'dep1' }], result: 'minimum-group-size-not-met', branchName: 'groupBranch1', }, ]; config.dependencyDashboard = true; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain(codeBlock` ## Group Size Not Met The following branches have not met their minimum group size. To create them, click on a checkbox below. - [ ] undefined `); // same with dry run await dryRun(branches, platform, 0, 1); }); it('checks an issue with 3 PR in approval', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr1', upgrades: [{ ...mock(), depName: 'dep1' }], result: 'needs-pr-approval', branchName: 'branchName1', }, { ...mock(), prTitle: 'pr2', upgrades: [ { ...mock(), depName: 'dep2' }, { ...mock(), depName: 'dep3' }, ], result: 'needs-pr-approval', branchName: 'branchName2', }, { ...mock(), prTitle: 'pr3', upgrades: [{ ...mock(), depName: 'dep3' }], result: 'needs-pr-approval', branchName: 'branchName3', }, { ...mock(), prTitle: 'pr4', upgrades: [{ ...mock(), depName: 'dep4' }], result: 'pending', branchName: 'branchName4', }, ]; config.dependencyDashboard = true; config.dependencyDashboardPrApproval = true; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue.mock.calls[0][0].body).toBe( Fixtures.get('dependency-dashboard-with-3-PR-in-approval.txt'), ); // same with dry run await dryRun(branches, platform, 0, 1); }); it('adds a checkbox for config migration', async () => { const branches: BranchConfig[] = []; config.repository = 'test'; config.packageRules = [ { dependencyDashboardApproval: true, }, {}, ]; config.dependencyDashboardHeader = 'This is a header for platform:{{platform}}'; config.dependencyDashboardFooter = 'And this is a footer for repository:{{repository}}'; await dependencyDashboard.ensureDependencyDashboard( config, branches, undefined, { result: 'add-checkbox', }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue.mock.calls[0][0].body).toMatch( ' - [ ] Select this checkbox to let Renovate create an automated Config Migration PR.', ); }); it('adds config migration pr link when it exists', async () => { const branches: BranchConfig[] = []; config.repository = 'test'; config.packageRules = [ { dependencyDashboardApproval: true, }, {}, ]; config.dependencyDashboardHeader = 'This is a header for platform:{{platform}}'; config.dependencyDashboardFooter = 'And this is a footer for repository:{{repository}}'; await dependencyDashboard.ensureDependencyDashboard( config, branches, undefined, { result: 'pr-exists', prNumber: 1, }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue.mock.calls[0][0].body).toMatch( `## Config Migration Needed\n\n See Config Migration PR:`, ); }); it('adds related text when config migration pr has been modified', async () => { const branches: BranchConfig[] = []; config.repository = 'test'; config.packageRules = [ { dependencyDashboardApproval: true, }, {}, ]; config.dependencyDashboardHeader = 'This is a header for platform:{{platform}}'; config.dependencyDashboardFooter = 'And this is a footer for repository:{{repository}}'; await dependencyDashboard.ensureDependencyDashboard( config, branches, undefined, { result: 'pr-modified', prNumber: 1, }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue.mock.calls[0][0].body).toMatch( 'The Config Migration branch exists but has been modified by another user. Renovate will not push to this branch unless it is first deleted.', ); }); it('does not add a config migration checkbox when not needed', async () => { const branches: BranchConfig[] = []; config.repository = 'test'; config.packageRules = [ { dependencyDashboardApproval: true, }, {}, ]; config.dependencyDashboardHeader = 'This is a header for platform:{{platform}}'; config.dependencyDashboardFooter = 'And this is a footer for repository:{{repository}}'; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssueClosing).toHaveBeenCalledTimes(0); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].title).toBe( config.dependencyDashboardTitle, ); expect(platform.ensureIssue.mock.calls[0][0].body).not.toMatch( '## Config Migration Needed', ); }); it('contains logged problems', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr1', upgrades: [ { ...mock(), depName: 'dep1', repository: 'repo1' }, ], result: 'pending', branchName: 'branchName1', }, ]; logger.getProblems.mockReturnValueOnce([ { level: ERROR, msg: 'everything is broken', }, { level: WARN, msg: 'just a bit', }, { level: ERROR, msg: 'i am a duplicated problem', }, { level: ERROR, msg: 'i am a duplicated problem', }, { level: ERROR, msg: 'i am a non-duplicated problem', }, { level: WARN, msg: 'i am a non-duplicated problem', }, { level: WARN, msg: 'i am an artifact error', artifactErrors: {}, }, ]); config.dependencyDashboard = true; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; // duplicates are collapsed, and problems with artifact errors are omitted expect(body).toContain(codeBlock` ## Repository Problems Renovate tried to run on this repository, but found these problems. - ❌ ERROR: everything is broken - âš ī¸ WARN: just a bit - ❌ ERROR: i am a duplicated problem - ❌ ERROR: i am a non-duplicated problem - âš ī¸ WARN: i am a non-duplicated problem `); expect(body).not.toContain('i am an artifact error'); expect(body).toContain('## Pending Status Checks'); expect(body).toContain(' - [ ] pr1'); }); it('contains logged problems with custom header', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr1', upgrades: [ { ...mock(), depName: 'dep1', repository: 'repo1' }, ], result: 'pending', branchName: 'branchName1', }, ]; logger.getProblems.mockReturnValueOnce([ { level: ERROR, msg: 'i am a non-duplicated problem', }, ]); config.dependencyDashboard = true; config.customizeDashboard = { repoProblemsHeader: 'platform is {{platform}}', }; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; // the templated custom header replaces the default problems header expect(body).toContain(codeBlock` ## Repository Problems platform is github - ❌ ERROR: i am a non-duplicated problem `); expect(body).not.toContain( 'Renovate tried to run on this repository, but found these problems.', ); expect(body).toContain('## Pending Status Checks'); expect(body).toContain(' - [ ] pr1'); }); it('dependency Dashboard All Pending Approval', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr1', upgrades: [{ ...mock(), depName: 'dep1' }], result: 'needs-approval', branchName: 'branchName1', }, { ...mock(), prTitle: 'pr2', upgrades: [{ ...mock(), depName: 'dep2' }], result: 'needs-approval', branchName: 'branchName2', }, ]; config.dependencyDashboard = true; config.dependencyDashboardChecks = { branchName1: 'approve-branch', branchName2: 'approve-branch', }; config.dependencyDashboardIssue = 1; getIssueSpy.mockResolvedValueOnce({ title: 'Dependency Dashboard', body: codeBlock` This issue contains a list of Renovate updates and their statuses. ## Pending Approval These branches will be created by Renovate only once you click their checkbox below. - [ ] pr1 - [ ] pr2 - [x] 🔐 **Create all pending approval PRs at once** 🔐 `, }); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); // all checkboxes are reset to unchecked once the requested action has run const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain(' - [ ] '); expect(body).toContain(' - [ ] pr1'); expect(body).toContain(' - [ ] pr2'); }); it('dependency Dashboard Open All rate-limited', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr1', upgrades: [{ ...mock(), depName: 'dep1' }], result: 'branch-limit-reached', branchName: 'branchName1', }, { ...mock(), prTitle: 'pr2', upgrades: [{ ...mock(), depName: 'dep2' }], result: 'pr-limit-reached', branchName: 'branchName2', }, ]; config.dependencyDashboard = true; config.dependencyDashboardChecks = { branchName1: 'unlimit-branch', branchName2: 'unlimit-branch', }; config.dependencyDashboardIssue = 1; getIssueSpy.mockResolvedValueOnce({ title: 'Dependency Dashboard', body: codeBlock` This issue contains a list of Renovate updates and their statuses. ## Rate-limited These updates are currently rate-limited. Click on a checkbox below to force their creation now. - [x] **Open all rate-limited PRs** - [ ] pr1 - [ ] pr2 `, }); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); // all checkboxes are reset to unchecked once the requested action has run const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain(' - [ ] '); expect(body).toContain(' - [ ] pr1'); expect(body).toContain(' - [ ] pr2'); }); it('rechecks branches', async () => { const branches: BranchConfig[] = [ { ...mock(), prTitle: 'pr1', upgrades: [{ ...mock(), depName: 'dep1' }], result: 'needs-approval', branchName: 'branchName1', }, { ...mock(), prTitle: 'pr2', upgrades: [{ ...mock(), depName: 'dep2' }], result: 'needs-approval', branchName: 'branchName2', }, { ...mock(), prTitle: 'pr3', upgrades: [{ ...mock(), depName: 'dep3' }], result: 'not-scheduled', branchName: 'branchName3', }, ]; config.dependencyDashboard = true; config.dependencyDashboardChecks = { branchName2: 'approve-branch' }; config.dependencyDashboardIssue = 1; vi.mocked(platform.getIssue).mockResolvedValueOnce({ title: 'Dependency Dashboard', body: '', }); vi.mocked(platform.getIssue).mockResolvedValueOnce({ title: 'Dependency Dashboard', body: `This issue contains a list of Renovate updates and their statuses. ## Pending Approval The following branches are pending approval. To create them, click on a checkbox below. - [ ] pr1 - [x] pr2 ## Awaiting Schedule The following updates are awaiting their schedule. To get an update now, click on a checkbox below. - [x] pr3 - [x] ' `, }); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); const body = platform.ensureIssue.mock.calls[0][0].body; // the rechecked branch is unchecked again, the others keep their state expect(body).toContain('## Pending Approval'); expect(body).toContain(' - [ ] pr1'); expect(body).toContain(' - [ ] pr2'); expect(body).toContain(' - [ ] '); expect(body).toContain('## Awaiting Schedule'); expect(body).toContain( ' - [x] pr3', ); expect(body).not.toContain('rebase-all-open-prs'); }); it('skips fetching issue if content unchanged', async () => { const branches: BranchConfig[] = []; config.dependencyDashboard = true; config.dependencyDashboardChecks = {}; config.dependencyDashboardIssue = 1; vi.mocked(platform.getIssue).mockResolvedValueOnce({ title: 'Dependency Dashboard', body: `This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. This repository currently has no open or pending branches. ## Detected Dependencies None detected `, }); vi.mocked(platform.getIssue).mockResolvedValueOnce({ title: 'Dependency Dashboard', body: '', }); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).not.toHaveBeenCalled(); }); it('forwards configured labels to the ensure issue call', async () => { const branches: BranchConfig[] = []; config.dependencyDashboard = true; config.dependencyDashboardLabels = ['Renovate', 'Maintenance']; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].labels).toStrictEqual([ 'Renovate', 'Maintenance', ]); // same with dry run await dryRun(branches, platform, 0, 1); }); describe('checks detected dependencies section', () => { const packageFiles = Fixtures.getJson('./package-files.json'); const packageFilesWithDigest = Fixtures.getJson( './package-files-digest.json', ); let config: RenovateConfig; beforeAll(() => { GlobalConfig.reset(); config = getConfig(); config.dependencyDashboard = true; }); describe('single base branch repo', () => { beforeEach(() => { PackageFiles.clear(); PackageFiles.add('main', packageFiles); }); it('add detected dependencies to the Dependency Dashboard body', async () => { const branches: BranchConfig[] = []; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain('## Detected Dependencies'); // one manager block in full, to pin down the nesting expect(body).toContain(codeBlock`
dockerfile (1)
Dockerfile (1) - \`ubuntu 20.04\` → [Updates: \`22.04\`]
`); expect(body).toContain('
npm (1)'); expect(body).toContain( '
package.json (8)', ); expect(body).toContain(' - `cookie-parser ^1.4.5`'); expect(body).toContain(' - `express ~4.17.1` → [Updates: `~4.18.0`]'); expect(body).toContain(' - `express-handlebars >=5.3.4`'); expect(body).toContain(' - `geoip-lite 1.4.*`'); expect(body).toContain(' - `nodemailer 6.7.0` → [Updates: `6.7.4`]'); expect(body).toContain( ' - `redis 3.1.2-3.4.0` → [Updates: `3.1.1`, `3.1.2`, `4.1.0`]', ); expect(body).toContain(' - `dotenv 10.0.0` → [Updates: `16.0.0`]'); expect(body).toContain(' - `nodemon 2.0.14` → [Updates: `2.0.16`]'); expect(body).toContain('
poetry (1)'); expect(body).toContain( '
pyproject.toml (1)', ); expect(body).toContain(' - `six <=1.3.0` → [Updates: `<=1.16.0`]'); // same with dry run await dryRun(branches, platform, 0, 1); }); it('show default message in issues body when packageFiles is empty', async () => { const branches: BranchConfig[] = []; PackageFiles.clear(); PackageFiles.add('main', {}); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain('## Detected Dependencies\n\nNone detected'); expect(body).not.toContain('
'); // same with dry run await dryRun(branches, platform, 0, 1); }); it('show default message in issues body when when packageFiles is null', async () => { const branches: BranchConfig[] = []; PackageFiles.clear(); PackageFiles.add('main', null); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain('## Detected Dependencies\n\nNone detected'); expect(body).not.toContain('
'); // same with dry run await dryRun(branches, platform, 0, 1); }); it('shows different combinations of version+digest for a given dependency', async () => { const branches: BranchConfig[] = []; PackageFiles.add('main', packageFilesWithDigest); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain('
dockerfile (4)'); expect(body).toContain( '
digest-only/Dockerfile (1)', ); expect(body).toContain( ' - `ubuntu sha256:06b5d30fabc1fc574f2ecab87375692299d45f8f190d9b71f512deb494114e1f`', ); // a dep with neither version nor digest gets no count and no entry expect(body).toContain(codeBlock`
no-ver-no-digest/Dockerfile
`); expect(body).toContain( '
ver-and-digest/Dockerfile (1)', ); expect(body).toContain( ' - `ubuntu 18.04@sha256:a7ed45c4a95fbe19f9c5fb9d1ca58b2431b2a4984754be2f50ccec99d9428b79`', ); expect(body).toContain( '
ver-only/Dockerfile (1)', ); expect(body).toContain(' - `ubuntu 20.04`'); // same with dry run await dryRun(branches, platform, 0, 1); }); it('shows deprecations', async () => { const branches: BranchConfig[] = []; const packageFilesWithDeprecations = clone(packageFiles); packageFilesWithDeprecations.npm[0].deps[0].deprecationMessage = 'some deprecation message'; packageFilesWithDeprecations.npm[0].deps[2].updates.push({ updateType: 'replacement', newName: 'prop-types-tools', newValue: '2.17.0', branchName: 'renovate/airbnb-prop-types-replacement', }); PackageFiles.add('main', packageFilesWithDeprecations); await dependencyDashboard.ensureDependencyDashboard( config, branches, packageFilesWithDeprecations, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect(platform.ensureIssue.mock.calls[0][0].body).toInclude( 'The following dependencies are either deprecated or have replacements available.', ); expect(platform.ensureIssue.mock.calls[0][0].body).toInclude( '| npm | [cookie-parser](https://redirect.github.com/expressjs/cookie-parser) | ![Unavailable]', ); expect(platform.ensureIssue.mock.calls[0][0].body).toInclude( 'npm | [express-handlebars](https://redirect.github.com/express-handlebars/express-handlebars) | ![Available]', ); // same with dry run await dryRun(branches, platform, 0, 1); }); it('shows replacement as unavailable when target already exists as sibling', async () => { const branches: BranchConfig[] = []; const packageFilesWithExistingReplacement: Record< string, PackageFile[] > = { npm: [ { packageFile: 'package.json', deps: [ { depName: '@material-ui/core', deprecationMessage: 'This package is deprecated', updates: [ { updateType: 'replacement', newName: '@mui/material', newValue: '^5.0.0', }, ], }, { depName: '@material-ui/icons', updates: [ { updateType: 'replacement', newName: '@mui/icons-material', newValue: '^5.0.0', }, ], }, { depName: '@mui/material', updates: [ { newValue: '^6.2.0', updateType: 'minor', }, ], }, { depName: '@mui/icons-material', updates: [ { newValue: '^6.2.0', updateType: 'minor', }, ], }, ], }, ], }; PackageFiles.add('main', packageFilesWithExistingReplacement); await dependencyDashboard.ensureDependencyDashboard( config, branches, packageFilesWithExistingReplacement, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toInclude('Deprecations / Replacements'); expect(body).toInclude('@material-ui/core'); expect(body).toInclude('@material-ui/icons'); expect(body).not.toInclude( '![Available](https://img.shields.io/badge/available-green?style=flat-square)', ); expect(body).toInclude( '![Unavailable](https://img.shields.io/badge/unavailable-orange?style=flat-square)', ); }); it('handles missing version/digest values correctly', async () => { const branches: BranchConfig[] = []; const packageFilesWithMissingVersions = { npm: [ { packageFile: 'package.json', deps: [ { depName: 'dep-with-version-only', currentValue: '1.0.0', }, { depName: 'dep-with-digest-only', currentDigest: 'sha256:1234567890', }, { depName: 'dep-with-version-and-digest', currentValue: '2.0.0', currentDigest: 'sha256:0987654321', }, { depName: 'dep-with-locked-version-only', lockedVersion: '3.0.0', }, { depName: 'dep-with-no-version-info', }, ], }, ], }; PackageFiles.add('main', packageFilesWithMissingVersions); await dependencyDashboard.ensureDependencyDashboard( config, branches, packageFilesWithMissingVersions, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const dashboardBody = platform.ensureIssue.mock.calls[0][0].body; // Version only case expect(dashboardBody).toInclude('`dep-with-version-only 1.0.0`'); // Digest only case expect(dashboardBody).toInclude( '`dep-with-digest-only sha256:1234567890`', ); // Version and digest case expect(dashboardBody).toInclude( '`dep-with-version-and-digest 2.0.0@sha256:0987654321`', ); // Locked version fallback case expect(dashboardBody).toInclude( '`dep-with-locked-version-only lock file @ 3.0.0`', ); // No version info case expect(dashboardBody).toInclude( '`dep-with-no-version-info unknown version`', ); // Verify no 'undefined' appears in the output expect(dashboardBody).not.toInclude('undefined'); // same with dry run await dryRun(branches, platform, 0, 1); }); }); describe('multi base branch repo', () => { beforeEach(() => { PackageFiles.clear(); PackageFiles.add('main', packageFiles); PackageFiles.add('dev', packageFiles); }); it('add detected dependencies to the Dependency Dashboard body', async () => { const branches: BranchConfig[] = []; await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain('## Detected Dependencies'); // dependencies are grouped per base branch, base branches sorted const devIndex = body.indexOf( '
Branch dev', ); const mainIndex = body.indexOf( '
Branch main', ); expect(devIndex).toBeGreaterThan(-1); expect(mainIndex).toBeGreaterThan(devIndex); // both base branches list the same dependencies for (const section of [ body.slice(devIndex, mainIndex), body.slice(mainIndex), ]) { expect(section).toContain( '
dockerfile (1)', ); expect(section).toContain( '
Dockerfile (1)', ); expect(section).toContain(' - `ubuntu 20.04` → [Updates: `22.04`]'); expect(section).toContain('
npm (1)'); expect(section).toContain( '
package.json (8)', ); expect(section).toContain(' - `cookie-parser ^1.4.5`'); expect(section).toContain( ' - `express ~4.17.1` → [Updates: `~4.18.0`]', ); expect(section).toContain(' - `express-handlebars >=5.3.4`'); expect(section).toContain(' - `geoip-lite 1.4.*`'); expect(section).toContain( ' - `nodemailer 6.7.0` → [Updates: `6.7.4`]', ); expect(section).toContain( ' - `redis 3.1.2-3.4.0` → [Updates: `3.1.1`, `3.1.2`, `4.1.0`]', ); expect(section).toContain( ' - `dotenv 10.0.0` → [Updates: `16.0.0`]', ); expect(section).toContain( ' - `nodemon 2.0.14` → [Updates: `2.0.16`]', ); expect(section).toContain('
poetry (1)'); expect(section).toContain( '
pyproject.toml (1)', ); expect(section).toContain( ' - `six <=1.3.0` → [Updates: `<=1.16.0`]', ); } // same with dry run await dryRun(branches, platform, 0, 1); }); it('show default message in issues body when packageFiles is empty', async () => { const branches: BranchConfig[] = []; PackageFiles.add('main', {}); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain('## Detected Dependencies'); // the `dev` branch still lists its dependencies expect(body).toContain('
Branch dev'); expect(body).toContain('
dockerfile (1)'); expect(body).toContain(' - `ubuntu 20.04` → [Updates: `22.04`]'); expect(body).toContain('
npm (1)'); expect(body).toContain(' - `cookie-parser ^1.4.5`'); expect(body).toContain('
poetry (1)'); expect(body).toContain(' - `six <=1.3.0` → [Updates: `<=1.16.0`]'); // only the `main` branch falls back to the default message expect(body).toContain(codeBlock`
Branch main
None detected
`); // same with dry run await dryRun(branches, platform, 0, 1); }); it('show default message in issues body when when packageFiles is null', async () => { const branches: BranchConfig[] = []; PackageFiles.add('main', null); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain('## Detected Dependencies'); // the `dev` branch still lists its dependencies expect(body).toContain('
Branch dev'); expect(body).toContain('
dockerfile (1)'); expect(body).toContain(' - `ubuntu 20.04` → [Updates: `22.04`]'); expect(body).toContain('
npm (1)'); expect(body).toContain(' - `cookie-parser ^1.4.5`'); expect(body).toContain('
poetry (1)'); expect(body).toContain(' - `six <=1.3.0` → [Updates: `<=1.16.0`]'); // only the `main` branch falls back to the default message expect(body).toContain(codeBlock`
Branch main
None detected
`); // same with dry run await dryRun(branches, platform, 0, 1); }); it('truncates the body of a really big repo', async () => { const branches: BranchConfig[] = []; const packageFilesBigRepo = genRandPackageFile(100, 700); PackageFiles.clear(); PackageFiles.add('main', packageFilesBigRepo); await dependencyDashboard.ensureDependencyDashboard( config, branches, {}, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); expect( platform.ensureIssue.mock.calls[0][0].body.length < platform.maxBodyLength(), ).toBeTrue(); // same with dry run await dryRun(branches, platform, 0, 1); }); }); describe('dependency dashboard lookup warnings', () => { beforeEach(() => { PackageFiles.add('main', packageFiles); PackageFiles.add('dev', packageFiles); }); afterEach(() => { PackageFiles.clear(); }); it('Dependency Lookup Warnings message in issues body', async () => { const branches: BranchConfig[] = []; PackageFiles.add('main', { npm: [{ packageFile: 'package.json', deps: [] }], }); const dep = [ { warnings: [{ message: 'dependency-2', topic: '' }], }, ]; const packageFiles: Record = { npm: [{ packageFile: 'package.json', deps: dep }], }; await dependencyDashboard.ensureDependencyDashboard( config, branches, packageFiles, { result: 'no-migration' }, ); expect(platform.ensureIssue).toHaveBeenCalledTimes(1); const body = platform.ensureIssue.mock.calls[0][0].body; expect(body).toContain(codeBlock` --- > [!WARNING] > Renovate failed to look up the following dependencies: \`dependency-2\`. >${' '} > Files affected: \`package.json\` --- `); // same with dry run await dryRun(branches, platform, 0, 1); }); }); describe('PackageFiles.getDashboardMarkdown()', () => { const note = emojify( `> :information_source: **Note**\n> \n> Detected dependencies section has been truncated\n\n`, ); const title = `## Detected Dependencies\n\n`; beforeEach(() => { PackageFiles.clear(); }); it('does not truncates as there is enough space to fit', () => { PackageFiles.add('main', packageFiles); const nonTruncated = PackageFiles.getDashboardMarkdown(Infinity); const len = (title + note + nonTruncated).length; const truncated = PackageFiles.getDashboardMarkdown(len); const truncatedWithTitle = PackageFiles.getDashboardMarkdown(len); expect(truncated.length === nonTruncated.length).toBeTrue(); expect(truncatedWithTitle.includes(note)).toBeFalse(); }); it('removes a branch with no managers', () => { PackageFiles.add('main', packageFiles); PackageFiles.add('dev', packageFilesWithDigest); const md = PackageFiles.getDashboardMarkdown(Infinity, false); const len = md.length; PackageFiles.add('empty/branch', {}); const truncated = PackageFiles.getDashboardMarkdown(len, false); expect(truncated.includes('empty/branch')).toBeFalse(); expect(truncated.length === len).toBeTrue(); }); it('removes a manager with no package files', () => { PackageFiles.add('main', packageFiles); const md = PackageFiles.getDashboardMarkdown(Infinity, false); const len = md.length; PackageFiles.add('dev', { dockerfile: [] }); const truncated = PackageFiles.getDashboardMarkdown(len, false); expect(truncated.includes('dev')).toBeFalse(); expect(truncated.length === len).toBeTrue(); }); it('does nothing when there are no base branches left', () => { const truncated = PackageFiles.getDashboardMarkdown(-1, false); expect(truncated).toBe(''); }); it('removes an entire base branch', () => { PackageFiles.add('main', packageFiles); const md = PackageFiles.getDashboardMarkdown(Infinity); const len = md.length + note.length; PackageFiles.add('dev', packageFilesWithDigest); const truncated = PackageFiles.getDashboardMarkdown(len); expect(truncated.includes('dev')).toBeFalse(); expect(truncated.length === len).toBeTrue(); }); it('ensures original data is unchanged', () => { PackageFiles.add('main', packageFiles); PackageFiles.add('dev', packageFilesWithDigest); const pre = PackageFiles.getDashboardMarkdown(Infinity); const truncated = PackageFiles.getDashboardMarkdown(-1, false); const post = PackageFiles.getDashboardMarkdown(Infinity); expect(truncated).toBe(''); expect(pre === post).toBeTrue(); expect(post.includes('main')).toBeTrue(); expect(post.includes('dev')).toBeTrue(); }); }); }); }); describe('getDashboardMarkdownVulnerabilities()', () => { const packageFiles = Fixtures.getJson>( './package-files.json', ); it('return empty string if summary is empty', async () => { const result = await getDashboardMarkdownVulnerabilities( config, packageFiles, ); expect(result).toBeEmpty(); }); it('return empty string if summary is set to none', async () => { const result = await getDashboardMarkdownVulnerabilities( { ...config, dependencyDashboardOSVVulnerabilitySummary: 'none', }, packageFiles, ); expect(result).toBeEmpty(); }); it('return no data section if summary is set to all and no vulnerabilities', async () => { const fetchVulnerabilitiesMock = vi.fn(); createVulnerabilitiesMock.mockResolvedValueOnce({ fetchVulnerabilities: fetchVulnerabilitiesMock, }); fetchVulnerabilitiesMock.mockResolvedValueOnce([]); const result = await getDashboardMarkdownVulnerabilities( { ...config, dependencyDashboardOSVVulnerabilitySummary: 'all', }, {}, ); expect(result).toBe( `## Vulnerabilities\n\nRenovate has not found any CVEs on [osv.dev](https://osv.dev).\n\n`, ); }); it('return all vulnerabilities if set to all and disabled osvVulnerabilities', async () => { const fetchVulnerabilitiesMock = vi.fn(); createVulnerabilitiesMock.mockResolvedValueOnce({ fetchVulnerabilities: fetchVulnerabilitiesMock, }); fetchVulnerabilitiesMock.mockResolvedValueOnce([ { packageName: 'express', depVersion: '4.17.3', fixedVersion: '4.18.1', packageFileConfig: { manager: 'npm', }, vulnerability: { id: 'GHSA-29mw-wpgm-hmr9', }, }, { packageName: 'cookie-parser', depVersion: '1.4.6', packageFileConfig: { manager: 'npm', }, vulnerability: { id: 'GHSA-35jh-r3h4-6jhm', }, }, ]); const result = await getDashboardMarkdownVulnerabilities( { ...config, dependencyDashboardOSVVulnerabilitySummary: 'all', osvVulnerabilityAlerts: true, }, packageFiles, ); const heading = '## Vulnerabilities\n\n> ❗ **Important**\n> \n' + '> `1`/`2` CVEs have Renovate fixes.\n\n'; expect(result).toStartWith(heading); expect(result).toContain('
npm'); expect(result).toContain('
express'); expect(result).toContain( '- [GHSA-29mw-wpgm-hmr9](https://osv.dev/vulnerability/GHSA-29mw-wpgm-hmr9) (fixed in 4.18.1)', ); expect(result).toContain('
cookie-parser'); expect(result).toContain( '- [GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm)', ); }); it('return unresolved vulnerabilities if set to "unresolved"', async () => { const fetchVulnerabilitiesMock = vi.fn(); createVulnerabilitiesMock.mockResolvedValueOnce({ fetchVulnerabilities: fetchVulnerabilitiesMock, }); fetchVulnerabilitiesMock.mockResolvedValueOnce([ { packageName: 'express', depVersion: '4.17.3', fixedVersion: '4.18.1', packageFileConfig: { manager: 'npm', }, vulnerability: { id: 'GHSA-29mw-wpgm-hmr9', }, }, { packageName: 'cookie-parser', depVersion: '1.4.6', packageFileConfig: { manager: 'npm', }, vulnerability: { id: 'GHSA-35jh-r3h4-6jhm', }, }, ]); const heading = '## Vulnerabilities\n\n> ❗ **Important**\n> \n' + '> `1`/`2` CVEs have possible Renovate fixes.\n' + '> See [`osvVulnerabilityAlerts`](https://docs.renovatebot.com/configuration-options/#osvvulnerabilityalerts) to allow Renovate to supply fixes.\n\n'; const result = await getDashboardMarkdownVulnerabilities( { ...config, dependencyDashboardOSVVulnerabilitySummary: 'unresolved', }, packageFiles, ); expect(result).toStartWith(heading); expect(result).toContain('
npm'); expect(result).toContain('
cookie-parser'); expect(result).toContain( '- [GHSA-35jh-r3h4-6jhm](https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm)', ); // the vulnerability with a fixed version is left out expect(result).not.toContain('express'); expect(result).not.toContain('GHSA-29mw-wpgm-hmr9'); }); }); describe('getAbandonedPackagesMd()', () => { it('returns empty string when no abandoned packages exist', () => { const packageFiles: Record = { npm: [ { packageFile: 'package.json', deps: [ { depName: 'lodash', isAbandoned: false }, { depName: 'express', isAbandoned: false }, ], }, ], }; const result = dependencyDashboard.getAbandonedPackagesMd( config, packageFiles, ); expect(result).toEqual(''); }); it('returns formatted markdown when abandoned packages exist', () => { const packageFiles: Record = { npm: [ { packageFile: 'package.json', deps: [ { depName: 'abandoned-pkg', isAbandoned: true, mostRecentTimestamp: asTimestamp('2020-05-15T12:00:00.000Z')!, }, ], }, ], }; const result = dependencyDashboard.getAbandonedPackagesMd( config, packageFiles, ); expect(result).toContain('## Abandoned Dependencies'); expect(result).toContain( 'The following dependencies have not received updates for an extended period and may be unmaintained.', ); expect(result).toContain( 'View abandoned dependencies (1)', ); expect(result).toContain('> â„šī¸ **Note**'); expect(result).toContain('| Datasource | Package | Last Updated |'); expect(result).toContain('| npm | `abandoned-pkg` | `2020-05-15` |'); expect(result).toContain('abandonmentThreshold'); }); it('handles multiple abandoned packages across different managers', () => { const packageFiles: Record = { npm: [ { packageFile: 'package.json', deps: [ { depName: 'pkg1', isAbandoned: true, mostRecentTimestamp: asTimestamp('2021-01-10T10:00:00.000Z')!, }, { depName: 'pkg2', isAbandoned: false }, { depName: 'pkg3', isAbandoned: true, mostRecentTimestamp: asTimestamp('2020-11-05T15:30:00.000Z')!, }, ], }, ], gradle: [ { packageFile: 'build.gradle', deps: [ { depName: 'org.example:lib', isAbandoned: true, mostRecentTimestamp: asTimestamp('2019-07-22T08:15:00.000Z')!, }, ], }, ], }; const result = dependencyDashboard.getAbandonedPackagesMd( config, packageFiles, ); expect(result).toContain('## Abandoned Dependencies'); expect(result).toContain( 'The following dependencies have not received updates for an extended period and may be unmaintained.', ); expect(result).toContain( 'View abandoned dependencies (3)', ); expect(result).toContain('> â„šī¸ **Note**'); expect(result).toContain('| Datasource | Package | Last Updated |'); expect(result).toContain('| gradle | `org.example:lib` | `2019-07-22` |'); expect(result).toContain('| npm | `pkg1` | `2021-01-10` |'); expect(result).toContain('| npm | `pkg3` | `2020-11-05` |'); expect(result).not.toContain('pkg2'); }); it('displays "unknown" when mostRecentTimestamp is missing', () => { const packageFiles: Record = { npm: [ { packageFile: 'package.json', deps: [ { depName: 'pkg-with-date', isAbandoned: true, mostRecentTimestamp: asTimestamp('2021-03-17T14:30:00.000Z')!, }, { depName: 'pkg-no-date', isAbandoned: true }, ], }, ], }; const result = dependencyDashboard.getAbandonedPackagesMd( config, packageFiles, ); expect(result).toContain('## Abandoned Dependencies'); expect(result).toContain( 'The following dependencies have not received updates for an extended period and may be unmaintained.', ); expect(result).toContain( 'View abandoned dependencies (2)', ); expect(result).toContain('> â„šī¸ **Note**'); expect(result).toContain('| Datasource | Package | Last Updated |'); expect(result).toContain('| npm | `pkg-with-date` | `2021-03-17` |'); expect(result).toContain('| npm | `pkg-no-date` | `unknown` |'); }); it('handles empty deps array', () => { const packageFiles: Record = { npm: [ { packageFile: 'package.json', deps: [], }, ], }; const result = dependencyDashboard.getAbandonedPackagesMd( config, packageFiles, ); expect(result).toEqual(''); }); }); });