Files
renovate/lib/workers/repository/dependency-dashboard.spec.ts
Sebastian Poxhofer df8c4051ff test(core): replace file snapshots with explicit assertions (#45660)
* test(core): replace file snapshots with explicit assertions

* test(core): replace inline snapshots with explicit assertions

* test(core): assert on relevant sections instead of full bodies

* test(core): drop assertions implied by full equality

* test(core): address review feedback

* test(core): use resolves instead of awaiting inside expect
2026-09-04 10:28:55 +00:00

2395 lines
82 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, PackageFile[]> {
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<Platform>,
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] <!-- rebase-all-open-prs -->`,
});
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(
'- [ ] <!-- approve-all-pending-prs -->',
'- [x] <!-- approve-all-pending-prs -->',
),
});
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(
'- [ ] <!-- create-all-rate-limited-prs -->',
'- [x] <!-- create-all-rate-limited-prs -->',
),
});
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(
'- [ ] <!-- create-all-awaiting-schedule-prs -->',
'- [x] <!-- create-all-awaiting-schedule-prs -->',
),
});
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] <!-- create-config-migration-pr -->',
});
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 - [ ] <!-- create-config-migration-pr -->',
});
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 <!-- config-migration-pr-info -->',
});
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] <!-- approveGroup-branch=groupedBranch1 -->
- [ ] <!-- approveGroup-branch=groupedBranch2 -->
`,
});
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<BranchConfig>(),
prTitle: 'pr1',
},
{
...mock<BranchConfig>(),
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<BranchConfig>(),
prTitle: 'pr1',
result: 'automerged',
},
{
...mock<BranchConfig>(),
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<BranchConfig>(),
prTitle: 'pr1',
upgrades: [{ ...mock<BranchUpgradeConfig>(), depName: 'dep1' }],
result: 'needs-approval',
branchName: 'branchName1',
},
{
...mock<BranchConfig>(),
prTitle: 'pr2',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep2' }],
result: 'needs-approval',
branchName: 'branchName2',
},
{
...mock<BranchConfig>(),
prTitle: 'pr3',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep3' }],
result: 'not-scheduled',
branchName: 'branchName3',
},
{
...mock<BranchConfig>(),
prTitle: 'pr4',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep4' }],
result: 'not-scheduled',
branchName: 'branchName4',
},
{
...mock<BranchConfig>(),
prTitle: 'pr5',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep5' }],
result: 'pr-limit-reached',
branchName: 'branchName5',
},
{
...mock<BranchConfig>(),
prTitle: 'pr6',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep6' }],
result: 'pr-limit-reached',
branchName: 'branchName6',
},
{
...mock<BranchConfig>(),
prTitle: 'pr7',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep7' }],
result: 'error',
branchName: 'branchName7',
},
{
...mock<BranchConfig>(),
prTitle: 'pr8',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep8' }],
result: 'error',
branchName: 'branchName8',
},
{
...mock<BranchConfig>(),
prTitle: 'pr9',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep9' }],
result: 'done',
prBlockedBy: 'BranchAutomerge',
branchName: 'branchName9',
},
{
...mock<BranchConfig>(),
prTitle: 'pr10',
upgrades: [{ ...mock<PrUpgrade>(), 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<BranchConfig>(),
prTitle: 'pr0',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep0' }],
result: 'needs-approval',
dependencyDashboardCategory: 'Category #2',
branchName: 'branchName0',
},
{
...mock<BranchConfig>(),
prTitle: 'pr1',
upgrades: [{ ...mock<BranchUpgradeConfig>(), depName: 'dep1' }],
result: 'needs-approval',
branchName: 'branchName1',
},
{
...mock<BranchConfig>(),
prTitle: 'pr2',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep2' }],
result: 'needs-approval',
dependencyDashboardCategory: 'Category #10',
branchName: 'branchName2',
},
{
...mock<BranchConfig>(),
prTitle: 'pr3',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep3' }],
result: 'not-scheduled',
branchName: 'branchName3',
},
{
...mock<BranchConfig>(),
prTitle: 'pr4',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep4' }],
result: 'not-scheduled',
branchName: 'branchName4',
dependencyDashboardCategory: 'Category #1',
},
{
...mock<BranchConfig>(),
prTitle: 'pr5',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep5' }],
result: 'pr-limit-reached',
branchName: 'branchName5',
dependencyDashboardCategory: 'Category #3',
},
{
...mock<BranchConfig>(),
prTitle: 'pr6',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep6' }],
result: 'pr-limit-reached',
branchName: 'branchName6',
dependencyDashboardCategory: 'Category #3',
},
{
...mock<BranchConfig>(),
prTitle: 'pr7',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep7' }],
result: 'error',
branchName: 'branchName7',
},
{
...mock<BranchConfig>(),
prTitle: 'pr8',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep8' }],
result: 'error',
branchName: 'branchName8',
},
{
...mock<BranchConfig>(),
prTitle: 'pr9',
upgrades: [{ ...mock<PrUpgrade>(), 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
- [ ] <!-- approve-branch=branchName0 -->pr0
### Category #10
- [ ] <!-- approve-branch=branchName2 -->pr2
### Others
- [ ] <!-- approve-branch=branchName1 -->pr1
### All
- [ ] <!-- approve-all-pending-prs -->🔐 **Create all pending approval PRs at once** 🔐
`);
expect(body).toContain('## Awaiting Schedule');
expect(body).toContain(codeBlock`
### Category #1
- [ ] <!-- unschedule-branch=branchName4 -->pr4
### Others
- [ ] <!-- unschedule-branch=branchName3 -->pr3
### All
- [ ] <!-- create-all-awaiting-schedule-prs -->🔐 **Create all awaiting schedule PRs at once** 🔐
`);
expect(body).toContain('## Rate-Limited');
expect(body).toContain(codeBlock`
### Category #3
- [ ] <!-- unlimit-branch=branchName5 -->pr5
- [ ] <!-- unlimit-branch=branchName6 -->pr6
### All
- [ ] <!-- create-all-rate-limited-prs -->🔐 **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.
- [ ] <!-- retry-branch=branchName7 -->pr7
- [ ] <!-- retry-branch=branchName8 -->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.
- [ ] <!-- approvePr-branch=branchName9 -->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<BranchConfig>(),
prNo: 1,
prTitle: 'pr1',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep1' }],
result: 'pr-edited',
branchName: 'branchName1',
},
{
...mock<BranchConfig>(),
prNo: 2,
prTitle: 'pr2',
upgrades: [
{ ...mock<PrUpgrade>(), depName: 'dep2' },
{ ...mock<PrUpgrade>(), 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<BranchConfig>(),
prTitle: 'pr1',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep1' }],
result: 'rebase',
prNo: 1,
branchName: 'branchName1',
},
{
...mock<BranchConfig>(),
prTitle: 'pr2',
prNo: 2,
upgrades: [
{ ...mock<PrUpgrade>(), depName: 'dep2' },
{ ...mock<PrUpgrade>(), depName: 'dep3' },
],
result: 'rebase',
branchName: 'branchName2',
},
{
...mock<BranchConfig>(),
prTitle: 'pr3',
prNo: 3,
upgrades: [{ ...mock<PrUpgrade>(), 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<BranchConfig>(),
prTitle: 'pr1',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep1' }],
result: 'already-existed',
branchName: 'branchName1',
},
{
...mock<BranchConfig>(),
prTitle: 'pr2',
upgrades: [
{ ...mock<PrUpgrade>(), depName: 'dep2' },
{ ...mock<PrUpgrade>(), 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<BranchConfig>(),
upgrades: [{ ...mock<PrUpgrade>(), 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.
- [ ] <!-- approveGroup-branch=groupBranch1 -->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<BranchConfig>(),
prTitle: 'pr1',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep1' }],
result: 'needs-pr-approval',
branchName: 'branchName1',
},
{
...mock<BranchConfig>(),
prTitle: 'pr2',
upgrades: [
{ ...mock<PrUpgrade>(), depName: 'dep2' },
{ ...mock<PrUpgrade>(), depName: 'dep3' },
],
result: 'needs-pr-approval',
branchName: 'branchName2',
},
{
...mock<BranchConfig>(),
prTitle: 'pr3',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep3' }],
result: 'needs-pr-approval',
branchName: 'branchName3',
},
{
...mock<BranchConfig>(),
prTitle: 'pr4',
upgrades: [{ ...mock<PrUpgrade>(), 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(
' - [ ] <!-- create-config-migration-pr --> 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<!-- config-migration-pr-info --> 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<BranchConfig>(),
prTitle: 'pr1',
upgrades: [
{ ...mock<PrUpgrade>(), 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(' - [ ] <!-- unpend-branch=branchName1 -->pr1');
});
it('contains logged problems with custom header', async () => {
const branches: BranchConfig[] = [
{
...mock<BranchConfig>(),
prTitle: 'pr1',
upgrades: [
{ ...mock<PrUpgrade>(), 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(' - [ ] <!-- unpend-branch=branchName1 -->pr1');
});
it('dependency Dashboard All Pending Approval', async () => {
const branches: BranchConfig[] = [
{
...mock<BranchConfig>(),
prTitle: 'pr1',
upgrades: [{ ...mock<BranchUpgradeConfig>(), depName: 'dep1' }],
result: 'needs-approval',
branchName: 'branchName1',
},
{
...mock<BranchConfig>(),
prTitle: 'pr2',
upgrades: [{ ...mock<BranchUpgradeConfig>(), 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.
- [ ] <!-- approve-branch=branchName1 -->pr1
- [ ] <!-- approve-branch=branchName2 -->pr2
- [x] <!-- approve-all-pending-prs -->🔐 **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(' - [ ] <!-- approve-all-pending-prs -->');
expect(body).toContain(' - [ ] <!-- approve-branch=branchName1 -->pr1');
expect(body).toContain(' - [ ] <!-- approve-branch=branchName2 -->pr2');
});
it('dependency Dashboard Open All rate-limited', async () => {
const branches: BranchConfig[] = [
{
...mock<BranchConfig>(),
prTitle: 'pr1',
upgrades: [{ ...mock<BranchUpgradeConfig>(), depName: 'dep1' }],
result: 'branch-limit-reached',
branchName: 'branchName1',
},
{
...mock<BranchConfig>(),
prTitle: 'pr2',
upgrades: [{ ...mock<PrUpgrade>(), 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] <!-- create-all-rate-limited-prs -->**Open all rate-limited PRs**
- [ ] <!-- unlimit-branch=branchName1 -->pr1
- [ ] <!-- unlimit-branch=branchName2 -->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(' - [ ] <!-- create-all-rate-limited-prs -->');
expect(body).toContain(' - [ ] <!-- unlimit-branch=branchName1 -->pr1');
expect(body).toContain(' - [ ] <!-- unlimit-branch=branchName2 -->pr2');
});
it('rechecks branches', async () => {
const branches: BranchConfig[] = [
{
...mock<BranchConfig>(),
prTitle: 'pr1',
upgrades: [{ ...mock<BranchUpgradeConfig>(), depName: 'dep1' }],
result: 'needs-approval',
branchName: 'branchName1',
},
{
...mock<BranchConfig>(),
prTitle: 'pr2',
upgrades: [{ ...mock<PrUpgrade>(), depName: 'dep2' }],
result: 'needs-approval',
branchName: 'branchName2',
},
{
...mock<BranchConfig>(),
prTitle: 'pr3',
upgrades: [{ ...mock<PrUpgrade>(), 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.
- [ ] <!-- approve-branch=branchName1 -->pr1
- [x] <!-- approve-branch=branchName2 -->pr2
## Awaiting Schedule
The following updates are awaiting their schedule. To get an update now, click on a checkbox below.
- [x] <!-- unschedule-branch=branchName3 -->pr3
- [x] <!-- rebase-all-open-prs -->'
`,
});
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(' - [ ] <!-- approve-branch=branchName1 -->pr1');
expect(body).toContain(' - [ ] <!-- approve-branch=branchName2 -->pr2');
expect(body).toContain(' - [ ] <!-- approve-all-pending-prs -->');
expect(body).toContain('## Awaiting Schedule');
expect(body).toContain(
' - [x] <!-- unschedule-branch=branchName3 -->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`
<details><summary>dockerfile (1)</summary>
<blockquote>
<details><summary>Dockerfile (1)</summary>
- \`ubuntu 20.04\` → [Updates: \`22.04\`]
</details>
</blockquote>
</details>
`);
expect(body).toContain('<details><summary>npm (1)</summary>');
expect(body).toContain(
'<details><summary>package.json (8)</summary>',
);
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('<details><summary>poetry (1)</summary>');
expect(body).toContain(
'<details><summary>pyproject.toml (1)</summary>',
);
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('<details>');
// 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('<details>');
// 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('<details><summary>dockerfile (4)</summary>');
expect(body).toContain(
'<details><summary>digest-only/Dockerfile (1)</summary>',
);
expect(body).toContain(
' - `ubuntu sha256:06b5d30fabc1fc574f2ecab87375692299d45f8f190d9b71f512deb494114e1f`',
);
// a dep with neither version nor digest gets no count and no entry
expect(body).toContain(codeBlock`
<details><summary>no-ver-no-digest/Dockerfile</summary>
</details>
`);
expect(body).toContain(
'<details><summary>ver-and-digest/Dockerfile (1)</summary>',
);
expect(body).toContain(
' - `ubuntu 18.04@sha256:a7ed45c4a95fbe19f9c5fb9d1ca58b2431b2a4984754be2f50ccec99d9428b79`',
);
expect(body).toContain(
'<details><summary>ver-only/Dockerfile (1)</summary>',
);
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(
'<details><summary>Branch dev</summary>',
);
const mainIndex = body.indexOf(
'<details><summary>Branch main</summary>',
);
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(
'<details><summary>dockerfile (1)</summary>',
);
expect(section).toContain(
'<details><summary>Dockerfile (1)</summary>',
);
expect(section).toContain(' - `ubuntu 20.04` → [Updates: `22.04`]');
expect(section).toContain('<details><summary>npm (1)</summary>');
expect(section).toContain(
'<details><summary>package.json (8)</summary>',
);
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('<details><summary>poetry (1)</summary>');
expect(section).toContain(
'<details><summary>pyproject.toml (1)</summary>',
);
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('<details><summary>Branch dev</summary>');
expect(body).toContain('<details><summary>dockerfile (1)</summary>');
expect(body).toContain(' - `ubuntu 20.04` → [Updates: `22.04`]');
expect(body).toContain('<details><summary>npm (1)</summary>');
expect(body).toContain(' - `cookie-parser ^1.4.5`');
expect(body).toContain('<details><summary>poetry (1)</summary>');
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`
<details><summary>Branch main</summary>
<blockquote>
None detected
</blockquote>
</details>
`);
// 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('<details><summary>Branch dev</summary>');
expect(body).toContain('<details><summary>dockerfile (1)</summary>');
expect(body).toContain(' - `ubuntu 20.04` → [Updates: `22.04`]');
expect(body).toContain('<details><summary>npm (1)</summary>');
expect(body).toContain(' - `cookie-parser ^1.4.5`');
expect(body).toContain('<details><summary>poetry (1)</summary>');
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`
<details><summary>Branch main</summary>
<blockquote>
None detected
</blockquote>
</details>
`);
// 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<string, PackageFile[]> = {
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<Record<string, PackageFile[]>>(
'./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('<details><summary>npm</summary>');
expect(result).toContain('<details><summary>express</summary>');
expect(result).toContain(
'- [GHSA-29mw-wpgm-hmr9](https://osv.dev/vulnerability/GHSA-29mw-wpgm-hmr9) (fixed in 4.18.1)',
);
expect(result).toContain('<details><summary>cookie-parser</summary>');
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('<details><summary>npm</summary>');
expect(result).toContain('<details><summary>cookie-parser</summary>');
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<string, PackageFile[]> = {
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<string, PackageFile[]> = {
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(
'<summary>View abandoned dependencies (1)</summary>',
);
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<string, PackageFile[]> = {
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(
'<summary>View abandoned dependencies (3)</summary>',
);
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<string, PackageFile[]> = {
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(
'<summary>View abandoned dependencies (2)</summary>',
);
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<string, PackageFile[]> = {
npm: [
{
packageFile: 'package.json',
deps: [],
},
],
};
const result = dependencyDashboard.getAbandonedPackagesMd(
config,
packageFiles,
);
expect(result).toEqual('');
});
});
});