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:
co-authored by
Claude Opus 5
parent
2483c4032f
commit
b2f577ef49
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { afterAll, beforeAll, test } from "vitest";
|
||||
import { afterAll, beforeAll, expect, test } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
@@ -143,7 +143,7 @@ test("does not offer a QUARANTINED asset for insertion", async () => {
|
||||
render(<AssetPicker gateway={gatewayOf([READY, QUARANTINED])} onInsert={() => {}} />);
|
||||
|
||||
await screen.findByRole("button", { name: /fetch-strategy-boundary/ });
|
||||
assert.equal(screen.queryByRole("button", { name: /unsafe/ }), null);
|
||||
expect(screen.queryByRole("button", { name: /unsafe/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("reports the loaded Asset list once listAssets resolves", async () => {
|
||||
@@ -398,14 +398,19 @@ test("focuses the file input on open, traps Tab inside the dialog, and calls onC
|
||||
const altInput = screen.getByLabelText("대체 텍스트");
|
||||
const uploadButton = screen.getByRole("button", { name: "업로드" });
|
||||
const closeButton = screen.getByRole("button", { name: "닫기" });
|
||||
assert.equal(document.activeElement, input);
|
||||
// `toHaveFocus`, never `assert.equal(document.activeElement, input)`:
|
||||
// node:assert inspects both operands at `depth: 1000` to build its failure
|
||||
// message, and a React-rendered element's `__reactFiber$*` graph re-expands
|
||||
// per traversal path, so inspecting one exhausts the heap and kills the
|
||||
// worker instead of reporting the regression.
|
||||
expect(input).toHaveFocus();
|
||||
|
||||
// Final fix wave, item 2: the trap now has to hold across the two metadata
|
||||
// controls the dialog gained, and still wrap from the last back to the first.
|
||||
input.focus();
|
||||
for (const expected of [decorativeBox, altInput, closeButton, uploadButton, input]) {
|
||||
await user.tab();
|
||||
assert.equal(document.activeElement, expected);
|
||||
expect(expected).toHaveFocus();
|
||||
}
|
||||
|
||||
await user.click(closeButton);
|
||||
@@ -457,7 +462,7 @@ test("inserts the directive at the saved cursor position and the live preview re
|
||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
||||
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
||||
|
||||
assert.equal(within(panel).queryByRole("alert"), null);
|
||||
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
|
||||
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
|
||||
// the zoom trigger, once inside the (closed) zoom dialog -- both share the
|
||||
// same alt text, so assert on the first (the visible trigger figure).
|
||||
@@ -1299,7 +1304,7 @@ test("Instant Preview renders the same asset the gate and the descriptor picked
|
||||
[newer, older],
|
||||
);
|
||||
|
||||
assert.equal(screen.queryByRole("alert"), null);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
assert.equal(
|
||||
screen.getByAltText("중복 키 그림").getAttribute("src"),
|
||||
"/media/dup-pixels-newer.svg",
|
||||
@@ -1629,7 +1634,7 @@ test("the Picker shows a first page before anything is typed, then narrows to th
|
||||
await user.click(screen.getByRole("button", { name: "검색" }));
|
||||
|
||||
await waitFor(() =>
|
||||
assert.equal(screen.queryByRole("button", { name: /inserted-diagram/ }), null),
|
||||
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
|
||||
);
|
||||
assert.ok(screen.getByRole("button", { name: /other-diagram/ }));
|
||||
assert.equal(calls.at(-1)?.q, "other");
|
||||
@@ -1680,7 +1685,7 @@ test("a QUARANTINED asset the server wrongly returns for a query is still not of
|
||||
await waitFor(() =>
|
||||
assert.ok(screen.getByRole("button", { name: /inserted-diagram/ })),
|
||||
);
|
||||
assert.equal(screen.queryByRole("button", { name: /unsafe-diagram/ }), null);
|
||||
expect(screen.queryByRole("button", { name: /unsafe-diagram/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the Picker reports search results too, so the catalog grows with every query", async () => {
|
||||
@@ -1753,7 +1758,7 @@ test("a slow earlier Picker search never overwrites the newer query's results",
|
||||
pending[1]!.settle({ items: [PICKER_A], nextCursor: null });
|
||||
});
|
||||
|
||||
assert.equal(screen.queryByRole("button", { name: /inserted-diagram/ }), null);
|
||||
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument();
|
||||
assert.ok(screen.getByRole("button", { name: /other-diagram/ }));
|
||||
});
|
||||
|
||||
@@ -1792,7 +1797,7 @@ test("a Picker search never drops an already-inserted asset out of Instant Previ
|
||||
await user.type(screen.getByLabelText("Asset 검색"), "other");
|
||||
await user.click(screen.getByRole("button", { name: "검색" }));
|
||||
await waitFor(() =>
|
||||
assert.equal(screen.queryByRole("button", { name: /inserted-diagram/ }), null),
|
||||
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
||||
|
||||
Reference in New Issue
Block a user