Files
tech-log-frontend/tests/features/tech-log/asset-library.test.tsx
T
DongHyeonkaandClaude Opus 5 b2f577ef49 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>
2026-08-18 14:03:33 +09:00

460 lines
19 KiB
TypeScript

// @vitest-environment jsdom
import assert from "node:assert/strict";
import { expect, test } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
AssetLibrary,
canHardDelete,
} from "../../../src/features/tech-log/presentation/studio/components/asset-library.tsx";
import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
const ASSET = {
id: "11111111-1111-4111-8111-111111111111",
assetKey: "boundary",
kind: "DIAGRAM",
mediaType: "image/svg+xml",
originalFilename: "b.svg",
byteSize: 10,
width: 1080,
height: 420,
altText: "경계",
decorative: false,
managementStatus: "READY",
publicPath: "/media/boundary.svg",
usageCount: 0,
version: 1,
createdAt: "2026-08-14T01:00:00.000Z",
updatedAt: "2026-08-14T01:00:00.000Z",
} as never;
// `ASSET` is typed `never` (see above) so it can stand in for any generated
// contract shape without fighting the type checker across this file's other
// fixtures. That means its fields can't be dereferenced directly; these two
// mirror the literals in `ASSET` above for the fix-round-1 tests that need
// to assert on them.
const ASSET_ID = "11111111-1111-4111-8111-111111111111";
const ASSET_VERSION = 1;
function gatewayOf(detail: unknown, onDelete?: () => never) {
return {
async listAssets() {
return { items: [ASSET], nextCursor: null } as never;
},
async getAsset() {
return detail as never;
},
async uploadAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
return ASSET;
},
async deleteAsset() {
if (onDelete) onDelete();
},
} as never;
}
/**
* Fix round 1 (Minor 4). Unlike `gatewayOf`, this records exactly what
* `archive()`/`remove()` send the gateway, so a test can pin the command
* shape (`expectedVersion`, `managementStatus`) and the idempotency-key
* plumbing, not just the resulting UI text.
*/
function recordingGateway(detail: unknown) {
const archiveCalls: Array<{ assetId: string; command: unknown; options: unknown }> = [];
const deleteCalls: Array<{ assetId: string; options: unknown }> = [];
const archivedAsset = { ...(ASSET as object), managementStatus: "ARCHIVED" } as never;
const gateway = {
async listAssets() {
return { items: [ASSET], nextCursor: null } as never;
},
async getAsset() {
return detail as never;
},
async uploadAsset() {
throw new Error("not used");
},
async updateAssetMetadata(assetId: string, command: unknown, options: unknown) {
archiveCalls.push({ assetId, command, options });
return archivedAsset;
},
async deleteAsset(assetId: string, options: unknown) {
deleteCalls.push({ assetId, options });
},
} as never;
return { gateway, archiveCalls, deleteCalls };
}
test("offers hard delete only for an unused asset with no publication history", () => {
assert.equal(
canHardDelete({ asset: ASSET, usages: [], hasPublicationHistory: false } as never),
true,
);
assert.equal(
canHardDelete({ asset: ASSET, usages: [], hasPublicationHistory: true } as never),
false,
);
assert.equal(
canHardDelete({
asset: ASSET,
usages: [{ documentId: "d", documentKind: "CASE", title: "문서", published: true }],
hasPublicationHistory: false,
} as never),
false,
);
// Fix round 1 (Minor 2). Every assertion above uses `usageCount: 0`, so
// the `asset.usageCount === 0` clause was never independently exercised --
// dropping or inverting it would still pass. `usages: []` and
// `hasPublicationHistory: false` isolate it: only the usage-count clause
// can make this `false`.
assert.equal(
canHardDelete({
asset: { ...(ASSET as object), usageCount: 1 },
usages: [],
hasPublicationHistory: false,
} as never),
false,
);
});
test("shows archive instead of delete for an asset in use", async () => {
const user = userEvent.setup();
render(<AssetLibrary gateway={gatewayOf({
asset: { ...(ASSET as object), usageCount: 1 },
usages: [{ documentId: "d", documentKind: "CASE", title: "사용 중 문서", published: true }],
hasPublicationHistory: true,
})} />);
await user.click(await screen.findByRole("button", { name: "boundary" }));
assert.ok(await screen.findByRole("button", { name: "보관" }));
expect(screen.queryByRole("button", { name: "삭제" })).not.toBeInTheDocument();
});
test("surfaces ASSET_IN_USE when the server rejects a delete", async () => {
const user = userEvent.setup();
render(<AssetLibrary gateway={gatewayOf(
{ asset: ASSET, usages: [], hasPublicationHistory: false },
() => {
throw new StudioGatewayError({
type: "https://techlog.local/problems/asset-in-use",
title: "ASSET_IN_USE",
status: 409,
detail: "사용 중인 Asset은 삭제할 수 없습니다.",
code: "ASSET_IN_USE",
});
},
)} />);
await user.click(await screen.findByRole("button", { name: "boundary" }));
await user.click(await screen.findByRole("button", { name: "삭제" }));
assert.ok(await screen.findByText("사용 중인 Asset은 삭제할 수 없습니다."));
});
// Fix round 1 (Minor 4). The archive success path -- the exact command sent
// to the gateway, the merged list state, and the success notice -- was
// previously verified only by reading the source.
test("archives an asset by sending the expected command and merging the server's response into the list", async () => {
const user = userEvent.setup();
const { gateway, archiveCalls } = recordingGateway({
asset: { ...(ASSET as object), usageCount: 1 },
usages: [{ documentId: "d", documentKind: "CASE", title: "사용 중 문서", published: true }],
hasPublicationHistory: true,
});
render(<AssetLibrary gateway={gateway} />);
const row = (await screen.findByRole("button", { name: "boundary" })).closest("li");
assert.ok(row);
await user.click(await screen.findByRole("button", { name: "boundary" }));
await user.click(await screen.findByRole("button", { name: "보관" }));
assert.ok(await screen.findByText("보관했습니다."));
assert.equal(archiveCalls.length, 1);
assert.equal(archiveCalls[0].assetId, ASSET_ID);
assert.deepEqual(archiveCalls[0].command, {
expectedVersion: ASSET_VERSION,
managementStatus: "ARCHIVED",
});
const options = archiveCalls[0].options as { idempotencyKey?: unknown };
assert.equal(typeof options.idempotencyKey, "string");
assert.ok((options.idempotencyKey as string).length > 0);
// The row reflects the server's returned asset (now ARCHIVED), not a
// client-guessed status -- proving the list state actually merged the
// response instead of just leaving the old row in place. `findByText`
// above already settled on the post-update render, so this can assert
// directly instead of polling.
assert.ok(row.textContent?.includes("ARCHIVED"));
});
// Fix round 1 (I1 + Minor 4). Combines the delete success path's gateway
// call shape with the focus-restoration regression this round fixed: the
// row that opened the panel is removed from the DOM by a successful delete,
// so restoring focus to it (the pre-fix behaviour) is a silent no-op that
// strands focus on <body>. This test pins the fix -- focus must land on the
// page heading, the one anchor guaranteed to still exist -- so a future
// regression back to the removed trigger fails loudly here.
test("removes the asset and restores focus to the page heading, not the detached row button, after a successful delete", async () => {
const user = userEvent.setup();
const { gateway, deleteCalls } = recordingGateway({
asset: ASSET,
usages: [],
hasPublicationHistory: false,
});
render(<AssetLibrary gateway={gateway} />);
await user.click(await screen.findByRole("button", { name: "boundary" }));
await user.click(await screen.findByRole("button", { name: "삭제" }));
assert.ok(await screen.findByText("삭제했습니다."));
assert.equal(deleteCalls.length, 1);
assert.equal(deleteCalls[0].assetId, ASSET_ID);
const options = deleteCalls[0].options as { idempotencyKey?: unknown };
assert.equal(typeof options.idempotencyKey, "string");
assert.ok((options.idempotencyKey as string).length > 0);
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
// `findByText` only resolves after yielding through at least one macrotask
// (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 });
expect(heading).toHaveFocus();
});
// --- Alignment follow-up, item 1: the management screen gets search and paging ---
//
// `listAssets` has always accepted `q`/`cursor` and returned `nextCursor`, and
// this screen's own heading promises search ("업로드한 Asset을 검색하고..."),
// but the component asked for `{ limit: 50 }` and dropped `nextCursor` on the
// floor. Past the 51st asset older assets were simply unmanageable, with
// nothing on screen admitting anything had been left out.
type ListCall = Readonly<{ q?: string; cursor?: string; limit?: number }>;
type ListPage = Readonly<{ items: unknown[]; nextCursor: string | null }>;
function assetNamed(assetKey: string, id: string) {
return { ...(ASSET as object), id, assetKey } as never;
}
function libraryGateway(respond: (query: ListCall) => ListPage) {
const calls: ListCall[] = [];
const gateway = {
async listAssets(query: ListCall) {
calls.push(query);
return respond(query) as never;
},
async getAsset() {
return { asset: ASSET, usages: [], hasPublicationHistory: false } as never;
},
async uploadAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
return ASSET;
},
async deleteAsset() {},
} as never;
return { gateway, calls };
}
test("searches assets with the typed query and announces the new result count", async () => {
const user = userEvent.setup();
const older = assetNamed("older-diagram", "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1");
const newer = assetNamed("newer-diagram", "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2");
const { gateway, calls } = libraryGateway((query) =>
query.q === "older"
? { items: [older], nextCursor: null }
: { items: [newer, older], nextCursor: null },
);
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "newer-diagram" });
await user.type(screen.getByLabelText("Asset 검색"), "older");
await user.click(screen.getByRole("button", { name: "검색" }));
assert.ok(await screen.findByRole("button", { name: "older-diagram" }));
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
// a search box invites.
const counts = screen
.getAllByRole("status")
.map((node) => node.textContent ?? "");
assert.ok(counts.some((text) => text.includes("1개")), counts.join(" | "));
});
test("typing alone never reaches the gateway -- only the submitted query does", async () => {
const user = userEvent.setup();
const { gateway, calls } = libraryGateway(() => ({ items: [ASSET], nextCursor: null }));
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "boundary" });
assert.equal(calls.length, 1);
await user.type(screen.getByLabelText("Asset 검색"), "boundary");
assert.equal(calls.length, 1);
});
test("loads the next page with the server cursor and appends it to the list", async () => {
const user = userEvent.setup();
const first = assetNamed("page-one", "ffffffff-ffff-4fff-8fff-fffffffffff1");
const second = assetNamed("page-two", "ffffffff-ffff-4fff-8fff-fffffffffff2");
const { gateway, calls } = libraryGateway((query) =>
query.cursor === "cursor-1"
? { items: [second], nextCursor: null }
: { items: [first], nextCursor: "cursor-1" },
);
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "page-one" });
await user.click(screen.getByRole("button", { name: "더 보기" }));
assert.ok(await screen.findByRole("button", { name: "page-two" }));
// Appended, not replaced: the first page must stay reachable.
assert.ok(screen.getByRole("button", { name: "page-one" }));
assert.equal(calls.at(-1)?.cursor, "cursor-1");
// Exhausted paging removes the control rather than leaving a button that
// does nothing.
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 () => {
const user = userEvent.setup();
const first = assetNamed("focus-page-one", "10101010-1010-4010-8010-101010101011");
const second = assetNamed("focus-page-two", "10101010-1010-4010-8010-101010101012");
const { gateway } = libraryGateway((query) =>
query.cursor === "cursor-1"
? { items: [second], nextCursor: null }
: { items: [first], nextCursor: "cursor-1" },
);
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "focus-page-one" });
await user.click(screen.getByRole("button", { name: "더 보기" }));
const appended = await screen.findByRole("button", { name: "focus-page-two" });
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" });
expect(screen.queryByRole("button", { name: "더 보기" })).not.toBeInTheDocument();
});
test("a new search restarts paging from the first page instead of carrying the old cursor", async () => {
const user = userEvent.setup();
const first = assetNamed("reset-page-one", "20202020-2020-4020-8020-202020202021");
const second = assetNamed("reset-page-two", "20202020-2020-4020-8020-202020202022");
const found = assetNamed("reset-found", "20202020-2020-4020-8020-202020202023");
const { gateway, calls } = libraryGateway((query) => {
if (query.q === "reset-found") return { items: [found], nextCursor: null };
return query.cursor === "cursor-1"
? { items: [second], nextCursor: null }
: { items: [first], nextCursor: "cursor-1" };
});
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "reset-page-one" });
await user.click(screen.getByRole("button", { name: "더 보기" }));
await screen.findByRole("button", { name: "reset-page-two" });
await user.type(screen.getByLabelText("Asset 검색"), "reset-found");
await user.click(screen.getByRole("button", { name: "검색" }));
assert.ok(await screen.findByRole("button", { name: "reset-found" }));
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);
});
test("a slow earlier search never overwrites the newer search's results", async () => {
const user = userEvent.setup();
const initial = assetNamed("race-initial", "30303030-3030-4030-8030-303030303031");
const slow = assetNamed("race-slow", "30303030-3030-4030-8030-303030303032");
const fast = assetNamed("race-fast", "30303030-3030-4030-8030-303030303033");
const pending: Array<{ query: ListCall; settle: (page: ListPage) => void }> = [];
const gateway = {
async listAssets(query: ListCall) {
return new Promise<ListPage>((resolve) => {
pending.push({ query, settle: resolve });
}) as never;
},
async getAsset() {
return { asset: ASSET, usages: [], hasPublicationHistory: false } as never;
},
async uploadAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
return ASSET;
},
async deleteAsset() {},
} as never;
render(<AssetLibrary gateway={gateway} />);
await waitFor(() => assert.equal(pending.length, 1));
await act(async () => {
pending[0]!.settle({ items: [initial], nextCursor: null });
});
await screen.findByRole("button", { name: "race-initial" });
const input = screen.getByLabelText("Asset 검색");
await user.clear(input);
await user.type(input, "slow");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() => assert.equal(pending.length, 2));
await user.clear(input);
await user.type(input, "fast");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() => assert.equal(pending.length, 3));
await act(async () => {
pending[2]!.settle({ items: [fast], nextCursor: null });
});
await screen.findByRole("button", { name: "race-fast" });
// The abandoned "slow" request lands last. Nothing about it may reach the
// screen: the user is looking at "fast" results.
await act(async () => {
pending[1]!.settle({ items: [slow], nextCursor: null });
});
expect(screen.queryByRole("button", { name: "race-slow" })).not.toBeInTheDocument();
assert.ok(screen.getByRole("button", { name: "race-fast" }));
});
test("a search that matches nothing says so instead of claiming no asset was ever uploaded", async () => {
const user = userEvent.setup();
const { gateway } = libraryGateway((query) =>
query.q ? { items: [], nextCursor: null } : { items: [ASSET], nextCursor: null },
);
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "boundary" });
await user.type(screen.getByLabelText("Asset 검색"), "없는키");
await user.click(screen.getByRole("button", { name: "검색" }));
assert.ok(await screen.findByText("조건에 맞는 Asset이 없습니다"));
expect(screen.queryByText("등록된 Asset이 없습니다")).not.toBeInTheDocument();
});