Skip to content

Commit 7934b1c

Browse files
committed
test(docs): split dependency policy assertions
Coverage: 94.97% (was 94.97%)
1 parent 32ace44 commit 7934b1c

1 file changed

Lines changed: 160 additions & 113 deletions

File tree

__tests__/unit/documentation.test.ts

Lines changed: 160 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -67,49 +67,163 @@ function readText(url: URL): string {
6767
return readFileSync(url, 'utf-8');
6868
}
6969

70-
function hasDependabotIgnoreEntry(source: string, dependencyName: string): boolean {
71-
let updateIndentation = -1;
72-
let ignoreIndentation = -1;
70+
type DependabotIgnoreState = {
71+
ignoreIndentation: number;
72+
updateIndentation: number;
73+
};
74+
75+
type YamlLine = {
76+
indentation: number;
77+
text: string;
78+
};
79+
80+
type PackageManifest = {
81+
dependencies?: Record<string, unknown>;
82+
engines?: Record<string, unknown>;
83+
version?: unknown;
84+
};
85+
86+
function toYamlLine(rawLine: string): YamlLine {
87+
return {
88+
indentation: rawLine.length - rawLine.trimStart().length,
89+
text: rawLine.replace(/\s+#.*$/u, '').trim(),
90+
};
91+
}
92+
93+
function isDependabotUpdate(line: YamlLine): boolean {
94+
return /^-\s+package-ecosystem\s*:/u.test(line.text);
95+
}
96+
97+
function isNextUpdateEntry(state: DependabotIgnoreState, line: YamlLine): boolean {
98+
return state.updateIndentation !== -1
99+
&& line.indentation <= state.updateIndentation
100+
&& /^-\s+/u.test(line.text);
101+
}
102+
103+
function startsIgnoreBlock(state: DependabotIgnoreState, line: YamlLine): boolean {
104+
return state.updateIndentation !== -1
105+
&& line.indentation > state.updateIndentation
106+
&& /^ignore\s*:\s*$/u.test(line.text);
107+
}
108+
109+
function endsIgnoreBlock(state: DependabotIgnoreState, line: YamlLine): boolean {
110+
return state.ignoreIndentation !== -1 && line.indentation <= state.ignoreIndentation;
111+
}
112+
113+
function nextDependabotIgnoreState(state: DependabotIgnoreState, line: YamlLine): DependabotIgnoreState {
114+
if (line.text === '') return state;
115+
if (isDependabotUpdate(line)) return { updateIndentation: line.indentation, ignoreIndentation: -1 };
116+
if (isNextUpdateEntry(state, line)) return { updateIndentation: -1, ignoreIndentation: -1 };
117+
if (startsIgnoreBlock(state, line)) return { ...state, ignoreIndentation: line.indentation };
118+
if (endsIgnoreBlock(state, line)) return { ...state, ignoreIndentation: -1 };
119+
return state;
120+
}
121+
122+
function unquoteYamlScalar(value: string): string {
123+
const trimmed = value.trim();
124+
const quote = trimmed[0];
125+
return quote === '"' || quote === "'" ? trimmed.slice(1, -1) : trimmed;
126+
}
127+
128+
function dependencyNameFromIgnoreItem(line: YamlLine): string | undefined {
129+
const match = line.text.match(/^-\s*dependency-name\s*:\s*(.+?)\s*$/u);
130+
return match === null ? undefined : unquoteYamlScalar(match[1]);
131+
}
132+
133+
function isIgnoredDependency(state: DependabotIgnoreState, line: YamlLine, dependencyName: string): boolean {
134+
return state.ignoreIndentation !== -1
135+
&& line.indentation > state.ignoreIndentation
136+
&& dependencyNameFromIgnoreItem(line) === dependencyName;
137+
}
73138

139+
function hasDependabotIgnoreEntry(source: string, dependencyName: string): boolean {
140+
let state: DependabotIgnoreState = { updateIndentation: -1, ignoreIndentation: -1 };
74141
for (const rawLine of source.split(/\r?\n/u)) {
75-
const lineWithoutComment = rawLine.replace(/\s+#.*$/u, '');
76-
const line = lineWithoutComment.trim();
77-
if (line === '') continue;
78-
79-
const indentation = rawLine.length - rawLine.trimStart().length;
80-
if (/^-\s+package-ecosystem\s*:/u.test(line)) {
81-
updateIndentation = indentation;
82-
ignoreIndentation = -1;
83-
continue;
84-
}
85-
if (updateIndentation === -1) continue;
86-
if (indentation <= updateIndentation && /^-\s+/u.test(line)) {
87-
updateIndentation = -1;
88-
ignoreIndentation = -1;
89-
continue;
90-
}
91-
if (ignoreIndentation !== -1 && indentation <= ignoreIndentation) {
92-
ignoreIndentation = -1;
93-
}
94-
if (indentation > updateIndentation && /^ignore\s*:\s*$/u.test(line)) {
95-
ignoreIndentation = indentation;
96-
continue;
97-
}
98-
if (ignoreIndentation === -1 || indentation <= ignoreIndentation) continue;
99-
100-
const match = line.match(/^-\s*dependency-name\s*:\s*(.+?)\s*$/u);
101-
if (match === null) continue;
102-
const value = match[1].trim();
103-
const unquotedValue = (value.startsWith('"') && value.endsWith('"'))
104-
|| (value.startsWith("'") && value.endsWith("'"))
105-
? value.slice(1, -1)
106-
: value;
107-
if (unquotedValue === dependencyName) return true;
142+
const line = toYamlLine(rawLine);
143+
state = nextDependabotIgnoreState(state, line);
144+
if (isIgnoredDependency(state, line, dependencyName)) return true;
108145
}
109-
110146
return false;
111147
}
112148

149+
function assertPackageMetadata(): void {
150+
const packageManifest = JSON.parse(readText(new URL('../../package.json', import.meta.url))) as PackageManifest;
151+
assert.equal(packageManifest.version, '1.16.0');
152+
assert.equal(packageManifest.engines?.node, '>=20');
153+
assert.equal(packageManifest.dependencies?.['express-rate-limit'], '^8.5.2');
154+
}
155+
156+
function assertReadmeNodePolicy(readme: string): void {
157+
assert.ok(readme.includes('Node.js 20 remains supported but is deprecated and end-of-life.'));
158+
assert.ok(readme.includes('Node.js 22 or later is recommended.'));
159+
assert.ok(readme.includes('Node.js 20 will be removed only in a future major release.'));
160+
}
161+
162+
function assertCiMatrix(ci: string): void {
163+
const matrix = ci.match(/^\s*node-version:\s*\[([^\]]+)\]\s*$/mu);
164+
assert.ok(matrix, 'CI must declare an inline Node version matrix');
165+
assert.deepEqual(
166+
[...matrix[1].matchAll(/['"]([^'"]+)['"]/gu)].map((match) => match[1]),
167+
['20', '22', '24', '26.7.0'],
168+
);
169+
}
170+
171+
function assertCiCommonJob(ci: string): void {
172+
const jobs = ci.slice(ci.indexOf('jobs:'));
173+
assert.deepEqual((jobs.match(/^ [A-Za-z0-9_-]+:\s*$/gmu) ?? []).map((line) => line.trim()), ['test:']);
174+
assert.match(ci, /uses:\s*actions\/checkout@/u);
175+
assert.match(ci, /uses:\s*actions\/setup-node@/u);
176+
assert.match(ci, /cache:\s*['"]npm['"]/u);
177+
for (const command of [/run:\s*npm ci/u, /run:\s*npm run lint/u, /run:\s*npm run build/u, /run:\s*npm run test:coverage/u]) {
178+
assert.match(ci, command);
179+
}
180+
assert.doesNotMatch(ci, /^\s*include\s*:/mu);
181+
assert.doesNotMatch(ci, /^\s*if\s*:/mu);
182+
assert.doesNotMatch(ci, /continue-on-error\s*:/u);
183+
}
184+
185+
function assertCodeqlActionPins(workflow: string): void {
186+
const oldCodeqlSha = 'e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81';
187+
const newCodeqlSha = 'ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd';
188+
assert.ok(!workflow.includes(oldCodeqlSha));
189+
const references = [
190+
...workflow.matchAll(/uses:\s*github\/codeql-action\/[^@\s]+@([a-f0-9]{40})\s*#\s*(v[^\s]+)/gu),
191+
];
192+
assert.ok(references.length > 0, 'workflow must retain CodeQL action references');
193+
for (const reference of references) {
194+
assert.equal(reference[1], newCodeqlSha);
195+
assert.equal(reference[2], 'v4.37.7');
196+
}
197+
}
198+
199+
function assertDockerLoginPin(workflow: string): void {
200+
const oldDockerLoginSha = 'abd2ef45e78c5afb21d64d4ca52ee8550d9572c7';
201+
const newDockerLoginSha = 'dbcb813823bdd20940b903addbd779551569679f';
202+
assert.ok(!workflow.includes(oldDockerLoginSha));
203+
const references = [
204+
...workflow.matchAll(/uses:\s*docker\/login-action@([a-f0-9]{40})\s*#\s*(v[^\s]+)/gu),
205+
];
206+
assert.equal(references.length, 1);
207+
assert.equal(references[0][1], newDockerLoginSha);
208+
assert.equal(references[0][2], 'v4.6.0');
209+
}
210+
211+
function assertDependabotUnpdfPolicy(dependabot: string): void {
212+
const ignoredUnpdfFixture = `updates:
213+
- package-ecosystem: npm
214+
ignore:
215+
- dependency-name: "unpdf" # controlled ignored package
216+
`;
217+
const allowedUnpdfFixture = `updates:
218+
- package-ecosystem: npm
219+
allow:
220+
- dependency-name: unpdf # controlled allowed package
221+
`;
222+
assert.ok(hasDependabotIgnoreEntry(ignoredUnpdfFixture, 'unpdf'));
223+
assert.ok(!hasDependabotIgnoreEntry(allowedUnpdfFixture, 'unpdf'));
224+
assert.ok(!hasDependabotIgnoreEntry(dependabot, 'unpdf'));
225+
}
226+
113227
function extractFences(markdown: string, language: string): string[] {
114228
const blocks: string[] = [];
115229
let active: string[] | null = null;
@@ -276,82 +390,15 @@ export async function runTests(): Promise<TestResult> {
276390
const dockerPublish = readText(new URL('../../.github/workflows/docker-publish.yml', import.meta.url));
277391
const dockerRebuild = readText(new URL('../../.github/workflows/docker-rebuild.yml', import.meta.url));
278392
const dependabot = readText(new URL('../../.github/dependabot.yml', import.meta.url));
279-
const packageManifest = JSON.parse(readText(new URL('../../package.json', import.meta.url))) as {
280-
dependencies?: Record<string, unknown>;
281-
engines?: Record<string, unknown>;
282-
version?: unknown;
283-
};
284-
285-
assert.equal(packageManifest.version, '1.16.0');
286-
assert.equal(packageManifest.engines?.node, '>=20');
287-
assert.equal(packageManifest.dependencies?.['express-rate-limit'], '^8.5.2');
288-
289-
assert.ok(readme.includes('Node.js 20 remains supported but is deprecated and end-of-life.'));
290-
assert.ok(readme.includes('Node.js 22 or later is recommended.'));
291-
assert.ok(readme.includes('Node.js 20 will be removed only in a future major release.'));
292-
293-
const matrix = ci.match(/^\s*node-version:\s*\[([^\]]+)\]\s*$/mu);
294-
assert.ok(matrix, 'CI must declare an inline Node version matrix');
295-
assert.deepEqual(
296-
[...matrix[1].matchAll(/['"]([^'"]+)['"]/gu)].map((match) => match[1]),
297-
['20', '22', '24', '26.7.0'],
298-
);
299-
const jobs = ci.slice(ci.indexOf('jobs:'));
300-
assert.deepEqual((jobs.match(/^ [A-Za-z0-9_-]+:\s*$/gmu) ?? []).map((line) => line.trim()), ['test:']);
301-
assert.match(ci, /uses:\s*actions\/checkout@/u);
302-
assert.match(ci, /uses:\s*actions\/setup-node@/u);
303-
assert.match(ci, /cache:\s*['"]npm['"]/u);
304-
for (const command of [
305-
/run:\s*npm ci/u,
306-
/run:\s*npm run lint/u,
307-
/run:\s*npm run build/u,
308-
/run:\s*npm run test:coverage/u,
309-
]) {
310-
assert.match(ci, command);
311-
}
312-
assert.doesNotMatch(ci, /^\s*include\s*:/mu);
313-
assert.doesNotMatch(ci, /^\s*if\s*:/mu);
314-
assert.doesNotMatch(ci, /continue-on-error\s*:/u);
315-
316-
const oldCodeqlSha = 'e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81';
317-
const newCodeqlSha = 'ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd';
318-
for (const workflow of [codeql, scorecard]) {
319-
assert.ok(!workflow.includes(oldCodeqlSha));
320-
const codeqlReferences = [
321-
...workflow.matchAll(/uses:\s*github\/codeql-action\/[^@\s]+@([a-f0-9]{40})\s*#\s*(v[^\s]+)/gu),
322-
];
323-
assert.ok(codeqlReferences.length > 0, 'workflow must retain CodeQL action references');
324-
for (const reference of codeqlReferences) {
325-
assert.equal(reference[1], newCodeqlSha);
326-
assert.equal(reference[2], 'v4.37.7');
327-
}
328-
}
329-
330-
const oldDockerLoginSha = 'abd2ef45e78c5afb21d64d4ca52ee8550d9572c7';
331-
const newDockerLoginSha = 'dbcb813823bdd20940b903addbd779551569679f';
332-
for (const workflow of [dockerPublish, dockerRebuild]) {
333-
assert.ok(!workflow.includes(oldDockerLoginSha));
334-
const dockerLoginReferences = [
335-
...workflow.matchAll(/uses:\s*docker\/login-action@([a-f0-9]{40})\s*#\s*(v[^\s]+)/gu),
336-
];
337-
assert.equal(dockerLoginReferences.length, 1);
338-
assert.equal(dockerLoginReferences[0][1], newDockerLoginSha);
339-
assert.equal(dockerLoginReferences[0][2], 'v4.6.0');
340-
}
341-
342-
const ignoredUnpdfFixture = `updates:
343-
- package-ecosystem: npm
344-
ignore:
345-
- dependency-name: "unpdf" # controlled ignored package
346-
`;
347-
const allowedUnpdfFixture = `updates:
348-
- package-ecosystem: npm
349-
allow:
350-
- dependency-name: unpdf # controlled allowed package
351-
`;
352-
assert.ok(hasDependabotIgnoreEntry(ignoredUnpdfFixture, 'unpdf'));
353-
assert.ok(!hasDependabotIgnoreEntry(allowedUnpdfFixture, 'unpdf'));
354-
assert.ok(!hasDependabotIgnoreEntry(dependabot, 'unpdf'));
393+
assertPackageMetadata();
394+
assertReadmeNodePolicy(readme);
395+
assertCiMatrix(ci);
396+
assertCiCommonJob(ci);
397+
assertCodeqlActionPins(codeql);
398+
assertCodeqlActionPins(scorecard);
399+
assertDockerLoginPin(dockerPublish);
400+
assertDockerLoginPin(dockerRebuild);
401+
assertDependabotUnpdfPolicy(dependabot);
355402
}, results);
356403

357404
await testFunction('cookbook exists and README links to it', () => {

0 commit comments

Comments
 (0)