test(e2e): ratchet new unconditional sleeps to dwell/injectLatency (#2646)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-13 22:50:13 +01:00
committed by GitHub
parent f34db0f227
commit c488c7b4f9
17 changed files with 2016 additions and 6 deletions
+30
View File
@@ -209,6 +209,36 @@ jobs:
node scripts/run-i18n-ratchet.mjs --base "${BASE_SHA}"
# New-code ratchet for E2E waits. `page.waitForTimeout(500)` and
# hand-rolled promise sleeps are the suite's most reliable source of flake:
# a hand-picked number that was long enough on the machine it was written
# on. The sanctioned forms (`dwell`, `injectLatency` in
# e2e/helpers/causal-waits.ts) take a category and a reason.
#
# The eslint rule is an error only on directories already converted to
# zero, so this judges the CHANGE instead of the FILE for everywhere else —
# added files whole, modified files only on touched lines. Same base
# handling as the i18n step above, and the same reason for it: DO NOT pass
# --base on pull_request.
- name: Run E2E sleep ratchet
shell: bash
working-directory: apps/web
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
node scripts/check-new-e2e-sleeps.mjs
exit 0
fi
BASE_SHA="${{ github.event.before }}"
if [[ -z "${BASE_SHA}" || "${BASE_SHA}" == "0000000000000000000000000000000000000000" ]]; then
echo "No usable base SHA; skipping the E2E sleep ratchet."
exit 0
fi
node scripts/check-new-e2e-sleeps.mjs --base "${BASE_SHA}"
# Deliberately run under the variable that broke the suite, so a
# regression fails here instead of only in a container. React exports
# `act()` from its development build alone, `vitest.config.ts` pins
+13
View File
@@ -69,6 +69,19 @@ repos:
files: ^apps/web/(components|app|hooks|lib|src)/.*\.tsx?$|^apps/web/eslint\.i18n\.options\.mjs$
pass_filenames: false
# New E2E waits must name their cause, or name a category and a reason via
# dwell()/injectLatency(). Raw page.waitForTimeout() and hand-rolled
# promise sleeps are the suite's most reliable source of flake. Same
# judge-the-change contract as the i18n hook above: added files whole,
# modified files only on touched lines, so the ~126 sleeps that predate it
# never block an unrelated commit.
- id: e2e-sleep-ratchet
name: E2E sleep guard (new code only)
entry: bash -c 'unset GIT_DIR GIT_WORK_TREE; cd apps/web && node scripts/check-new-e2e-sleeps.mjs'
language: system
files: ^apps/web/e2e/.*\.tsx?$|^apps/web/eslint-rules/no-unsanctioned-sleep\.mjs$
pass_filenames: false
# Public copy hooks
- repo: local
hooks:
+147
View File
@@ -444,6 +444,153 @@ A ratchet banning raw sleeps needs to allow **both** tokens, `dwell(` and
instead — a proximity heuristic misfires, and a wrong exemption silently
unguards real sleeps.
### How this is enforced
**The sanctioned forms are only sanctioned when they are actually imported.**
`dwell` and `injectLatency` must resolve to a binding from
`e2e/helpers/causal-waits`; a call to an unimported (or differently imported)
identifier of that name is reported like a raw sleep. It is not pedantry: the
repo turns `no-undef` off, nothing typechecks `e2e/`, and an import that
silently fails to apply leaves a call site that lints clean and throws
`dwell is not defined` at runtime, inside whichever retry path only executes on
a loaded shard. That has already happened once.
Two checks, deliberately scoped differently, because ~126 raw sleeps predate
them and `pnpm lint` is `eslint --max-warnings 0` — a repo-wide rule at _any_
severity would break every unrelated PR until the conversion finishes.
**1. The new-code ratchet** (`scripts/check-new-e2e-sleeps.mjs`) runs in CI and
pre-commit and judges the **change**, not the file:
- a file the change **added** must be clean outright;
- a file the change **modified** is judged only on the lines it touched.
So editing a line next to somebody else's `waitForTimeout` never becomes your
problem, and there is no migration treadmill. Same model as
`golangci-lint --new-from-rev` and the i18n ratchet.
**2. The eslint rule** (`eslint-rules/no-unsanctioned-sleep.mjs`) is an
**error**, but only on `e2eSleepGuardFiles` — directories the conversion has
already driven to zero. Append a directory there in the same PR that clears it.
**Never remove an entry to make a build pass**: a removal means a sleep was added
to a clean directory.
When the list would cover all of `e2e/`, replace it with `e2e/**/*.ts`. That is
the graduation, and from then on the ratchet is a second line of defence rather
than the only one.
Two commands:
```bash
pnpm run e2e:waits # debt report: raw sleeps, dwell by category
pnpm run lint:e2e-sleeps <path> # preview the rule on a path not yet guarded
```
`e2e:waits` reports three numbers that must not be added together. Raw sleeps are
what the conversion drives to zero. **Dwell debt excludes `negative-assertion`,
which is permanent** — a test asserting something never happens has no event to
wait for, and deleting its wait removes the window the regression needs in order
to appear. `injectLatency` is counted apart from both, so deliberate fixture
configuration cannot inflate a number that is supposed to be shrinking. Inside
the debt, `unverified` is the sub-total to attack first.
**What the rule does not flag, and why.** It is an AST rule rather than a grep
because `e2e/` holds ~700 `test.setTimeout(60_000)` calls — Playwright's per-test
timeout setter, unrelated to sleeping. It also leaves alone two shapes that look
almost identical to a sleep:
- a **race guard**, where the timer is one of several resolution paths
(`socket.onopen = …; setTimeout(() => resolve(false), 3000)`);
- a **cancellable timer**, where the handle is kept for a later `clearTimeout`.
A sleep never keeps the handle.
The same reasoning leaves `requestAnimationFrame(() => setTimeout(resolve, 0))`
alone: that promise resolves on the **frame**, and the `setTimeout(…, 0)` only
hops out of the rAF callback so a measurement reflects painted geometry. It is
not a duration wait. This falls out of the rule's shape rather than from a
carve-out — a blanket "any `setTimeout(…, 0)` is fine" exemption would have been
a loophole.
## Verifying a converted wait
Replacing a sleep with a causal wait is easy to do wrongly in a way that still
passes. These four checks are ordered by how often they actually catch
something.
### 1. Verify against observed traffic, never a name that looks right
Reading the components and picking the action that sounds correct is the single
most common way a conversion goes wrong. Attach a throwaway logger, run the spec
once, and read what actually arrived:
```ts
page.on("response", (r) => console.log(r.request().method(), new URL(r.url()).pathname));
page.on("websocket", (ws) =>
ws.on("framereceived", (f) => console.log(String(f.payload).slice(0, 200))),
);
```
Three conversions in this suite were wrong despite careful code-reading: a spec
named after `kanban.update` whose path only ever delivers `task.updated`; a
branch list fetched on the workspace-scoped route rather than the
repository-scoped one the client's shape implies; and a `task.plan.get` that
never fires on first panel open at all. Each looked right in the source.
### 2. Rule out the silent burn
A wait that resolves on timeout instead of throwing converts a hard failure into
a slow pass — the test still goes green, having asserted nothing about timing.
Every armed wait must be awaited, and must reject rather than resolve when it
expires (the primitives in `helpers/causal-waits.ts` do).
Then compare **runtime against the budget**. A spec carrying two 15s waits that
finishes in 6.3s cannot be burning either of them; one that finishes in 30.1s is
burning both. One fixed conversion here went from a 15s burn to 6.8s, and that
delta is what exposed it — not any assertion.
### 3. Run it enough times to mean something
```sh
pnpm run build:e2e # specs run against the prebuilt dist
pnpm e2e:raw tests/<area>/<spec>.spec.ts --repeat-each=10
```
Read the **spread**, not the pass count. `1.4s-11.4s` across ten runs with zero
failures is evidence the wait absorbs load; a single green 1.4s run is evidence
of nothing. A component edit is invisible until `build:e2e` re-runs, so a spec
that "passes after the fix" without a rebuild has told you nothing.
### 4. Mutate what you rely on
Break the thing the test depends on, confirm the intended test fails, restore,
confirm the tree is byte-identical. Assert the expected number of tests was
**collected and executed** — a mutant that fails to compile makes the runner
report zero collected, which at a glance is indistinguishable from a clean run.
### Two traps
**Wrong Playwright project.** `mobile-*.spec.ts` exists only under
`mobile-chrome`, `tests/auth/**` under `auth`, `office-routing-*` under
`routing`, and `tests/{docker,ssh}/**` under `containers`. Selecting one under
`chromium` matches nothing, prints `No tests found`, and **exits non-zero** — so
the failure is real. What hides it is the idiom: `playwright test … | tail`
reports `tail`'s exit status, not Playwright's. **Never pipe a gate.** Derive the
expected test count before the run and compare it to Playwright's first line;
checking afterwards is a habit, deriving first is a check.
**Nothing typechecks `e2e/**`.** `apps/web/tsconfig.json`excludes it, there is
no`e2e/tsconfig.json`, and eslint runs no type-aware rules here — a file
containing `const x: number = "string"`passes both`pnpm run typecheck`and`pnpm lint`. Anything load-bearing needs a runtime check or a test. `dwell`
validates its category at runtime for exactly this reason.
### Why this matters
**A sleep is where a test goes to stop asserting.** Converting them has already
found two specs that had quietly stopped testing anything. The clearest was a
test pressing a keybinding that is `UNBOUND_SHORTCUT` by default: the keypress
was a no-op, and the 250ms sleep after it made the test look like it was waiting
for a result it could never receive.
## Adding a new spec
1. Pick a directory under `tests/` (or create one for a new feature).
@@ -0,0 +1,333 @@
/**
* `no-unsanctioned-sleep` — an unconditional wall-clock sleep in `e2e/` must go
* through `dwell()` or `injectLatency()` from `e2e/helpers/causal-waits.ts`.
*
* Those two helpers demand a category and a reason. A raw sleep demands nothing,
* which is why it is indistinguishable — at a glance and to a grep — from
* somebody who could not find the right event and reached for a number instead.
* The whole point of the causal-waits work is that the sanctioned forms are
* greppable and self-documenting; that only holds if the unsanctioned ones
* cannot be added silently.
*
* ## Why this is an AST rule and not a grep
*
* `e2e/` contains **701** `test.setTimeout(60_000)` calls — Playwright's
* per-test timeout setter, which has nothing to do with sleeping and must never
* be flagged — against ~38 real promise sleeps. Any `setTimeout` pattern that a
* regex can express either drowns in those 701 or misses the multi-line sleeps.
* It also cannot separate a sleep from a `Promise.race` timeout guard, which is
* correct code that looks almost identical. The AST separates all three exactly.
*
* ## What is reported
*
* 1. **`waitForTimeout`**, in any receiver position. Playwright exposes it on
* `Page`, `Frame` and `Locator.page()`, so the receiver is not fixed; the
* method name is. `dwell(page, ms, category, reason)` is the legal form.
*
* 2. **The promise-sleep idiom** — `new Promise((r) => setTimeout(r, ms))` — in
* the narrow shape where the timer is the promise's *only* resolution path.
* `dwell(ms, category, reason)` is the legal form where no `Page` exists.
*
* ## What is deliberately NOT reported, and why the narrowing is principled
*
* The executor must contain **exactly one statement**, that statement's value
* must be **discarded**, and its first argument must **resolve the promise**.
* Each clause excludes a real, correct pattern in this suite:
*
* - *More than one statement* excludes a race guard. `auth-lifecycle.spec.ts`
* registers `socket.onopen/onclose/onerror` and then a `setTimeout` fallback:
* the timer is one of four resolution paths, not a sleep.
* - *Value discarded* excludes a cancellable timer. `concurrency.spec.ts` has a
* lone `timer = setTimeout(() => resolve({kind:"timeout"}), ms)` inside its
* executor — one statement, but it keeps the handle because something else
* will `clearTimeout` it. **A sleep never keeps the handle**; keeping it is
* precisely the intent to cancel, which means another path resolves first.
* - *First argument resolves the promise* keeps the diagnostic honest: it makes
* the finding "this is a sleep" rather than merely "this has the right shape".
* "Resolves the promise" includes bound references — `resolve.bind(null)` is
* still `resolve`, and recursively so, since a form that laundered a sleep
* past the rule would be worth more to a determined author than to an honest
* one.
*
* The same three clauses dissolve the `requestAnimationFrame(() =>
* setTimeout(resolve, 0))` frame-yield in `toggle-sidebar-shortcut.spec.ts`
* without a carve-out: the executor's one statement is a `requestAnimationFrame`
* call, not a `setTimeout` one. That is not a convenient exception — the promise
* there resolves on the **frame**, and the `setTimeout(…, 0)` only hops out of
* the rAF callback so the measurement reflects painted geometry. It is not a
* duration wait, so a rule about duration waits should not see it. A blanket
* "any `setTimeout(…, 0)` is fine" carve-out would have been a loophole; this
* needs no carve-out at all.
*
* ## Severity
*
* An **error**, but only on {@link e2eSleepGuardFiles} — the directories already
* converted to zero. Not a warning either: `pnpm lint` is
* `eslint --max-warnings 0`, so a repo-wide registration at *any* severity would
* fail the build on the ~126 sleeps that predate this rule and block every
* unrelated PR, which is the mistake the first i18n attempt made.
*
* Everywhere else the gate is `scripts/check-new-e2e-sleeps.mjs`, which judges
* only the lines a change added. Once the conversion reaches zero the guard list
* collapses to `e2e/**\/*.{ts,tsx}` and the ratchet becomes belt-and-braces.
*/
/**
* Files exempt from the rule, repo-relative from `apps/web`.
*
* `causal-waits.ts` implements `dwell` and `injectLatency` and therefore
* contains both banned forms by construction — `dwell` delegates to
* `page.waitForTimeout(ms)` when a page exists and to a promise sleep when one
* does not. Without this the sanctioned helper is the one file that can never
* satisfy the rule it exists to serve.
*
* Its own `*.test.ts` is exempt for the same reason: tests for a sleep helper
* necessarily construct sleeps.
*
* Exported so `eslint.config.mjs` and `eslint.e2e-sleeps.config.mjs` cannot
* drift — an exemption that applies in the editor but not in the gate (or the
* reverse) is a difference nobody would notice until it fired.
*/
export const SLEEP_EXEMPT_FILES = [
"e2e/helpers/causal-waits.ts",
"e2e/helpers/causal-waits.test.ts",
];
/**
* Paths where this rule is an **error** in `eslint.config.mjs`.
*
* Same opt-in shape as `i18nGuardFiles`, and for the same reason: `pnpm lint` is
* `eslint --max-warnings 0`, so a repo-wide registration at any severity fails
* the build on the ~126 sleeps that predate the rule and blocks every unrelated
* PR. Listing only directories already at zero means this can only ever tighten.
*
* The seed was measured, not guessed — `pnpm run e2e:waits` reports remaining
* raw sleeps per file, and every directory below had none at the time it was
* added. Adding a path is the only safe edit: **never remove one to make a build
* pass.** A removal means somebody added a sleep to a directory that had none,
* which is exactly the regression this exists to catch.
*
* The conversion effort is working through the directories that are *not* here
* (`tests/{auth,chat,layout,office,session,settings,ssh,system,task,terminal,
* workflow,…}`); each should be appended as it reaches zero. When the list would
* cover all of `e2e/`, replace it with a plain `["e2e/**\/*.{ts,tsx}"]` — that is the
* graduation, and at that point the diff-scanning ratchet becomes a second line
* of defence rather than the only one.
*/
export const e2eSleepGuardFiles = [
// Shard planning, flake and timing reports. Tooling rather than specs, and not
// in any conversion chunk's path.
"e2e/scripts/**/*.{ts,tsx}",
// Test directories measured at zero raw sleeps.
"e2e/tests/cli-mode/**/*.{ts,tsx}",
"e2e/tests/docker/**/*.{ts,tsx}",
"e2e/tests/github/**/*.{ts,tsx}",
"e2e/tests/i18n/**/*.{ts,tsx}",
"e2e/tests/kanban/**/*.{ts,tsx}",
"e2e/tests/preview/**/*.{ts,tsx}",
];
/**
* The sanctioned waits. Calling one is only sanctioned if the identifier really
* IS that helper — see {@link resolveWaitBinding}.
*/
const SANCTIONED_WAITS = new Set(["dwell", "injectLatency"]);
/** `…/causal-waits`, with or without an extension. */
const CAUSAL_WAITS_MODULE = /(^|\/)causal-waits(\.[jt]s)?$/;
/** The nearest binding for `name`, walking out through enclosing scopes. */
function lookupVariable(scope, name) {
for (let current = scope; current; current = current.upper) {
const found = current.variables.find((variable) => variable.name === name);
if (found) return found;
}
return null;
}
/**
* Whether `variable` is really the helper from `causal-waits`.
*
* An unresolved identifier is a `ReferenceError` waiting for the machine slow
* enough to reach it, and a `dwell` imported from somewhere else is not the
* helper this rule promotes. Both answer `false`.
*/
function isCausalWaitsBinding(variable) {
if (!variable) return false;
return variable.defs.some(
(def) =>
def.type === "ImportBinding" && CAUSAL_WAITS_MODULE.test(String(def.parent.source.value)),
);
}
const WAIT_FOR_TIMEOUT = "waitForTimeout";
const SET_TIMEOUT = "setTimeout";
/** `setTimeout`, `window.setTimeout`, `globalThis.setTimeout`. */
function isSetTimeoutCallee(node) {
if (node.type === "Identifier") return node.name === SET_TIMEOUT;
if (node.type !== "MemberExpression" || node.computed) return false;
return node.property.type === "Identifier" && node.property.name === SET_TIMEOUT;
}
/** The method name a call invokes, for `a.b()` and `a["b"]()` alike. */
function calleeMethodName(node) {
if (node.type === "Identifier") return node.name;
if (node.type !== "MemberExpression") return null;
if (!node.computed) return node.property.type === "Identifier" ? node.property.name : null;
return node.property.type === "Literal" && typeof node.property.value === "string"
? node.property.value
: null;
}
/**
* The executor's own statements, or `null` if it is not a function we can read.
*
* An expression-bodied arrow (`(r) => setTimeout(r, 5)`) is normalised to a
* single-entry list so both forms flow through one check. Nested functions are
* NOT unwrapped: that is exactly what keeps the rAF frame-yield out.
*/
function executorStatements(executor) {
if (executor?.type !== "ArrowFunctionExpression" && executor?.type !== "FunctionExpression") {
return null;
}
if (executor.body.type !== "BlockStatement") return [executor.body];
return executor.body.body.map((statement) =>
statement.type === "ExpressionStatement" ? statement.expression : statement,
);
}
/** The single expression a function evaluates to, or `null` if it does more. */
function soleBodyExpression(node) {
if (node.body.type !== "BlockStatement") return node.body;
if (node.body.body.length !== 1) return null;
const [only] = node.body.body;
return only.type === "ExpressionStatement" ? only.expression : null;
}
/** Whether `node` is `X.bind(...)`, returning `X`; otherwise `null`. */
function boundTarget(node) {
if (node.type !== "CallExpression") return null;
const { callee } = node;
if (callee.type !== "MemberExpression" || callee.computed) return null;
if (callee.property.type !== "Identifier" || callee.property.name !== "bind") return null;
return callee.object;
}
/** Whether `node` is a call to `resolve`, or a function whose body only calls it. */
function resolvesPromise(node, resolveName) {
if (!node) return false;
if (node.type === "Identifier") return node.name === resolveName;
// A bound reference to `resolve` is still `resolve`:
// `setTimeout(resolve.bind(null), ms)` sleeps exactly like `setTimeout(resolve, ms)`.
// Recursive, so `resolve.bind(a).bind(b)` does not launder it either.
const bound = boundTarget(node);
if (bound) return resolvesPromise(bound, resolveName);
if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return false;
const body = soleBodyExpression(node);
if (body?.type !== "CallExpression" || body.callee.type !== "Identifier") return false;
return body.callee.name === resolveName;
}
/**
* The `setTimeout` call that makes `node` an unconditional sleep, or `null`.
*
* Every clause here is load-bearing — see the module comment for the real call
* site each one protects.
*/
function promiseSleepCall(node) {
if (node.callee.type !== "Identifier" || node.callee.name !== "Promise") return null;
const executor = node.arguments[0];
const resolveParam = executor?.params?.[0];
if (resolveParam?.type !== "Identifier") return null;
const statements = executorStatements(executor);
// Exactly one statement: more than one means the timer is a fallback among
// several resolution paths (a race guard), not the wait itself.
if (statements?.length !== 1) return null;
const [only] = statements;
// A bare call, so the handle is discarded. `timer = setTimeout(...)` arrives
// as an AssignmentExpression and stops here: keeping the handle is the intent
// to cancel, and a sleep is never cancelled.
if (only.type !== "CallExpression" || !isSetTimeoutCallee(only.callee)) return null;
return resolvesPromise(only.arguments[0], resolveParam.name) ? only : null;
}
/** @type {import("eslint").Rule.RuleModule} */
export const noUnsanctionedSleep = {
meta: {
type: "problem",
docs: {
description:
"Require dwell()/injectLatency() from e2e/helpers/causal-waits.ts instead of raw " +
"page.waitForTimeout() or hand-rolled promise sleeps.",
},
schema: [],
messages: {
waitForTimeout:
"Raw `waitForTimeout()` is not a sanctioned wait. Use `dwell(page, ms, category, reason)` " +
"from `e2e/helpers/causal-waits.ts`, or better, wait on the cause with `waitForHttp` / " +
"`watchWs`. See e2e/README.md.",
unboundWait:
"`{{name}}()` is not imported from `e2e/helpers/causal-waits`, so this is an " +
"undefined identifier that throws at runtime — on exactly the slow, loaded shards a " +
"wait is there to survive. Import it, or use a causal wait instead.",
promiseSleep:
"Hand-rolled promise sleep is not a sanctioned wait. Use `dwell(ms, category, reason)` " +
"from `e2e/helpers/causal-waits.ts`, or `injectLatency(ms, reason)` inside a " +
"`page.route()` handler. See e2e/README.md.",
},
},
create(context) {
return {
CallExpression(node) {
// A sanctioned wait is only sanctioned if the identifier resolves to the
// real helper. `eslint.config.mjs` turns `no-undef` OFF repo-wide, and
// nothing typechecks `e2e/`, so an import that silently failed to apply
// leaves `dwell(...)` linting clean and throwing at runtime — inside
// whichever retry path only executes under load. Scope analysis catches
// this without any type information, which is how `no-undef` works.
if (node.callee.type === "Identifier" && SANCTIONED_WAITS.has(node.callee.name)) {
const variable = lookupVariable(context.sourceCode.getScope(node), node.callee.name);
if (!isCausalWaitsBinding(variable)) {
context.report({
node: node.callee,
messageId: "unboundWait",
data: { name: node.callee.name },
});
}
return;
}
if (calleeMethodName(node.callee) !== WAIT_FOR_TIMEOUT) return;
// Report at the METHOD token, not the whole call. A finding's line is
// its node's start line, and the ratchet keeps only findings on lines
// the change added. For a wrapped call —
//
// page
// .waitForTimeout(500);
//
// — the CallExpression starts at `page`, so changing `.click(…)` to
// `.waitForTimeout(…)` would attribute the finding to an UNCHANGED line
// and the ratchet would silently drop it. The method token is on the
// line that actually changed.
const target = node.callee.type === "MemberExpression" ? node.callee.property : node.callee;
context.report({ node: target, messageId: "waitForTimeout" });
},
NewExpression(node) {
const sleep = promiseSleepCall(node);
if (sleep) context.report({ node: sleep, messageId: "promiseSleep" });
},
};
},
};
/** Flat-config plugin object, so both configs register the rule the same way. */
export const e2eSleepPlugin = {
rules: { "no-unsanctioned-sleep": noUnsanctionedSleep },
};
export const SLEEP_RULE_ID = "e2e-sleeps/no-unsanctioned-sleep";
@@ -0,0 +1,262 @@
import { RuleTester } from "eslint";
import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest";
import { noUnsanctionedSleep, SLEEP_EXEMPT_FILES } from "./no-unsanctioned-sleep.mjs";
/**
* Every `invalid` case below is a shape that must FAIL, and every `valid` case
* is a shape from this suite that must PASS. The valid list is the load-bearing
* half: the rule's whole design problem is that `e2e/` holds 701
* `test.setTimeout()` calls and a handful of race guards that look almost
* exactly like the ~38 sleeps it must catch.
*
* Each valid case names the real call site it stands in for, so a future
* loosening of the matcher fails here with the reason attached rather than
* silently flagging correct code.
*/
/**
* Hand vitest's `describe`/`it` to RuleTester explicitly.
*
* Without this, RuleTester finds no test globals (this project does not enable
* `globals: true`) and falls back to running every case *synchronously during
* collection*. The cases do execute, but vitest reports `Tests 1 passed (1)` —
* only the explicit `it` below — so "25 cases passed" and "0 cases ran" produce
* an identical, green-looking summary. Wiring the hooks makes each case a real
* test, which is what lets `EXPECTED_CASES` below be an assertion rather than a
* comment.
*/
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: tseslint.parser as never,
parserOptions: { ecmaVersion: 2022, sourceType: "module" },
},
});
ruleTester.run("no-unsanctioned-sleep", noUnsanctionedSleep, {
valid: [
// --- The 701-site case. A regex ban on `setTimeout` dies here. ---
{ code: "test.setTimeout(60_000);" },
{ code: "e2eTest.setTimeout(120_000);" },
// --- The sanctioned forms, which are only sanctioned when actually imported. ---
{
code: `import { dwell } from "../../helpers/causal-waits";
await dwell(page, 300, "library-timer", "Radix open delay publishes nothing");`,
},
{
code: `import { dwell } from "./causal-waits";
await dwell(500, "poll-interval", "backend health poll; no page exists yet");`,
},
{
code: `import { injectLatency } from "../helpers/causal-waits";
await injectLatency(800, "make the in-flight spinner observable");`,
},
// Aliased and namespace imports still resolve to the helper.
{
code: `import { dwell as sleep, injectLatency } from "../../helpers/causal-waits";
await injectLatency(10, "x");`,
},
// --- Race guard: several resolution paths, timer is a fallback. ---
// e2e/tests/auth/auth-lifecycle.spec.ts:218
{
code: `new Promise<boolean>((resolve) => {
const socket = new WebSocket(url);
socket.onopen = () => resolve(false);
socket.onclose = () => resolve(true);
setTimeout(() => resolve(false), 3000);
});`,
},
// --- Cancellable timer: the handle is kept, so something clears it. ---
// e2e/tests/ssh/concurrency.spec.ts:15 — one statement, still not a sleep.
{
code: `new Promise<{ kind: "timeout" }>((resolve) => {
timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
});`,
},
{
code: `new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), 30_000);
ws.onmessage = () => { clearTimeout(timer); resolve(); };
});`,
},
// --- Frame yield, not a duration wait. No carve-out: the executor's one
// --- statement is a requestAnimationFrame call, not a setTimeout one.
// e2e/tests/layout/toggle-sidebar-shortcut.spec.ts:123
{
code: `new Promise<void>((resolve) => {
requestAnimationFrame(() => setTimeout(resolve, 0));
});`,
},
// --- In-page code: the discriminator is what the promise RESOLVES ON,
// --- not which context it runs in. A frame is an event; a duration is not.
//
// Instrumentation, sampling geometry frame by frame. The promise resolves on
// the FRAME — the `setTimeout(…, 0)` only hops out of the rAF callback so the
// measurement reflects painted geometry. Neither `dwell` nor `injectLatency`
// exists in the browser context, so flagging this would trip the gate with no
// legal way to comply. Excluded structurally (the executor's one statement is
// the rAF call), and pinned here so a future tightening of the nesting rule
// cannot silently break it.
// e2e/tests/layout/toggle-sidebar-shortcut.spec.ts:123
{
code: `await page.evaluate(() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => setTimeout(resolve, 0));
}),
);`,
},
// --- A promise that is not a sleep at all. ---
{ code: "new Promise((resolve) => { socket.onopen = () => resolve(); });" },
{ code: "new Promise((resolve) => setTimeout(unrelatedCallback, 100));" },
// Not the global Promise constructor shape the rule keys on.
{ code: "new Deferred((resolve) => setTimeout(resolve, 100));" },
],
invalid: [
// --- waitForTimeout, in every receiver position Playwright offers. ---
{
code: "await page.waitForTimeout(500);",
errors: [{ messageId: "waitForTimeout" }],
},
{
code: "await locator.page().waitForTimeout(1000);",
errors: [{ messageId: "waitForTimeout" }],
},
{
code: 'await page["waitForTimeout"](500);',
errors: [{ messageId: "waitForTimeout" }],
},
{
code: "await frame.waitForTimeout(HEALTH_POLL_MS);",
errors: [{ messageId: "waitForTimeout" }],
},
// --- Promise sleeps, every form the suite actually contains. ---
// The 26-site shape.
{
code: "await new Promise((r) => setTimeout(r, 500));",
errors: [{ messageId: "promiseSleep" }],
},
// The 6-site shape.
{
code: "await new Promise((resolve) => setTimeout(resolve, 1000));",
errors: [{ messageId: "promiseSleep" }],
},
// The typed shape.
{
code: "await new Promise<void>((resolve) => setTimeout(resolve, 250));",
errors: [{ messageId: "promiseSleep" }],
},
// Block-bodied, still exactly one statement.
{
code: "await new Promise((resolve) => { setTimeout(resolve, 300); });",
errors: [{ messageId: "promiseSleep" }],
},
// A wrapping arrow that only calls resolve is still a sleep.
{
code: "await new Promise((resolve) => setTimeout(() => resolve(), 300));",
errors: [{ messageId: "promiseSleep" }],
},
// Non-literal delays are sleeps too.
{
code: "await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));",
errors: [{ messageId: "promiseSleep" }],
},
// `window.`/`globalThis.` prefix must not launder it.
{
code: "await new Promise((r) => window.setTimeout(r, 100));",
errors: [{ messageId: "promiseSleep" }],
},
// A function expression executor, not just an arrow.
{
code: "await new Promise(function (resolve) { setTimeout(resolve, 100); });",
errors: [{ messageId: "promiseSleep" }],
},
// Both bans can fire in one file.
{
code: "await page.waitForTimeout(100); await new Promise((r) => setTimeout(r, 100));",
errors: [{ messageId: "waitForTimeout" }, { messageId: "promiseSleep" }],
},
// A sanctioned wait that is not actually bound to the helper. Chunk 1 hit
// this for real: a patch anchored on an import line whose actual form was
// `{ type Page, expect }`, so the edit missed and the call site was left
// referencing an undefined identifier. `no-undef` is off repo-wide and
// nothing typechecks `e2e/`, so lint passed clean and it would have surfaced
// as `dwell is not defined` inside a retry path that only runs on slow
// shards. The token being allowed is not the same as the helper being there.
{
code: 'await dwell(page, 500, "unverified", "import silently did not apply");',
errors: [{ messageId: "unboundWait" }],
},
{
code: 'await injectLatency(200, "import silently did not apply");',
errors: [{ messageId: "unboundWait" }],
},
// Imported, but not from causal-waits: also not the sanctioned helper.
{
code: `import { dwell } from "./local-utils";
await dwell(500, "unverified", "wrong module");`,
errors: [{ messageId: "unboundWait" }],
},
// A locally defined look-alike is not the helper either.
{
code: `function dwell(ms) { return ms; }
await dwell(500, "unverified", "shadowed by a local");`,
errors: [{ messageId: "unboundWait" }],
},
// A DURATION wait is still a duration wait inside `page.evaluate`.
// `await page.evaluate(() => new Promise((r) => setTimeout(r, 500)))` blocks
// the test for 500ms exactly like `page.waitForTimeout(500)` — the browser
// context is a wrapper, not a different kind of wait. Exempting in-page code
// wholesale would make that one line a laundering wrapper for every sleep in
// the suite. There IS a legal path: hoist it to `dwell(page, ms, …)` and
// evaluate afterwards, or resolve on a frame/event as the rAF case above does.
{
code: "await page.evaluate(() => new Promise((r) => setTimeout(r, 500)));",
errors: [{ messageId: "promiseSleep" }],
},
// A bound reference to `resolve` is still `resolve`. Flagged so the form
// cannot be used to launder a sleep past the rule (review finding).
{
code: "await new Promise((resolve) => setTimeout(resolve.bind(null), 100));",
errors: [{ messageId: "promiseSleep" }],
},
{
code: "await new Promise((resolve) => setTimeout(resolve.bind(null).bind(null), 100));",
errors: [{ messageId: "promiseSleep" }],
},
// The finding must land on the METHOD token, not the receiver. On a
// wrapped call those are different LINES, and the ratchet keeps only
// findings on lines the change added — so reporting the whole call would
// attribute this to line 1 and silently drop it (review finding).
{
code: "await page\n .waitForTimeout(500);",
errors: [{ messageId: "waitForTimeout", line: 2 }],
},
],
});
describe("no-unsanctioned-sleep exemptions", () => {
/**
* The exemption list is consumed by two separate eslint configs. Asserting the
* paths exist stops a rename from silently un-exempting the helper that
* implements both banned forms — which would surface as the sanctioned wait
* failing the rule that exists to promote it.
*/
it("exempts exactly the causal-waits implementation and its own test", () => {
expect(SLEEP_EXEMPT_FILES).toEqual([
"e2e/helpers/causal-waits.ts",
"e2e/helpers/causal-waits.test.ts",
]);
});
});
+20
View File
@@ -6,6 +6,11 @@ import unusedImports from "eslint-plugin-unused-imports";
import i18next from "eslint-plugin-i18next";
import tseslint from "typescript-eslint";
import { i18nGuardFiles, noLiteralStringOptions } from "./eslint.i18n.options.mjs";
import {
e2eSleepGuardFiles,
e2eSleepPlugin,
SLEEP_EXEMPT_FILES,
} from "./eslint-rules/no-unsanctioned-sleep.mjs";
const eslintConfig = defineConfig([
{
@@ -77,6 +82,21 @@ const eslintConfig = defineConfig([
"sonarjs/no-duplicate-string": "off",
},
},
// Unconditional wall-clock sleeps in E2E specs. An ERROR, but only on
// `e2eSleepGuardFiles` — the directories the causal-waits conversion has
// already driven to zero. `pnpm lint` is `eslint --max-warnings 0`, so a
// repo-wide registration at ANY severity (warning included) would fail the
// build on the ~126 sleeps that predate the rule. Everything not listed is
// covered by `scripts/check-new-e2e-sleeps.mjs`, which judges only the lines a
// change added, so new sleeps are caught everywhere without a treadmill.
// Append a directory here when it reaches zero; see the list's own comment.
{
files: e2eSleepGuardFiles,
// `causal-waits.ts` implements both banned forms — see SLEEP_EXEMPT_FILES.
ignores: SLEEP_EXEMPT_FILES,
plugins: { "e2e-sleeps": e2eSleepPlugin },
rules: { "e2e-sleeps/no-unsanctioned-sleep": "error" },
},
]);
export default eslintConfig;
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import { e2eSleepPlugin, SLEEP_EXEMPT_FILES } from "./eslint-rules/no-unsanctioned-sleep.mjs";
/**
* Standalone config for the sleep ratchet and its preview command.
*
* The main config (eslint.config.mjs) errors only on `e2eSleepGuardFiles` — the
* directories the causal-waits conversion has already driven to zero. That is
* deliberate: `pnpm run lint` is `eslint --max-warnings 0`, so registering this
* rule repo-wide at *any* severity, warning included, would fail the build on
* the ~130 sleeps that predate it and break every unrelated PR until the
* conversion finishes.
*
* This config applies the same rule to whatever paths it is given, regardless of
* that allowlist. Two consumers:
*
* - `scripts/check-new-e2e-sleeps.mjs`, the ratchet, which filters findings
* down to the lines a change added.
* - `pnpm run lint:e2e-sleeps <path>`, the work-list generator for whoever is
* converting a directory next.
*
* The rule and the exemption list are imported, not restated, so the preview,
* the gate and the editor cannot disagree about what counts as a sleep.
*/
export default defineConfig([
{
// BOTH extensions. `isE2eCandidate` selects `.tsx` too, so a config matching
// only `.ts` left a selected file matching no config at all — and
// `warnIgnored: false` in the ratchet suppressed the one signal that would
// have shown it, so the gate passed silently. Keep this in step with the
// selector.
files: ["**/*.ts", "**/*.tsx"],
ignores: [...SLEEP_EXEMPT_FILES, "node_modules/**", "dist/**"],
languageOptions: { parser: tseslint.parser },
plugins: { "e2e-sleeps": e2eSleepPlugin },
rules: { "e2e-sleeps/no-unsanctioned-sleep": "error" },
},
]);
+3
View File
@@ -12,6 +12,7 @@
"start": "vite preview --host 0.0.0.0",
"lint": "eslint --max-warnings 0",
"lint:i18n": "eslint --config eslint.i18n.config.mjs",
"lint:e2e-sleeps": "eslint --config eslint.e2e-sleeps.config.mjs",
"pretypecheck": "node scripts/generate-release-notes.mjs && node scripts/generate-changelog.mjs",
"typecheck": "tsc --noEmit",
"pretest": "node scripts/generate-release-notes.mjs && node scripts/generate-changelog.mjs",
@@ -23,6 +24,8 @@
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
"e2e:headed": "playwright test --config e2e/playwright.config.ts --headed",
"e2e:capture": "cross-env CAPTURE_PR_ASSETS=true playwright test --config e2e/playwright.config.ts && tsx e2e/scripts/convert-assets.ts",
"e2e:sleep-ratchet": "node scripts/check-new-e2e-sleeps.mjs",
"e2e:waits": "node scripts/report-e2e-waits.mjs",
"licenses:gen": "tsx scripts/generate-licenses.ts",
"i18n:pseudo": "node scripts/generate-pseudo-locale.mjs",
"i18n:check": "node scripts/check-i18n-keys.mjs && node scripts/check-trans-indices.mjs && node scripts/check-inline-plurals.mjs && node scripts/check-module-scope-t.mjs && node scripts/check-no-em-dash-ui.mjs",
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
/**
* Ratchet: NEW unconditional sleeps in `apps/web/e2e` must go through `dwell()`
* or `injectLatency()` from `e2e/helpers/causal-waits.ts`.
*
* `page.waitForTimeout(500)` and `await new Promise((r) => setTimeout(r, 500))`
* are the suite's most reliable source of flake: a hand-picked number that
* happened to be long enough on the machine it was written on. The sanctioned
* forms take a category and a reason, so a wall-clock wait that survives review
* has to say why no event exists to wait on instead.
*
* ~126 raw sleeps predate this check, and `pnpm lint` is
* `eslint --max-warnings 0`, so registering the rule repo-wide at any severity
* would break every unrelated PR until the conversion finishes — the mistake the
* first i18n migration attempt made. Hence judging the CHANGE, not the FILE:
*
* - a file the change ADDED must be clean outright. New code carries no legacy
* debt, so requiring zero sleeps costs nothing.
* - a file the change MODIFIED is judged only on the lines it touched. Sleeps
* elsewhere in the file belong to the conversion effort, not to this PR.
*
* Same contract, same plumbing and same fork-point handling as
* `check-new-code-i18n.mjs`, which mirrors `golangci-lint --new-from-rev`.
*
* The decision logic lives in `lib/e2e-sleep-ratchet.mjs` so it can be driven
* against real git topologies in a test; this file is the shell that supplies
* the real repository and turns the result into output and an exit code.
*
* Usage:
* node scripts/check-new-e2e-sleeps.mjs [--base <ref-or-sha>]
*
* Prefer passing no --base; see lib/git-base.mjs for why an explicit one is
* floored at the fork point.
*/
import path from "node:path";
import { findNewSleeps } from "./lib/e2e-sleep-ratchet.mjs";
import { REPO_ROOT, WEB_DIR } from "./lib/git-base.mjs";
const result = await findNewSleeps({
repoRoot: REPO_ROOT,
webDir: WEB_DIR,
configFile: path.join(WEB_DIR, "eslint.e2e-sleeps.config.mjs"),
argv: process.argv,
});
if (result.skip) {
console.log(`⚠ e2e sleep ratchet skipped — ${result.skip}`);
process.exit(0);
}
const { added, modified, violations } = result;
if (added.length === 0 && modified.length === 0) {
console.log("✓ e2e sleep ratchet — no e2e source added or modified");
process.exit(0);
}
if (violations.length === 0) {
console.log(
`✓ e2e sleep ratchet — ${added.length} added + ${modified.length} modified e2e file(s) clean`,
);
process.exit(0);
}
console.error(
`\n✖ ${violations.length} unconditional sleep(s) in new e2e code.\n` +
` A wall-clock wait must name its category and its reason, so use dwell() or\n` +
` injectLatency() from e2e/helpers/causal-waits.ts. Better still, wait on the\n` +
` cause: waitForHttp() / watchWs(). See apps/web/e2e/README.md.\n`,
);
for (const violation of violations) {
const scope = violation.whole ? "new file" : "changed line";
console.error(` ${violation.repoPath}:${violation.line} [${scope}] ${violation.text}`);
}
console.error("");
process.exit(1);
+44 -6
View File
@@ -21,18 +21,56 @@ export function isCandidate(repoPath) {
}
/**
* Candidate paths matching `filter` (a `--diff-filter` value) between `base` and
* the working tree.
* Only Playwright E2E sources, for the sleep ratchet.
*
* The exact complement of the `e2e/` exclusion in {@link isCandidate}: the i18n
* guard skips this tree because tests build fixtures out of literals on purpose,
* and the sleep guard exists only for this tree. `.spec.ts` files are the
* *primary* target here rather than an exclusion, which is why this cannot be
* expressed as options on the other predicate.
*
* `causal-waits.ts` is not excluded here. Exemption belongs to the eslint config
* (`SLEEP_EXEMPT_FILES`), so the editor, the preview and the gate all read one
* list; filtering it out at selection time as well would be a second place to
* keep in sync, and the one that fails silently.
*/
export function isE2eCandidate(repoPath) {
if (!repoPath.startsWith(WEB_PREFIX)) return false;
const rel = repoPath.slice(WEB_PREFIX.length);
return rel.startsWith("e2e/") && /\.tsx?$/.test(rel);
}
/**
* Every path matching `filter` (a `--diff-filter` value) between `base` and the
* working tree, unfiltered.
*
* `--find-renames` explicitly: rename detection defaults on since Git 2.9, but a
* repo with diff.renames=false would report a moved file as ADDED, and an added
* file is judged whole — demanding a full migration for a plain `git mv`.
*/
export function changedPaths(base, filter, cwd) {
return (
git(["diff", "--name-only", "--find-renames", `--diff-filter=${filter}`, base], { cwd })
.split("\n")
.map((line) => line.trim())
// `"".split("\n")` is `[""]`, so a no-match diff would otherwise yield one
// empty "path". Both predicates reject it, but this function is exported and
// its next caller should not have to know that.
.filter(Boolean)
);
}
/**
* Candidate UI-source paths matching `filter`.
*
* A pure delegation to {@link changedPaths} + {@link isCandidate}: the i18n
* ratchet's file selection is byte-for-byte what it was before the sleep ratchet
* needed the same git invocation with a different predicate. Kept as its own
* export rather than inlined at the call site so that stays true by
* construction.
*/
export function changedFiles(base, filter, cwd) {
return git(["diff", "--name-only", "--find-renames", `--diff-filter=${filter}`, base], { cwd })
.split("\n")
.map((line) => line.trim())
.filter(isCandidate);
return changedPaths(base, filter, cwd).filter(isCandidate);
}
/**
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { isCandidate, isE2eCandidate } from "./changed-files.mjs";
/**
* `isCandidate` decides which files the **live i18n ratchet** judges, and had no
* test until the sleep ratchet needed the same git plumbing with a different
* predicate. These cases pin the existing behaviour so that extraction is
* provably behaviour-preserving, and pin the new predicate as its exact
* complement over the `e2e/` tree.
*/
describe("isCandidate (i18n ratchet file selection)", () => {
it.each([
"apps/web/components/task/card.tsx",
"apps/web/app/settings/page.tsx",
"apps/web/hooks/domains/kanban/use-board.ts",
"apps/web/lib/state/store.ts",
"apps/web/src/settings-routes.tsx",
])("includes UI source %s", (path) => {
expect(isCandidate(path)).toBe(true);
});
it.each([
// Tests build fixtures out of literals on purpose.
["apps/web/components/task/card.test.tsx", "unit test"],
["apps/web/lib/util.spec.ts", "spec file"],
// Not UI source.
["apps/web/e2e/tests/task/chat.spec.ts", "e2e tree"],
["apps/web/scripts/check-i18n-keys.mjs", "build script"],
["apps/web/eslint.config.mjs", "config, not .ts"],
["apps/web/components/task/styles.css", "not TypeScript"],
["apps/backend/internal/office/router.go", "outside apps/web"],
["apps/packages/ui/src/button.tsx", "outside apps/web"],
])("excludes %s (%s)", (path) => {
expect(isCandidate(path)).toBe(false);
});
});
describe("isE2eCandidate (sleep ratchet file selection)", () => {
it.each([
"apps/web/e2e/tests/task/chat.spec.ts",
"apps/web/e2e/helpers/api-client.ts",
"apps/web/e2e/fixtures/backend.ts",
"apps/web/e2e/pages/task-page.ts",
// Exemption is the eslint config's job, not the selector's — this path is
// deliberately still selected here.
"apps/web/e2e/helpers/causal-waits.ts",
])("includes %s", (path) => {
expect(isE2eCandidate(path)).toBe(true);
});
it.each([
["apps/web/components/task/card.tsx", "UI source, not e2e"],
["apps/web/scripts/check-new-e2e-sleeps.mjs", "build script"],
["apps/web/e2e/README.md", "not TypeScript"],
["apps/web/e2e/scripts/seed.js", "plain JS is not parsed by the rule config"],
["apps/backend/internal/office/router.go", "outside apps/web"],
])("excludes %s (%s)", (path) => {
expect(isE2eCandidate(path)).toBe(false);
});
/**
* The two predicates must not both claim a file: the i18n guard would demand
* catalog keys for test fixture strings, which is why `e2e/` was excluded from
* it in the first place.
*/
it("never overlaps with the i18n predicate", () => {
const paths = [
"apps/web/e2e/tests/task/chat.spec.ts",
"apps/web/e2e/helpers/api-client.ts",
"apps/web/components/task/card.tsx",
"apps/web/lib/state/store.ts",
];
for (const path of paths) {
expect(isCandidate(path) && isE2eCandidate(path)).toBe(false);
}
});
});
@@ -0,0 +1,79 @@
/**
* The sleep ratchet's decision logic, separated from its CLI shell.
*
* Split out so the ratchet can be driven against real git topologies in a test
* — a temp repository with the same `apps/web/e2e/…` layout — rather than only
* through its individual helpers. Testing `parseAddedLineRanges` and the eslint
* rule proves each part works; it does not prove the parts are wired together,
* and a gate that is perfectly tested but never actually consulted reports a
* clean tree forever. `findNewSleeps` is the seam where those two claims meet,
* so it is the thing the tests drive.
*
* Everything path-shaped is a parameter for the same reason. The CLI supplies
* the real repository; the tests supply a throwaway one.
*/
import path from "node:path";
import { ESLint } from "eslint";
import { SLEEP_RULE_ID } from "../../eslint-rules/no-unsanctioned-sleep.mjs";
import { changedPaths, isE2eCandidate, webDiff } from "./changed-files.mjs";
import { lineInRanges, parseAddedLineRanges } from "./diff-ranges.mjs";
import { resolveBase, toPosixPath } from "./git-base.mjs";
/**
* Sleeps introduced by the change between the resolved base and the working
* tree.
*
* Added files are judged whole; modified files only on the lines they touched.
*
* @returns `{ skip }` when no base could be resolved and skipping is allowed,
* otherwise `{ base, added, modified, violations }`.
*/
export async function findNewSleeps({ repoRoot, webDir, configFile, argv = [] }) {
const resolved = resolveBase(argv, repoRoot);
if (resolved.skip) return { skip: resolved.skip };
const { base } = resolved;
const added = changedPaths(base, "A", repoRoot).filter(isE2eCandidate);
// Renames are judged like modifications: moving a legacy spec must not
// suddenly demand that every sleep in it be converted. A pure rename adds no
// lines and so reports nothing.
const modified = changedPaths(base, "MRC", repoRoot).filter(isE2eCandidate);
const targets = [...added, ...modified];
if (targets.length === 0) return { base, added, modified, violations: [] };
const addedRanges =
modified.length === 0 ? new Map() : parseAddedLineRanges(webDiff(base, repoRoot));
const eslint = new ESLint({
cwd: webDir,
overrideConfigFile: configFile,
// `causal-waits.ts` is in the config's `ignores`, and passing an ignored
// file explicitly is a warning that would otherwise print on every run that
// touches it.
warnIgnored: false,
});
const results = await eslint.lintFiles(targets.map((file) => path.join(repoRoot, file)));
const addedSet = new Set(added);
const violations = [];
for (const result of results) {
const repoPath = toPosixPath(path.relative(repoRoot, result.filePath));
const whole = addedSet.has(repoPath);
const ranges = addedRanges.get(repoPath);
for (const message of result.messages) {
if (message.ruleId !== SLEEP_RULE_ID) continue;
if (!whole && !lineInRanges(ranges, message.line)) continue;
violations.push({
repoPath,
whole,
line: message.line,
messageId: message.messageId,
text: message.message,
});
}
}
return { base, added, modified, violations };
}
@@ -0,0 +1,312 @@
/**
* 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([]);
});
});
@@ -0,0 +1,134 @@
/**
* The ratchet is actually WIRED UP.
*
* Every other test here proves the matcher is right and the diff attribution is
* right. None of them would notice if the CI step, the pre-commit hook or the
* script's own call to `findNewSleeps` were deleted — the rule would still be
* correct, its tests would still pass, and the gate would silently never run
* again. That is the worst failure mode available to a check: it reports a clean
* tree forever, in the one place nobody thinks to look.
*
* So these assert the call sites, not the logic. They are the mutants
* "delete the CI step", "delete the hook", and "delete the call while keeping
* the function".
*
* Matching is on the script FILENAME rather than on surrounding YAML structure,
* so reformatting, renaming a step or reordering the workflow does not break
* them — only actually removing the invocation does.
*/
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const WEB_DIR = path.resolve(import.meta.dirname, "../..");
const REPO_ROOT = path.resolve(WEB_DIR, "../..");
const RATCHET_SCRIPT = "check-new-e2e-sleeps.mjs";
const read = (rel: string) => fs.readFileSync(path.join(REPO_ROOT, rel), "utf8");
describe("sleep ratchet wiring", () => {
/**
* Both invocations, not just the filename.
*
* `toContain(RATCHET_SCRIPT)` alone was too weak: the step has a
* `pull_request` branch and a `push` branch, so deleting either one left the
* other's mention of the filename and the assertion still passed. A mutation
* run caught it. Count the call sites instead.
*/
it("runs in CI, in the frontend workflow, on both event paths", () => {
const workflow = read(".github/workflows/frontend-tests.yml");
expect(workflow).toMatch(/^\s+- name: Run E2E sleep ratchet$/m);
const calls = workflow.match(new RegExp(`node scripts/${RATCHET_SCRIPT}`, "g")) ?? [];
expect(calls).toHaveLength(2);
});
/**
* `pull_request` must NOT pass `--base`. The checkout is the merge ref and
* `pull_request.base.sha` is an ancestor of it, so handing it in makes every
* commit main landed since the PR opened read as this PR's new code (#2160
* did exactly this to the i18n ratchet).
*/
it("does not pass --base on pull_request in CI", () => {
const workflow = read(".github/workflows/frontend-tests.yml");
const step = workflow.slice(workflow.indexOf("Run E2E sleep ratchet"));
// Match the pull_request branch structurally rather than slicing at the
// first `BASE_SHA=` literal: a future YAML comment mentioning that name
// would move the slice boundary and quietly shrink what is asserted
// (review finding). This is strictly tighter than the slice was — it pins
// the invocation, its bare form, and the early exit, in order.
expect(step).toMatch(
new RegExp(
`if \\[\\[ "\\$\\{\\{ github.event_name \\}\\}" == "pull_request" \\]\\];? then\\n` +
`\\s+node scripts/${RATCHET_SCRIPT}\\n` +
`\\s+exit 0\\n`,
),
);
});
/**
* The hook id is matched to end-of-line. `toContain("id: e2e-sleep-ratchet")`
* was satisfied by `id: e2e-sleep-ratchet-disabled`, so renaming the hook to
* switch it off passed the test — a mutation run caught it.
*/
it("runs as a pre-commit hook on e2e changes", () => {
const config = read(".pre-commit-config.yaml");
expect(config).toMatch(/^\s+- id: e2e-sleep-ratchet$/m);
expect(config).toContain(RATCHET_SCRIPT);
// The hook must fire for the tree it guards.
expect(config).toContain("^apps/web/e2e/.*\\.tsx?$");
});
it("is reachable as a package script", () => {
const pkg = JSON.parse(read("apps/web/package.json"));
expect(pkg.scripts["e2e:sleep-ratchet"]).toContain(RATCHET_SCRIPT);
expect(pkg.scripts["e2e:waits"]).toContain("report-e2e-waits.mjs");
expect(pkg.scripts["lint:e2e-sleeps"]).toContain("eslint.e2e-sleeps.config.mjs");
});
/**
* The CLI must actually invoke the decision function. A shell that imports it
* and then exits 0 unconditionally is the "delete the call, keep the function"
* mutant, and it looks completely healthy from the outside.
*/
it("the CLI calls findNewSleeps and exits non-zero on violations", () => {
const cli = read(`apps/web/scripts/${RATCHET_SCRIPT}`);
expect(cli).toContain("await findNewSleeps(");
expect(cli).toContain("process.exit(1)");
// The failure exit must be reachable from the violations branch, not
// orphaned below an unconditional success exit.
expect(cli.indexOf("violations.length === 0")).toBeLessThan(cli.indexOf("process.exit(1)"));
});
/**
* The eslint rule is registered in the main config too, on the directories
* already converted. Losing this registration removes editor feedback and the
* graduation path, without any test elsewhere noticing.
*/
it("registers the rule in the main eslint config", () => {
const config = read("apps/web/eslint.config.mjs");
expect(config).toContain("e2eSleepGuardFiles");
expect(config).toContain('"e2e-sleeps/no-unsanctioned-sleep": "error"');
});
/**
* The guard list may only grow. A directory listed here was measured at zero,
* so removing an entry means somebody added a sleep to a clean directory and
* deleted the guard rather than the sleep.
*/
it("keeps every seeded guard directory", () => {
const config = read("apps/web/eslint-rules/no-unsanctioned-sleep.mjs");
for (const dir of [
"e2e/scripts/**/*.{ts,tsx}",
"e2e/tests/cli-mode/**/*.{ts,tsx}",
"e2e/tests/docker/**/*.{ts,tsx}",
"e2e/tests/github/**/*.{ts,tsx}",
"e2e/tests/i18n/**/*.{ts,tsx}",
"e2e/tests/kanban/**/*.{ts,tsx}",
"e2e/tests/preview/**/*.{ts,tsx}",
]) {
expect(config).toContain(`"${dir}"`);
}
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* Inventory of the *sanctioned* waits in `apps/web/e2e` — the debt report's
* data layer.
*
* Counting raw sleeps is not this module's job: those come from the eslint rule
* (`eslint-rules/no-unsanctioned-sleep.mjs`), so there is exactly one definition
* of what a banned sleep is. This module only reads the two legal forms, which
* the rule never sees.
*
* ## What the numbers mean
*
* `dwell` carries a category, and the categories are not interchangeable:
*
* - **`negative-assertion` is permanent and is NOT debt.** A test asserting
* that something never happens has no event to wait for. "Fixing" one by
* deleting the wait removes the window in which the regression could occur
* and leaves a test that passes for free. It is reported separately so
* nobody optimises it toward zero.
* - **`unverified` is the debt to drive to zero.** It means the author could
* not name the timer. Every other category names one.
* - The five named-timer categories are compromises of varying fixability,
* reported so a conversion effort can pick targets.
*
* `injectLatency` is counted entirely separately. It delays the system under
* test on purpose — the delay is the stimulus, and removing it destroys the
* scenario. Folding it into a sleep total would let deliberate fixture
* configuration inflate a number that is supposed to be shrinking.
*/
// `typescript-eslint`, not `@typescript-eslint/typescript-estree` directly:
// pnpm's strict layout does not hoist the transitive package, so importing it
// fails with ERR_MODULE_NOT_FOUND. The umbrella package is a declared dependency
// and re-exports the same parser.
import tseslint from "typescript-eslint";
/**
* The category list, read out of `causal-waits.ts` itself rather than restated.
*
* A restated copy drifts silently: a category added to the helper would be
* counted as unknown here, and the report would under-count the very thing it
* exists to track. Throws rather than falling back to a hardcoded list, because
* a silently-empty category set makes every dwell read as uncategorised.
*/
export function readDwellCategories(source) {
const match = /export const DWELL_CATEGORIES = \[([\s\S]*?)\] as const;/.exec(source);
if (!match) {
throw new Error(
"e2e-wait-inventory: could not find `export const DWELL_CATEGORIES = [...] as const;` in " +
"causal-waits.ts. The report derives categories from the helper so the two cannot drift; " +
"update this parser rather than hardcoding a list.",
);
}
const categories = [...match[1].matchAll(/"([a-z-]+)"/g)].map((entry) => entry[1]);
if (categories.length === 0) {
throw new Error("e2e-wait-inventory: DWELL_CATEGORIES parsed to an empty list");
}
return categories;
}
function walk(node, visit) {
if (!node || typeof node.type !== "string") return;
visit(node);
for (const key of Object.keys(node)) {
if (key === "parent") continue;
const value = node[key];
if (Array.isArray(value)) {
for (const child of value) walk(child, visit);
} else if (value && typeof value === "object") {
walk(value, visit);
}
}
}
function calleeName(node) {
return node.callee?.type === "Identifier" ? node.callee.name : null;
}
/**
* The category argument of a `dwell()` call.
*
* `dwell(ms, category, reason)` and `dwell(page, ms, category, reason)` are
* overloads on one name, so the category sits at index 1 or 2 depending on the
* form. It is always the FIRST string-literal argument either way — `ms` is
* numeric and `page` is an identifier — and `reason` is the second. Matching by
* position would need the two forms told apart; matching the first string does
* not, and `dwell` validates the value at runtime against the same list, so an
* unrecognised one here is a real miscategorisation rather than a parse gap.
*/
function dwellCategory(node, categories) {
const strings = node.arguments.filter(
(argument) => argument.type === "Literal" && typeof argument.value === "string",
);
const category = strings[0]?.value;
if (category === undefined) return "«no literal category»";
return categories.includes(category) ? category : `«unknown: ${category}»`;
}
/**
* Sanctioned waits in one file.
*
* @returns {{ dwells: Array<{category: string, line: number}>, injectLatency: number }}
*/
export function inventorySource(source, filePath, categories) {
const { ast } = tseslint.parser.parseForESLint(source, { loc: true, range: true });
const dwells = [];
let injectLatency = 0;
walk(ast, (node) => {
if (node.type !== "CallExpression") return;
const name = calleeName(node);
if (name === "dwell") {
dwells.push({ category: dwellCategory(node, categories), line: node.loc.start.line });
} else if (name === "injectLatency") {
injectLatency += 1;
}
});
return { filePath, dwells, injectLatency };
}
/**
* Fold per-file inventories into the report's totals.
*
* `negativeAssertion` is excluded from `debt` on purpose — see the module
* comment. `debt` is the number a conversion effort is trying to move.
*/
export function summarize(inventories, categories) {
const byCategory = Object.fromEntries(categories.map((category) => [category, 0]));
let unknown = 0;
let injectLatency = 0;
for (const inventory of inventories) {
injectLatency += inventory.injectLatency;
for (const dwell of inventory.dwells) {
if (dwell.category in byCategory) byCategory[dwell.category] += 1;
else unknown += 1;
}
}
const permanent = byCategory["negative-assertion"] ?? 0;
const total = Object.values(byCategory).reduce((sum, count) => sum + count, 0) + unknown;
return {
byCategory,
unknown,
injectLatency,
totalDwells: total,
permanent,
// Everything that is a compromise rather than a fact of the test.
debt: total - permanent,
unverified: byCategory.unverified ?? 0,
};
}
@@ -0,0 +1,163 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { inventorySource, readDwellCategories, summarize } from "./e2e-wait-inventory.mjs";
const CAUSAL_WAITS = path.resolve(import.meta.dirname, "../../e2e/helpers/causal-waits.ts");
const CATEGORIES = readDwellCategories(fs.readFileSync(CAUSAL_WAITS, "utf8")) as string[];
/** Extracted because it is the one category that is permanent rather than debt. */
const NEGATIVE = "negative-assertion";
const inventory = (code: string) => inventorySource(code, "fixture.ts", CATEGORIES);
describe("readDwellCategories", () => {
/**
* Read from the helper rather than restated here. If `causal-waits.ts` grows a
* category, this picks it up; if the export is renamed or reshaped, the parse
* throws instead of silently reporting every dwell as uncategorised.
*/
it("reads the live category list out of causal-waits.ts", () => {
expect(CATEGORIES).toEqual([
NEGATIVE,
"product-timer",
"library-timer",
"clock-separation",
"poll-interval",
"browser-chrome",
"unverified",
]);
});
it("throws rather than guessing when the export cannot be found", () => {
expect(() => readDwellCategories("export const SOMETHING_ELSE = [];")).toThrow(
/could not find/i,
);
});
it("throws when the list parses to nothing", () => {
expect(() => readDwellCategories("export const DWELL_CATEGORIES = [] as const;")).toThrow(
/empty list/i,
);
});
});
describe("inventorySource", () => {
/**
* The two overloads put `category` at a different index — 1 without a page, 2
* with one. Both must land in the same bucket, or the report splits one
* category across two rows depending on which form the author used.
*/
it("reads the category from the page-less form", () => {
const found = inventory('await dwell(500, "poll-interval", "backend health poll");');
expect(found.dwells).toEqual([{ category: "poll-interval", line: 1 }]);
});
it("reads the category from the page form", () => {
const found = inventory('await dwell(page, 300, "library-timer", "Radix open delay");');
expect(found.dwells).toEqual([{ category: "library-timer", line: 1 }]);
});
it("counts every category separately across a file", () => {
const found = inventory(`
await dwell(page, 300, "${NEGATIVE}", "asserting the toast never appears");
await dwell(100, "unverified", "pre-existing spacing, no timer identified");
await dwell(page, 50, "clock-separation", "keep the two writes distinguishable");
`);
expect(found.dwells.map((entry: { category: string }) => entry.category)).toEqual([
NEGATIVE,
"unverified",
"clock-separation",
]);
});
it("flags a category that is not in the live list rather than dropping it", () => {
const found = inventory('await dwell(500, "made-up-category", "why");');
expect(found.dwells[0].category).toBe("«unknown: made-up-category»");
});
it("flags a dwell whose category is not a literal", () => {
const found = inventory("await dwell(500, category, reason);");
expect(found.dwells[0].category).toBe("«no literal category»");
});
it("counts injectLatency apart from dwell", () => {
const found = inventory(`
await injectLatency(800, "make the spinner observable");
await dwell(100, "unverified", "no timer identified");
`);
expect(found.injectLatency).toBe(1);
expect(found.dwells).toHaveLength(1);
});
it("parses TypeScript syntax without a type checker", () => {
const found = inventory(`
const wait = async (page: Page): Promise<void> => {
await dwell(page, 10, "browser-chrome", "native print dialog");
};
`);
expect(found.dwells).toHaveLength(1);
});
});
describe("summarize", () => {
const build = (categories: string[], injectLatency = 0) => [
{
filePath: "fixture.ts",
dwells: categories.map((category) => ({ category, line: 1 })),
injectLatency,
},
];
/**
* The load-bearing assertion of the whole report: `negative-assertion` is
* permanent and must never appear in the number a conversion effort is trying
* to reduce. A test asserting something never happens has no event to wait
* for, and deleting its wait leaves a test that passes for free.
*/
it("excludes negative-assertion from debt", () => {
const summary = summarize(build([NEGATIVE, NEGATIVE]), CATEGORIES);
expect(summary.totalDwells).toBe(2);
expect(summary.permanent).toBe(2);
expect(summary.debt).toBe(0);
});
it("counts every other category as debt", () => {
const summary = summarize(
build([NEGATIVE, "unverified", "library-timer", "poll-interval"]),
CATEGORIES,
);
expect(summary.totalDwells).toBe(4);
expect(summary.permanent).toBe(1);
expect(summary.debt).toBe(3);
expect(summary.unverified).toBe(1);
});
/**
* injectLatency is deliberate fixture configuration, correct by construction.
* Folding it in would make adding a legitimate latency simulation read as a
* regression in a number that is supposed to be shrinking.
*/
it("keeps injectLatency out of both dwell totals", () => {
const summary = summarize(build(["unverified"], 5), CATEGORIES);
expect(summary.injectLatency).toBe(5);
expect(summary.totalDwells).toBe(1);
expect(summary.debt).toBe(1);
});
it("surfaces uncategorised dwells instead of silently dropping them", () => {
const summary = summarize(build(["«unknown: nope»", "unverified"]), CATEGORIES);
expect(summary.unknown).toBe(1);
expect(summary.totalDwells).toBe(2);
expect(summary.debt).toBe(2);
});
it("reports zeros for a suite with no dwells at all", () => {
const summary = summarize([], CATEGORIES);
expect(summary.totalDwells).toBe(0);
expect(summary.debt).toBe(0);
expect(summary.byCategory.unverified).toBe(0);
});
});
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env node
/**
* Debt report for wall-clock waits in `apps/web/e2e`.
*
* **This is a report, not a gate.** It never exits non-zero on a count. The
* conversion effort is actively *adding* `unverified` dwells as it replaces raw
* sleeps with categorised ones — that is progress, because an `unverified`
* dwell states its ignorance where a bare `waitForTimeout(500)` hid it — so a
* ratchet on the number would fight the migration it exists to measure. The
* gate is `check-new-e2e-sleeps.mjs`, and it gates the *form*, not the count.
*
* Three numbers, deliberately kept apart:
*
* - **raw sleeps**, counted by the same eslint rule the gate uses, so the
* report and the gate cannot disagree about what a sleep is. This is what
* the conversion drives to zero, and reaching zero is what makes the
* graduation flip (rule to `"error"` on all of `e2e/**`) possible.
* - **dwell debt**, every categorised wait EXCEPT `negative-assertion`.
* `unverified` is called out inside it as the sub-total to attack first.
* - **injectLatency**, counted on its own. It is deliberate fixture
* configuration, correct by construction, and must never be converted; if
* it were folded into the sleep total, adding a legitimate latency
* simulation would look like a regression.
*
* `negative-assertion` is printed under a heading that says it is permanent.
* A test asserting something never happens has no event to wait on, and
* deleting its wait removes the window the regression needs in order to appear.
*
* Usage: node scripts/report-e2e-waits.mjs [--json]
*/
import fs from "node:fs";
import path from "node:path";
import { ESLint } from "eslint";
import { SLEEP_EXEMPT_FILES, SLEEP_RULE_ID } from "../eslint-rules/no-unsanctioned-sleep.mjs";
import { inventorySource, readDwellCategories, summarize } from "./lib/e2e-wait-inventory.mjs";
import { WEB_DIR } from "./lib/git-base.mjs";
const E2E_DIR = path.join(WEB_DIR, "e2e");
const CAUSAL_WAITS = path.join(E2E_DIR, "helpers", "causal-waits.ts");
function collectTsFiles(dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "test-results") continue;
out.push(...collectTsFiles(full));
} else if (entry.name.endsWith(".ts")) {
out.push(full);
}
}
return out;
}
const categories = readDwellCategories(fs.readFileSync(CAUSAL_WAITS, "utf8"));
const files = collectTsFiles(E2E_DIR);
// The helper's own definitions and its unit test would otherwise be counted as
// usage sites, inflating every category by the examples that define it. Same
// list the eslint configs use, so "exempt from the rule" and "not a usage site"
// cannot drift apart.
const exempt = new Set(SLEEP_EXEMPT_FILES.map((file) => path.join(WEB_DIR, file)));
// Sanctioned waits: our own AST walk, since the rule never sees them.
const inventories = files
.filter((file) => !exempt.has(file))
.map((file) => inventorySource(fs.readFileSync(file, "utf8"), file, categories));
const dwell = summarize(inventories, categories);
// Raw sleeps: the gate's own rule, so there is one definition of "a sleep".
const eslint = new ESLint({
cwd: WEB_DIR,
overrideConfigFile: path.join(WEB_DIR, "eslint.e2e-sleeps.config.mjs"),
warnIgnored: false,
});
const results = await eslint.lintFiles(files);
const raw = { waitForTimeout: 0, promiseSleep: 0, byFile: {} };
for (const result of results) {
const messages = result.messages.filter((message) => message.ruleId === SLEEP_RULE_ID);
if (messages.length === 0) continue;
raw.byFile[path.relative(WEB_DIR, result.filePath)] = messages.length;
for (const message of messages) {
if (message.messageId === "waitForTimeout") raw.waitForTimeout += 1;
else raw.promiseSleep += 1;
}
}
const rawTotal = raw.waitForTimeout + raw.promiseSleep;
if (process.argv.includes("--json")) {
console.log(JSON.stringify({ raw: { ...raw, total: rawTotal }, dwell }, null, 2));
process.exit(0);
}
const pad = (label) => label.padEnd(22);
console.log("\nE2E wall-clock wait inventory\n");
console.log(` UNSANCTIONED — drive to zero, then graduate the rule to "error"`);
console.log(` ${pad("waitForTimeout")}${raw.waitForTimeout}`);
console.log(` ${pad("promise sleep")}${raw.promiseSleep}`);
console.log(` ${pad("total")}${rawTotal}`);
console.log(`\n DWELL DEBT — categorised, ${dwell.debt} of ${dwell.totalDwells} are debt`);
for (const category of categories) {
if (category === "negative-assertion") continue;
const count = dwell.byCategory[category] ?? 0;
const note = category === "unverified" ? " <- attack this first" : "";
console.log(` ${pad(category)}${count}${note}`);
}
if (dwell.unknown > 0) {
console.log(` ${pad("uncategorised")}${dwell.unknown} <- miscategorised or unparsed`);
}
console.log(`\n PERMANENT — not debt, do not drive down`);
console.log(` ${pad("negative-assertion")}${dwell.permanent}`);
console.log(`\n DELIBERATE STIMULUS — not a wait, counted apart from sleep debt`);
console.log(` ${pad("injectLatency")}${dwell.injectLatency}`);
if (rawTotal > 0) {
const worst = Object.entries(raw.byFile)
.sort(([, a], [, b]) => b - a)
.slice(0, 10);
console.log(`\n Top files by remaining raw sleeps (${Object.keys(raw.byFile).length} total):`);
for (const [file, count] of worst) console.log(` ${String(count).padStart(3)} ${file}`);
}
console.log("");