test/scripts/check-runner.test.ts
Rama main · réplica de lectura del repositorio original.
import { assertArrayIncludes, assertStringIncludes } from "@std/assert";
import { expect } from "@std/expect";
import { afterEach, beforeEach, describe, it as test } from "@std/testing/bdd";
import { runAliasExportCheck } from "#scripts/check-alias-exports/run.ts";
import { runEmptyCatchCheck } from "#scripts/check-empty-catch/run.ts";
import {
filesOverLimit,
runFileLengthCheck,
} from "#scripts/check-file-lengths/run.ts";
import {
type Counts,
compareCounts,
countsByPath,
countsCheckExit,
countsRose,
ratchetedState,
readCounts,
recordedState,
updateMode,
} from "#scripts/check-runner.ts";
import { type TempPath, tempDir } from "#test-utils/files.ts";
describe("reading the counts a check ratchets on", () => {
let dir: TempPath;
beforeEach(() => {
dir = tempDir();
});
afterEach(() => {
dir.dispose();
});
const writeCounts = (text: string): string => {
const path = `${dir.path}/counts.json`;
Deno.writeTextFileSync(path, text);
return path;
};
test("gives back the counts the file holds", async () => {
const path = writeCounts('{ "a.md": 2, "b.md": 0 }');
expect(await readCounts(path)).toEqual({ "a.md": 2, "b.md": 0 });
});
test("fails loudly for a counts file that is not there", async () => {
await expect(readCounts(`${dir.path}/gone.json`)).rejects.toThrow();
});
test("fails loudly for counts of the wrong shape", async () => {
const path = writeCounts('{ "a.md": "two" }');
await expect(readCounts(path)).rejects.toThrow();
});
test("fails loudly for a negative or fractional count", async () => {
const negative = writeCounts('{ "a.md": -1 }');
await expect(readCounts(negative)).rejects.toThrow();
const fractional = writeCounts('{ "a.md": 1.5 }');
await expect(readCounts(fractional)).rejects.toThrow();
});
test("counts findings per path and leaves paths without findings out", () => {
const counts = countsByPath([
{ path: "a.test.ts" },
{ path: "a.test.ts" },
{ path: "b.test.ts" },
]);
expect(counts).toEqual({ "a.test.ts": 2, "b.test.ts": 1 });
});
});
describe("the per-file check runners", () => {
let dir: TempPath;
beforeEach(() => {
dir = tempDir();
});
afterEach(() => {
dir.dispose();
});
/** A check that takes the trees and reports its findings: the shape every
* per-file runner has, whatever else it also reads. */
type Check = (
roots: readonly string[],
output: { log: (l: string) => void; logError: (l: string) => void },
) => Promise<number>;
/** Write one TypeScript file, run a check over the temp folder, and hand
* back the exit code plus the lines sent to each logger. */
const check = async (run: Check, name: string, content: string) => {
Deno.mkdirSync(`${dir.path}/src`);
Deno.writeTextFileSync(`${dir.path}/src/${name}`, content);
const out: string[] = [];
const errors: string[] = [];
const code = await run([dir.path], {
log: (line: string) => out.push(line),
logError: (line: string) => errors.push(line),
});
return { code, errors, out };
};
describe("alias exports", () => {
test("fails naming the export that renames an import", async () => {
const { code, errors, out } = await check(
runAliasExportCheck,
"alias.ts",
'import { byParent } from "#shared/parents.ts";\n' +
"export const getChildIds = byParent.getIds;\n",
);
expect(code).toBe(1);
expect(out).toEqual([]);
expect(errors[0]).toBe(
`${dir.path}/src/alias.ts:2 [alias-export]: "getChildIds" renames ` +
"the imported byParent.getIds — export byParent.getIds itself, and " +
"let callers use it",
);
});
test("passes a tree with no alias export", async () => {
const { code, errors, out } = await check(
runAliasExportCheck,
"plain.ts",
"export const total = 3;\n",
);
expect(code).toBe(0);
expect(errors).toEqual([]);
assertArrayIncludes(out, ["Every exported name says what it names."]);
});
});
describe("empty catch", () => {
test("fails naming the catch that says nothing", async () => {
const { code, errors } = await check(
runEmptyCatchCheck,
"empty.ts",
"try {\n save();\n} catch (e) {}\n",
);
expect(code).toBe(1);
assertStringIncludes(
errors[0] ?? "",
`${dir.path}/src/empty.ts:3 [empty-catch]`,
);
assertStringIncludes(errors[0] ?? "", "no statement and no comment");
});
test("passes a tree whose catches explain themselves", async () => {
const { code, out } = await check(
runEmptyCatchCheck,
"caught.ts",
"try {\n save();\n} catch (e) {\n recover(e);\n}\n",
);
expect(code).toBe(0);
assertArrayIncludes(out, [
"Every catch block says what it does with the error.",
]);
});
});
describe("file lengths", () => {
test("fails a file over the limit with no entry", async () => {
const { code, errors } = await check(
(roots, output) => runFileLengthCheck(roots, {}, output),
"big.ts",
"\n".repeat(501),
);
expect(code).toBe(1);
assertStringIncludes(errors[0] ?? "", "[over-limit]: holds 501 lines");
});
test("passes a file between the 400-line aim and the limit", async () => {
const { code, errors, out } = await check(
(roots, output) => runFileLengthCheck(roots, {}, output),
"aim.ts",
"\n".repeat(450),
);
expect(code).toBe(0);
expect(errors).toEqual([]);
assertArrayIncludes(out, [
"Every source file sits at or under the limit its list holds it to.",
]);
});
test("passes a file over the limit at its recorded count", async () => {
const { code } = await check(
(roots, output) =>
runFileLengthCheck(
roots,
{ [`${dir.path}/src/big.ts`]: 501 },
output,
),
"big.ts",
"\n".repeat(501),
);
expect(code).toBe(0);
});
test("lists every file over the limit with its current count", async () => {
Deno.mkdirSync(`${dir.path}/src`);
Deno.writeTextFileSync(`${dir.path}/src/big.ts`, "\n".repeat(501));
Deno.writeTextFileSync(`${dir.path}/src/small.ts`, "\n");
const over = await filesOverLimit([dir.path]);
expect(over).toEqual({ [`${dir.path}/src/big.ts`]: 501 });
});
test("reads authored shell and stylesheet files, not the frozen ones", async () => {
Deno.mkdirSync(`${dir.path}/ui/static`, { recursive: true });
const write = (name: string, lines: number) =>
Deno.writeTextFileSync(`${dir.path}/${name}`, "\n".repeat(lines));
write("authored.css", 501);
write("ui/static/style.css", 501);
write("ui/static/logistics-map.css", 501);
write("run.sh", 501);
const over = await filesOverLimit([dir.path]);
expect(Object.keys(over)).toEqual([
`${dir.path}/authored.css`,
`${dir.path}/run.sh`,
]);
});
test("fails an accepted entry whose file is gone, asking for deletion", async () => {
Deno.mkdirSync(`${dir.path}/src`);
Deno.writeTextFileSync(`${dir.path}/src/kept.ts`, "\n");
const errors: string[] = [];
const code = await runFileLengthCheck(
[dir.path],
{ [`${dir.path}/src/gone.ts`]: 500 },
{
log: () => {},
logError: (line: string) => errors.push(line),
},
);
expect(code).toBe(1);
assertStringIncludes(
errors[0] ?? "",
`${dir.path}/src/gone.ts [stale-entry]`,
);
assertStringIncludes(errors[0] ?? "", "delete the entry");
assertStringIncludes(errors[1] ?? "", "1 file-length issue(s) found");
});
});
});
describe("the record step of a ratchet", () => {
let dir: TempPath;
beforeEach(() => {
dir = tempDir();
});
afterEach(() => {
dir.dispose();
});
/** Write JSON in the registry format `writeJsonFile` produces. */
const counted = (path: string, value: unknown): void => {
Deno.writeTextFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
};
const recordPath = (): string => `${dir.path}/record.json`;
test("countsRose answers yes only for a rise, missing keys included", () => {
expect(countsRose({ "a.md": 2 }, { "a.md": 2 })).toBe(false);
expect(countsRose({ "a.md": 2 }, { "a.md": 1 })).toBe(false);
expect(countsRose({ "a.md": 2 }, { "a.md": 3 })).toBe(true);
expect(countsRose({}, { "new.md": 1 })).toBe(true);
expect(countsRose({ "a.md": 2 }, {})).toBe(false);
});
test("compareCounts reports only the findings past their recorded count", () => {
const findings = [
{ fix: "f", line: 1, problem: "one", rule: "r" },
{ fix: "f", line: 2, problem: "two", rule: "r" },
{ fix: "f", line: 3, problem: "three", rule: "s" },
];
const input = {
current: { r: 2, s: 1 },
file: "a.md",
findings,
keyOf: (finding: { rule: string }) => finding.rule,
recorded: { r: 1 } as Counts,
updateCommand: "deno task check --update",
whereOf: (finding: { line: number }) => `a.md:${finding.line}`,
};
// The rule was allowed one and seen twice: one excess finding reports.
// The key that was never recorded starts at zero, so its finding rises.
// A key whose count held or fell never reports, even when another key
// rose.
expect(
compareCounts({
...input,
current: { r: 2, s: 1, y: 0 },
findings: [
...findings,
{ fix: "f", line: 5, problem: "gone", rule: "x" },
{ fix: "f", line: 7, problem: "held", rule: "y" },
],
recorded: { r: 1, x: 2, y: 0 } as Counts,
}),
).toEqual(["a.md:1 [r]: one — f", "a.md:3 [s]: three — f"]);
// A held count is quiet.
expect(compareCounts({ ...input, current: { r: 1, s: 0 } })).toEqual([]);
// A fallen count asks for the step to be recorded.
expect(compareCounts({ ...input, current: {} })).toEqual([
"a.md [improved]: fewer findings than recorded (none) — run `deno task check --update` to record the step",
]);
// A fall with keys still held names what the records keep.
expect(
compareCounts({
...input,
current: { r: 1, s: 1 },
recorded: { r: 1, s: 2 } as Counts,
}),
).toEqual([
"a.md [improved]: fewer findings than recorded (r: 1, s: 1) — run `deno task check --update` to record the step",
]);
});
test("updateMode reads the recording flag off the command line", () => {
expect(updateMode([])).toEqual({ update: false });
expect(updateMode(["--update"])).toEqual({ update: true });
});
test("updateMode fails loudly for the removed --seed flag", () => {
expect(() => updateMode(["--seed"])).toThrow(
"The --seed flag is gone. A registry only falls.",
);
});
/** Record `count` of `a.md` on disk, then run one recording pass, and
* hand back what the check must now compare against plus what landed on
* disk. */
const recording = async (
flags: readonly string[],
fresh: Record<string, number>,
): Promise<{ after: unknown; onDisk: unknown }> => {
const path = recordPath();
counted(path, { "a.md": 2 });
const after = await recordedState(
path,
updateMode(flags),
{ "a.md": 2 },
() => Promise.resolve(fresh),
countsRose,
);
return { after, onDisk: JSON.parse(Deno.readTextFileSync(path)) };
};
test("recordedState changes nothing without a recording flag", async () => {
const path = recordPath();
counted(path, { old: 1 });
expect(
await recordedState(
path,
updateMode([]),
{ old: 1 },
() => Promise.resolve({ fresh: 2 }),
countsRose,
),
).toEqual({ old: 1 });
expect(JSON.parse(Deno.readTextFileSync(path))).toEqual({ old: 1 });
});
test("recordedState records the fresh state when nothing rose, and the check runs on it", async () => {
const { after, onDisk } = await recording(["--update"], { "a.md": 1 });
expect(after).toEqual({ "a.md": 1 });
expect(onDisk).toEqual({ "a.md": 1 });
});
test("recordedState refuses a rise, keeping what the check must compare against", async () => {
const { after, onDisk } = await recording(["--update"], { "a.md": 5 });
expect(after).toEqual({ "a.md": 2 });
expect(onDisk).toEqual({ "a.md": 2 });
});
test("a removed registry can no longer be recorded anew", () => {
const path = recordPath();
counted(path, { "a.md": 2 });
expect(() => updateMode(["--seed"])).toThrow(
"The --seed flag is gone. A registry only falls.",
);
expect(JSON.parse(Deno.readTextFileSync(path))).toEqual({ "a.md": 2 });
});
test("ratchetedState wires the read, the mode, and the rose together", async () => {
// The registry lives beside the calling module, at `relative`.
const moduleUrl = `file://${dir.path}/entry.ts`;
const path = `${dir.path}/baseline.json`;
counted(path, { "a.md": { ";": 2 } });
expect(
await ratchetedState(moduleUrl, "./baseline.json", [], () =>
Promise.resolve({ "a.md": { ";": 2 } }),
),
).toEqual({ "a.md": { ";": 2 } });
// A fall records; a rise refuses and keeps the recorded state. A file
// the records never held is a rise too.
expect(
await ratchetedState(moduleUrl, "./baseline.json", ["--update"], () =>
Promise.resolve({ "a.md": { ";": 1 } }),
),
).toEqual({ "a.md": { ";": 1 } });
expect(
await ratchetedState(moduleUrl, "./baseline.json", ["--update"], () =>
Promise.resolve({ "a.md": { ";": 5 } }),
),
).toEqual({ "a.md": { ";": 1 } });
expect(
await ratchetedState(moduleUrl, "./baseline.json", ["--update"], () =>
Promise.resolve({ "a.md": { ";": 1 }, "new.md": { ";": 2 } }),
),
).toEqual({ "a.md": { ";": 1 } });
expect(JSON.parse(Deno.readTextFileSync(path))).toEqual({
"a.md": { ";": 1 },
});
});
test("ratchetedState fails loudly when the registry is missing", async () => {
const moduleUrl = `file://${dir.path}/entry.ts`;
await expect(
ratchetedState(moduleUrl, "./absent.json", [], () =>
Promise.resolve({ "a.md": { ";": 1 } }),
),
).rejects.toThrow("Cannot read the JSON at");
});
test("ratchetedState fails loudly for a malformed registry count", async () => {
const moduleUrl = `file://${dir.path}/entry.ts`;
const path = `${dir.path}/baseline.json`;
counted(path, { "a.md": { ";": -1 } });
await expect(
ratchetedState(moduleUrl, "./baseline.json", [], () =>
Promise.resolve({ "a.md": { ";": 1 } }),
),
).rejects.toThrow();
});
test("a registry beside a module in a spaced directory is found", async () => {
const spaced = `${dir.path}/check out`;
Deno.mkdirSync(spaced);
const moduleUrl = `file://${spaced}/entry.ts`;
const path = `${spaced}/baseline.json`;
counted(path, { "a.md": { ";": 1 } });
expect(
await ratchetedState(moduleUrl, "./baseline.json", [], () =>
Promise.resolve({ "a.md": { ";": 1 } }),
),
).toEqual({ "a.md": { ";": 1 } });
});
test("countsCheckExit wires the read, the mode, the rose, and the run together", async () => {
const moduleUrl = `file://${dir.path}/entry.ts`;
const path = `${dir.path}/counts.json`;
counted(path, { "a.md": 2 });
const ran: [readonly string[], Counts][] = [];
const run = (
roots: readonly string[],
accepted: Counts,
): Promise<number> => {
ran.push([roots, accepted]);
return Promise.resolve(0);
};
const output = { log: () => {}, logError: () => {} };
const wiring = (args: readonly string[], fresh: () => Promise<Counts>) =>
countsCheckExit(
moduleUrl,
"./counts.json",
args,
["docs"],
fresh,
run,
output,
);
// Without the flag the recorded counts reach the run unchanged.
expect(await wiring([], () => Promise.resolve({ "a.md": 1 }))).toBe(0);
expect(ran).toEqual([[["docs"], { "a.md": 2 }]]);
// `--update` records a fall, and a rise refuses the record: both leave
// the recorded counts on disk and hand them to the run.
for (const fresh of [{ "a.md": 1 }, { "a.md": 3 }]) {
expect(await wiring(["--update"], () => Promise.resolve(fresh))).toBe(0);
expect(ran.at(-1)).toEqual([["docs"], { "a.md": 1 }]);
expect(JSON.parse(Deno.readTextFileSync(path))).toEqual({ "a.md": 1 });
}
});
});