Files
kandev/apps/web/scripts/lib/e2e-sleep-ratchet.test.ts
T
2026-08-13 22:50:13 +01:00

313 lines
11 KiB
TypeScript

/**
* The sleep ratchet driven end to end against real git topologies.
*
* The rule has its own RuleTester suite and the diff parser has its own; those
* prove the parts. This proves they are *connected* — that a sleep on a new line
* actually reaches an exit code, and that a sleep on an untouched line actually
* does not. A gate whose matcher is perfect but whose wiring is broken reports a
* clean tree forever, which is the one failure mode nobody goes looking for.
*
* Every scenario is a real repository with the real `apps/web/e2e/…` layout and
* the real eslint config, so nothing here can pass by mocking the thing under
* test.
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { findNewSleeps } from "./e2e-sleep-ratchet.mjs";
const REAL_WEB_DIR = path.resolve(import.meta.dirname, "../..");
const CONFIG_FILE = path.join(REAL_WEB_DIR, "eslint.e2e-sleeps.config.mjs");
const SPEC = "apps/web/e2e/tests/demo/demo.spec.ts";
/** The line each scenario inserts before, extracted because it appears in five. */
const ANCHOR = ' await page.click("#done");';
/** A spec with a sleep at a known line, plus filler to edit elsewhere. */
const LEGACY_SPEC = [
'import { test } from "@playwright/test";', // 1
"", // 2
'test("legacy", async ({ page }) => {', // 3
' await page.goto("/");', // 4
" await page.waitForTimeout(500);", // 5 <- the pre-existing sleep
ANCHOR, // 6
"});", // 7
"", // 8
].join("\n");
const tmpRoots: string[] = [];
afterAll(() => {
for (const dir of tmpRoots) fs.rmSync(dir, { recursive: true, force: true });
});
function git(cwd: string, args: string[]) {
return execFileSync(
"git",
[
"-c",
"user.email=ratchet@example.test",
"-c",
"user.name=Ratchet Test",
"-c",
"commit.gpgsign=false",
// A developer machine may set core.hooksPath globally; an inherited
// pre-commit hook would fail these fixture commits for unrelated reasons.
"-c",
"core.hooksPath=/nonexistent",
...args,
],
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
);
}
function write(dir: string, rel: string, body: string) {
const file = path.join(dir, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body);
}
/**
* Stage everything, which is what makes a NEW file visible to the ratchet.
*
* `git diff <base>` compares a commit against the working tree and does not
* report untracked paths at all, so an unstaged new file is invisible here. That
* is not a gap: pre-commit runs its hooks after staging, and CI judges commits,
* so every path that reaches this gate for real is tracked. Modified files need
* no staging, and are deliberately left unstaged below to prove the ratchet does
* not depend on it.
*/
function stage(dir: string) {
git(dir, ["add", "-A"]);
}
/**
* A repo whose `main` already contains `LEGACY_SPEC`, with `origin/main`
* pointing at it — the shape `resolveBase` expects — and HEAD on a branch off
* that commit, so working-tree edits read as this change's own.
*/
function repoWithLegacySleep() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-sleep-ratchet-"));
tmpRoots.push(dir);
git(dir, ["init", "-q", "-b", "main"]);
write(dir, SPEC, LEGACY_SPEC);
git(dir, ["add", "-A"]);
git(dir, ["commit", "-q", "-m", "base"]);
// `origin/main` without a remote: a local ref under refs/remotes is all
// `merge-base origin/main HEAD` needs.
git(dir, ["update-ref", "refs/remotes/origin/main", "HEAD"]);
git(dir, ["checkout", "-q", "-b", "feature"]);
return dir;
}
const run = (dir: string) =>
findNewSleeps({
repoRoot: dir,
webDir: path.join(dir, "apps/web"),
configFile: CONFIG_FILE,
}) as Promise<{
violations: Array<{ repoPath: string; line: number; whole: boolean; messageId: string }>;
added: string[];
modified: string[];
}>;
describe("e2e sleep ratchet", () => {
it("FAILS on a waitForTimeout added on a new line", async () => {
const dir = repoWithLegacySleep();
write(
dir,
SPEC,
LEGACY_SPEC.replace(
ANCHOR,
' await page.waitForTimeout(250);\n await page.click("#done");',
),
);
const { violations } = await run(dir);
expect(violations).toHaveLength(1);
expect(violations[0]).toMatchObject({ line: 6, whole: false, messageId: "waitForTimeout" });
});
it("FAILS on a raw promise sleep added on a new line", async () => {
const dir = repoWithLegacySleep();
write(
dir,
SPEC,
LEGACY_SPEC.replace(
ANCHOR,
' await new Promise((r) => setTimeout(r, 250));\n await page.click("#done");',
),
);
const { violations } = await run(dir);
expect(violations).toHaveLength(1);
expect(violations[0]).toMatchObject({ line: 6, whole: false, messageId: "promiseSleep" });
});
/**
* The property that makes this mergeable at all: touching a line next to
* somebody else's sleep must not make it yours. Without it the ratchet is a
* migration treadmill and the first PR to edit a legacy spec is blocked.
*/
it("PASSES when a line is edited right next to an existing untouched sleep", async () => {
const dir = repoWithLegacySleep();
write(
dir,
SPEC,
LEGACY_SPEC.replace(' await page.goto("/");', ' await page.goto("/tasks");'),
);
const { modified, violations } = await run(dir);
expect(modified).toEqual([SPEC]);
expect(violations).toEqual([]);
});
it("PASSES on an existing untouched sleep with no changes at all", async () => {
const { modified, added, violations } = await run(repoWithLegacySleep());
expect(added).toEqual([]);
expect(modified).toEqual([]);
expect(violations).toEqual([]);
});
it("PASSES on the sanctioned dwell() and injectLatency() forms", async () => {
const dir = repoWithLegacySleep();
write(
dir,
SPEC,
LEGACY_SPEC.replace(
'import { test } from "@playwright/test";',
'import { test } from "@playwright/test";\nimport { dwell, injectLatency } from "../../helpers/causal-waits";',
).replace(
ANCHOR,
[
' await dwell(page, 300, "library-timer", "Radix open delay publishes nothing");',
' await dwell(300, "poll-interval", "backend health poll; no page here");',
' await injectLatency(800, "make the in-flight spinner observable");',
ANCHOR,
].join("\n"),
),
);
const { violations } = await run(dir);
expect(violations).toEqual([]);
});
});
/**
* File-level selection: which paths the gate judges whole, which it judges by
* line, and which it does not judge at all.
*/
describe("e2e sleep ratchet — file selection", () => {
/** An added file carries no legacy debt, so it is judged whole. */
it("FAILS on a sleep anywhere in a newly ADDED file", async () => {
const dir = repoWithLegacySleep();
write(dir, "apps/web/e2e/tests/demo/new.spec.ts", LEGACY_SPEC);
stage(dir);
const { added, violations } = await run(dir);
expect(added).toEqual(["apps/web/e2e/tests/demo/new.spec.ts"]);
expect(violations).toHaveLength(1);
expect(violations[0]).toMatchObject({ line: 5, whole: true });
});
/**
* A `git mv` of a legacy spec adds no lines, so it must report nothing.
* Judging a rename as an addition would demand a full conversion for a move.
*/
it("PASSES on a pure rename of a file full of legacy sleeps", async () => {
const dir = repoWithLegacySleep();
git(dir, ["mv", SPEC, "apps/web/e2e/tests/demo/renamed.spec.ts"]);
const { violations } = await run(dir);
expect(violations).toEqual([]);
});
/**
* `.tsx` is selected by `isE2eCandidate`, so the eslint config must match it
* too. It did not: the file was linted against no config at all, and
* `warnIgnored: false` suppressed the only signal that would have shown it, so
* the gate PASSED on a file full of sleeps. Found in review; this is the
* regression test, and it fails against a `files: ["**\/*.ts"]` config.
*/
it("FAILS on a sleep in a .tsx file, which the selector also picks up", async () => {
const dir = repoWithLegacySleep();
write(dir, "apps/web/e2e/fixtures/probe-component.tsx", LEGACY_SPEC);
stage(dir);
const { added, violations } = await run(dir);
expect(added).toEqual(["apps/web/e2e/fixtures/probe-component.tsx"]);
expect(violations).toHaveLength(1);
expect(violations[0]).toMatchObject({ line: 5, whole: true, messageId: "waitForTimeout" });
});
/**
* The finding must land on the changed line. Reporting the whole
* `CallExpression` put it on the receiver, so converting `.click(…)` to
* `.waitForTimeout(…)` in a wrapped chain left the finding on an UNCHANGED
* line and the ratchet dropped it. Found in review.
*/
it("FAILS when only the method line of a wrapped call changes", async () => {
const dir = repoWithLegacySleep();
const wrapped = [
'import { test } from "@playwright/test";',
"",
'test("wrapped", async ({ page }) => {',
" await page",
' .click("#a");',
"});",
"",
].join("\n");
write(dir, SPEC, wrapped);
git(dir, ["add", "-A"]);
git(dir, ["commit", "-q", "-m", "wrapped chain"]);
git(dir, ["update-ref", "refs/remotes/origin/main", "HEAD"]);
// Only line 5 changes; line 4 (`await page`) is untouched.
write(dir, SPEC, wrapped.replace(' .click("#a");', " .waitForTimeout(500);"));
const { violations } = await run(dir);
expect(violations).toHaveLength(1);
expect(violations[0]).toMatchObject({ line: 5, whole: false, messageId: "waitForTimeout" });
});
/** Non-e2e changes are none of this gate's business. */
it("ignores changes outside apps/web/e2e", async () => {
const dir = repoWithLegacySleep();
write(dir, "apps/web/components/card.tsx", "export const C = () => null;\n");
write(dir, "apps/backend/main.go", "package main\n");
const { added, modified, violations } = await run(dir);
expect(added).toEqual([]);
expect(modified).toEqual([]);
expect(violations).toEqual([]);
});
/**
* `causal-waits.ts` implements both banned forms. If the exemption ever stops
* applying, the helper the whole effort promotes becomes unlandable.
*/
it("exempts the causal-waits helper even when added whole", async () => {
const dir = repoWithLegacySleep();
write(
dir,
"apps/web/e2e/helpers/causal-waits.ts",
[
"export function dwell(page: Page, ms: number): Promise<void> {",
" return page.waitForTimeout(ms);",
"}",
"export function injectLatency(ms: number): Promise<void> {",
" return new Promise((resolve) => setTimeout(resolve, ms));",
"}",
"",
].join("\n"),
);
stage(dir);
const { added, violations } = await run(dir);
expect(added).toEqual(["apps/web/e2e/helpers/causal-waits.ts"]);
expect(violations).toEqual([]);
});
});