Files
kandev/apps/web/scripts/lib/e2e-wait-inventory.mjs
2026-08-13 22:50:13 +01:00

153 lines
5.8 KiB
JavaScript

/**
* 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,
};
}