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
+20 -19
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import assert from "node:assert/strict";
import { test } from "vitest";
import { expect, test } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -132,7 +132,7 @@ test("shows archive instead of delete for an asset in use", async () => {
await user.click(await screen.findByRole("button", { name: "boundary" }));
assert.ok(await screen.findByRole("button", { name: "보관" }));
assert.equal(screen.queryByRole("button", { name: "삭제" }), null);
expect(screen.queryByRole("button", { name: "삭제" })).not.toBeInTheDocument();
});
test("surfaces ASSET_IN_USE when the server rejects a delete", async () => {
@@ -216,7 +216,7 @@ test("removes the asset and restores focus to the page heading, not the detached
const options = deleteCalls[0].options as { idempotencyKey?: unknown };
assert.equal(typeof options.idempotencyKey, "string");
assert.ok((options.idempotencyKey as string).length > 0);
assert.equal(screen.queryByRole("button", { name: "boundary" }), null);
expect(screen.queryByRole("button", { name: "boundary" })).not.toBeInTheDocument();
// `findByText` above already settled on the post-delete render -- the
// `queueMicrotask`-scheduled focus call has necessarily run by then, since
@@ -224,8 +224,16 @@ test("removes the asset and restores focus to the page heading, not the detached
// (its polling uses `setTimeout`/`MutationObserver`), and microtasks always
// drain before the next macrotask runs. So this asserts directly rather
// than polling.
//
// `toHaveFocus`, never `assert.equal(document.activeElement, heading)`:
// node:assert builds an AssertionError by running `util.inspect` over both
// operands at `depth: 1000`, and a React-rendered element carries
// `__reactFiber$*` own properties whose graph re-expands per traversal
// path. Inspecting one costs gigabytes, so the regression this test guards
// used to kill the worker instead of reporting itself. jest-dom prints DOM
// nodes through pretty-format's DOM plugin, which never touches the fiber.
const heading = screen.getByRole("heading", { name: "Asset", level: 1 });
assert.equal(document.activeElement, heading);
expect(heading).toHaveFocus();
});
// --- Alignment follow-up, item 1: the management screen gets search and paging ---
@@ -280,7 +288,7 @@ test("searches assets with the typed query and announces the new result count",
await user.click(screen.getByRole("button", { name: "검색" }));
assert.ok(await screen.findByRole("button", { name: "older-diagram" }));
assert.equal(screen.queryByRole("button", { name: "newer-diagram" }), null);
expect(screen.queryByRole("button", { name: "newer-diagram" })).not.toBeInTheDocument();
assert.equal(calls.at(-1)?.q, "older");
// The result count lives in a `role="status"` region so a screen reader is
// told the list changed -- a silently re-rendered list is the failure mode
@@ -324,7 +332,7 @@ test("loads the next page with the server cursor and appends it to the list", as
assert.equal(calls.at(-1)?.cursor, "cursor-1");
// Exhausted paging removes the control rather than leaving a button that
// does nothing.
assert.equal(screen.queryByRole("button", { name: "더 보기" }), null);
expect(screen.queryByRole("button", { name: "더 보기" })).not.toBeInTheDocument();
});
test("더 보기 moves focus to the first newly loaded asset instead of stranding it on the removed control", async () => {
@@ -342,21 +350,14 @@ test("더 보기 moves focus to the first newly loaded asset instead of strandin
await user.click(screen.getByRole("button", { name: "더 보기" }));
const appended = await screen.findByRole("button", { name: "focus-page-two" });
// Compared as a boolean, not as two nodes: `assert.equal(node, node)` builds
// its failure message by inspecting both jsdom elements, and that traversal
// is heavy enough to take the worker down instead of reporting the failure.
assert.equal(
document.activeElement === appended,
true,
`focus was on ${document.activeElement?.textContent ?? "nothing"}`,
);
expect(appended).toHaveFocus();
});
test("offers no 더 보기 control when the server reports no next cursor", async () => {
render(<AssetLibrary gateway={gatewayOf({ asset: ASSET, usages: [], hasPublicationHistory: false })} />);
await screen.findByRole("button", { name: "boundary" });
assert.equal(screen.queryByRole("button", { name: "더 보기" }), null);
expect(screen.queryByRole("button", { name: "더 보기" })).not.toBeInTheDocument();
});
test("a new search restarts paging from the first page instead of carrying the old cursor", async () => {
@@ -380,8 +381,8 @@ test("a new search restarts paging from the first page instead of carrying the o
await user.click(screen.getByRole("button", { name: "검색" }));
assert.ok(await screen.findByRole("button", { name: "reset-found" }));
assert.equal(screen.queryByRole("button", { name: "reset-page-one" }), null);
assert.equal(screen.queryByRole("button", { name: "reset-page-two" }), null);
expect(screen.queryByRole("button", { name: "reset-page-one" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "reset-page-two" })).not.toBeInTheDocument();
assert.equal(calls.at(-1)?.cursor, undefined);
});
@@ -438,7 +439,7 @@ test("a slow earlier search never overwrites the newer search's results", async
pending[1]!.settle({ items: [slow], nextCursor: null });
});
assert.equal(screen.queryByRole("button", { name: "race-slow" }), null);
expect(screen.queryByRole("button", { name: "race-slow" })).not.toBeInTheDocument();
assert.ok(screen.getByRole("button", { name: "race-fast" }));
});
@@ -454,5 +455,5 @@ test("a search that matches nothing says so instead of claiming no asset was eve
await user.click(screen.getByRole("button", { name: "검색" }));
assert.ok(await screen.findByText("조건에 맞는 Asset이 없습니다"));
assert.equal(screen.queryByText("등록된 Asset이 없습니다"), null);
expect(screen.queryByText("등록된 Asset이 없습니다")).not.toBeInTheDocument();
});