test: stop DOM-element assertions from OOM-killing the worker

`node:assert` builds its `AssertionError` eagerly, running `util.inspect`
over both operands with `depth: 1000`, `getters: true` and
`maxArrayLength: Infinity`. A React-rendered DOM element carries
`__reactFiber$*` / `__reactProps$*` as own enumerable properties, and that
fiber graph re-expands once per traversal path, so inspecting a single
rendered element allocates without bound. Measured on the Asset Library
heading: depth 6 = 1.3MB, 8 = 7.8MB, 10 = 36MB, 12 = 135MB -- at Node's
depth 1000 the worker dies before any `AssertionError` exists.

The damage is not the crash, it is the disguise. Equality assertions only
inspect their operands on failure, so these sites stayed invisible while
green and detonated exactly when the behaviour they guard regressed --
reporting as `worker exited unexpectedly` with a truncated count
(`8 passed (13)`) and no failing test named. Breaking the delete-path focus
restoration in `asset-library.tsx` reproduced it: 29.5GB anon-rss and the
system OOM killer, or a V8 heap abort in 3s under a 512MB cap. The same
regression now fails in 1.15s with `expect(element).toHaveFocus()` naming
both the expected heading and the `<body>` that took focus instead.

`expect` is not affected -- vitest prints and diffs DOM nodes through
pretty-format's DOM plugin, which reads tag/attributes/children and never
touches the fiber -- so every unsafe site converts to a matcher:
`toHaveFocus()` for the three focus comparisons, `not.toBeInTheDocument()`
for the sixteen `assert.equal(queryBy..., null)` absence checks, which are
equally lethal (proved separately: element-vs-null inspects the element).

Three layers so this cannot come back:
- the 20 live sites in asset-library/asset-picker now use matchers;
- `test-assertion-boundary/no-element-operand-equality` fails `pnpm lint`
  when a DOM-element expression reaches `node:assert` equality, resolving
  local bindings and exempting the forms that cannot fail with an element
  in hand (`assert.notEqual(el, null)`, `el.textContent`);
- a 2048MB worker old-space ceiling in `vitest.config.ts` bounds any future
  runaway to a legible `Reached heap limit` abort in seconds instead of an
  OOM-killed machine (heaviest suite peaks near 1.3GB RSS).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 14:03:33 +09:00
co-authored by Claude Opus 5
parent 2483c4032f
commit b2f577ef49
4 changed files with 239 additions and 29 deletions
+194
View File
@@ -382,6 +382,194 @@ const browserDataBoundaryPlugin = {
},
};
/**
* A DOM element rendered by React carries `__reactFiber$*` / `__reactProps$*`
* as *own enumerable* properties. `node:assert` builds its `AssertionError`
* eagerly, running `util.inspect` over both operands with `depth: 1000`,
* `getters: true` and `maxArrayLength: Infinity`; the fiber graph re-expands
* once per traversal path, so inspecting one rendered element allocates
* gigabytes and the worker dies before any `AssertionError` is ever thrown.
* A genuine regression then reports as an OOM or an opaque timeout instead of
* a failed assertion. Measured on this repo's Asset Library heading:
* depth 6 = 1.3MB, depth 8 = 7.8MB, depth 10 = 36MB, depth 12 = 135MB.
*
* Equality assertions only inspect their operands on failure, so an unsafe
* comparison stays invisible while green and detonates the day the behaviour
* it guards regresses -- which is exactly when the diagnosis is needed.
*
* `expect` is not affected: vitest prints and diffs through pretty-format's
* DOM plugin, which reads tag/attributes/children and never touches the fiber.
* So the safe form is always an `expect` matcher -- `toHaveFocus()`,
* `not.toBeInTheDocument()`, `toBe(element)` -- and this rule only forbids
* handing a DOM element to `node:assert`.
*/
const TESTING_LIBRARY_QUERY =
/^(get|query|find)(All)?By(Role|Text|LabelText|PlaceholderText|AltText|Title|DisplayValue|TestId)$/u;
const domQueryMethods = new Set([
"querySelector",
"querySelectorAll",
"getElementById",
"closest",
]);
const domElementProperties = new Set([
"activeElement",
"parentElement",
"firstElementChild",
"lastElementChild",
"nextElementSibling",
"previousElementSibling",
"offsetParent",
]);
// Fail when the operands *differ*, so on failure at least one element is
// still there to be inspected.
const positiveAssertEqualities = new Set([
"equal",
"strictEqual",
"deepEqual",
"deepStrictEqual",
]);
// Fail when the operands *match*. `assert.notEqual(element, null)` can only
// fail with `null` on both sides, so a nullish literal operand makes these
// safe; anything else leaves an element to inspect.
const negativeAssertEqualities = new Set([
"notEqual",
"notStrictEqual",
"notDeepEqual",
"notDeepStrictEqual",
]);
const noElementOperandEqualityRule: Rule.RuleModule = {
meta: {
type: "problem",
schema: [],
messages: {
unbounded:
"node:assert inspects both operands at depth 1000 to build its failure message, and a React-rendered element's __reactFiber$* graph exhausts the worker heap there, so the regression reports as an OOM instead of an assertion. Use an expect matcher instead -- expect(el).toHaveFocus(), expect(el).not.toBeInTheDocument(), expect(actual).toBe(expected) -- which prints DOM nodes through pretty-format's DOM plugin.",
},
},
create(context) {
const sourceCode = context.sourceCode;
const unwrap = (input: any): any => {
let node = input;
while (
node &&
[
"AwaitExpression",
"ChainExpression",
"TSAsExpression",
"TSNonNullExpression",
"TSSatisfiesExpression",
"TSTypeAssertion",
].includes(node.type)
) {
node = node.type === "AwaitExpression" ? node.argument : node.expression;
}
return node;
};
const memberName = (node: any): string | null => {
if (!node.computed && node.property?.type === "Identifier") {
return node.property.name;
}
if (
node.computed &&
(node.property?.type === "Literal" ||
node.property?.type === "StringLiteral") &&
typeof node.property.value === "string"
) {
return node.property.value;
}
return null;
};
const resolveInit = (node: any): any => {
const scope = sourceCode.getScope(node);
let current: any = scope;
while (current) {
const variable = current.variables.find(
(entry: any) => entry.name === node.name,
);
if (variable) {
const definition = variable.defs.at(-1);
return definition?.node?.type === "VariableDeclarator"
? definition.node.init
: null;
}
current = current.upper;
}
return null;
};
const isElementValued = (input: any, seen = new Set<any>()): boolean => {
const node = unwrap(input);
if (!node || seen.has(node)) return false;
seen.add(node);
if (node.type === "MemberExpression") {
const name = memberName(node);
return name !== null && domElementProperties.has(name);
}
if (node.type === "CallExpression") {
const callee = unwrap(node.callee);
if (callee?.type !== "MemberExpression") return false;
const name = memberName(callee);
return (
name !== null &&
(TESTING_LIBRARY_QUERY.test(name) || domQueryMethods.has(name))
);
}
if (node.type === "Identifier") {
return isElementValued(resolveInit(node), seen);
}
if (node.type === "ConditionalExpression") {
return (
isElementValued(node.consequent, seen) ||
isElementValued(node.alternate, seen)
);
}
return false;
};
const isNullish = (input: any): boolean => {
const node = unwrap(input);
if (!node) return false;
return (
(node.type === "Literal" && node.value === null) ||
(node.type === "Identifier" && node.name === "undefined")
);
};
return {
CallExpression(node: any) {
const callee = unwrap(node.callee);
if (callee?.type !== "MemberExpression") return;
const object = unwrap(callee.object);
if (object?.type !== "Identifier" || object.name !== "assert") return;
const name = memberName(callee);
if (name === null) return;
const positive = positiveAssertEqualities.has(name);
if (!positive && !negativeAssertEqualities.has(name)) return;
const operands = (node.arguments ?? []).slice(0, 2);
if (!positive && operands.some((argument: any) => isNullish(argument))) {
return;
}
const operand = operands.find((argument: any) =>
isElementValued(argument),
);
if (operand) context.report({ node: operand, messageId: "unbounded" });
},
};
},
};
const testAssertionBoundaryPlugin = {
rules: {
"no-element-operand-equality": noElementOperandEqualityRule,
},
};
const commonLanguageOptions = {
ecmaVersion: "latest",
sourceType: "module",
@@ -811,6 +999,12 @@ export default [
...globals.node,
},
},
plugins: {
"test-assertion-boundary": testAssertionBoundaryPlugin,
},
rules: {
"test-assertion-boundary/no-element-operand-equality": "error",
},
},
{
files: [`tests/support/browser/**/*.${sourceExtensions}`],