Files
reverse-engineering-anything/src/process/capture/TerminalRenderer.test.ts
T
morluto 974487f00f fix(process): preserve cleanup ownership through retries and close
Carry historical cleanup reports with the exact lease that can confirm
release. Bind originating capture errors and typed retry aggregates to
those owners while preserving primary failures and completed observations.

Keep those bindings through MCP close and compose mixed session/process
cleanup failures. Start both close operations even when a child throws a
typed failure synchronously.

Fixes #5144
Fixes #5163
Fixes #5166
2026-10-19 10:52:55 +08:00

230 lines
7.0 KiB
TypeScript

import { createRequire } from "node:module";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { TerminalRenderer } from "./TerminalRenderer.js";
import { ProcessCaptureResourceScope } from "./ProcessCaptureLifecycle.js";
import { createBoundedProcessTextNormalizer } from "./ProcessNormalization.js";
import { parseProcessScenario } from "../../domain/process/processScenario.js";
import type { BoundedProcessTextNormalization } from "./ProcessNormalization.js";
const require = createRequire(import.meta.url);
const { Terminal } =
require("@xterm/headless") as typeof import("@xterm/headless");
const { SerializeAddon } =
require("@xterm/addon-serialize") as typeof import("@xterm/addon-serialize");
const boundedNormalize =
(
normalize: (value: string) => string,
): ((value: string, maxBytes: number) => BoundedProcessTextNormalization) =>
(value, maxBytes) => {
const normalized = normalize(value);
const utf8Bytes = Buffer.byteLength(normalized);
return utf8Bytes <= maxBytes
? { state: "normalized", value: normalized, utf8Bytes }
: { state: "over_budget" };
};
const renderFrames = async (
data: string,
normalize: (value: string) => string = (value) => value,
) => {
const renderer = new TerminalRenderer({
columns: 20,
rows: 4,
scrollback: 10,
maxBytes: 100_000,
normalize: boundedNormalize(normalize),
});
renderer.write(data, 0);
const frames = await renderer.frames();
const retention = renderer.retention();
await renderer.dispose();
return { frames, retention };
};
it("omits trailing blank cells from visible lines without losing rows", async () => {
const { frames, retention } = await renderFrames(
"left right \r\n\r\n indented",
);
const [frame] = frames;
expect(frame?.lines).toEqual(["left right", "", " indented", ""]);
// Fixed-width rows remain recoverable from the recorded column count.
expect(frame?.lines.map((line) => line.padEnd(frame.columns, " "))).toEqual([
"left right ",
" ".repeat(20),
" indented ",
" ".repeat(20),
]);
expect(retention.retained_bytes).toBe(
Buffer.byteLength(frame?.serialized_state ?? "") +
Buffer.byteLength("left right indented"),
);
});
it("applies rendered retention cumulatively across sparse frames", async () => {
const renderer = new TerminalRenderer({
columns: 10,
rows: 1,
scrollback: 0,
maxBytes: 5,
normalize: boundedNormalize((value) => value),
});
try {
renderer.write("a", 0);
renderer.write("b", 1);
const frames = await renderer.frames();
expect(frames.map(({ lines }) => lines)).toEqual([["a"]]);
expect(renderer.retention()).toEqual({
budget_bytes: 5,
observed_bytes: 2,
retained_bytes: 2,
observed_frames: 2,
retained_frames: 1,
unknown_size_frames: 1,
});
expect(renderer.truncated()).toBe(true);
} finally {
await renderer.dispose();
}
});
it("trims only after normalization sees the full-width row", async () => {
const { frames } = await renderFrames("pid 4242", (value) =>
value.replace(/4242 +$/u, "<pid>"),
);
expect(frames[0]?.lines[0]).toBe("pid <pid>");
});
it("omits rendered frames with unknown expanded size and keeps later bounded states", async () => {
const renderer = new TerminalRenderer({
columns: 10,
rows: 1,
scrollback: 0,
maxBytes: 1_000,
normalize: createBoundedProcessTextNormalizer(
parseProcessScenario({
executable: "unused",
normalization: {
paths: false,
pids: false,
ports: false,
patterns: [{ pattern: "a", replacement: "x".repeat(2_000) }],
},
}),
"/tmp",
-1,
),
});
try {
renderer.write("a", 0);
renderer.write("\u001b[2J\rz", 1);
const frames = await renderer.frames();
expect(frames).toHaveLength(1);
expect(frames[0]?.lines[0]).toBe("z");
expect(renderer.retention()).toMatchObject({
observed_frames: 2,
retained_frames: 1,
unknown_size_frames: 1,
});
expect(renderer.truncated()).toBe(true);
} finally {
await renderer.dispose();
}
});
it.each(["write", "resize"] as const)(
"releases terminal resources after a queued %s observation fails",
async (operation) => {
const failure = new Error("normalization failed");
const terminalDispose = vi.spyOn(Terminal.prototype, "dispose");
const addonDispose = vi.spyOn(SerializeAddon.prototype, "dispose");
const renderer = new TerminalRenderer({
columns: 20,
rows: 4,
scrollback: 10,
maxBytes: 100_000,
normalize: boundedNormalize(() => {
throw failure;
}),
});
const scope = new ProcessCaptureResourceScope();
const temporaryRoot = await mkdtemp(
join(tmpdir(), "rea-terminal-cleanup-"),
);
try {
if (operation === "write") renderer.write("output", 0);
else renderer.resize(30, 4, 0);
await expect(renderer.frames()).rejects.toBe(failure);
const cleanup = await scope.release({
timers: new Set(),
terminal: undefined,
renderer,
runId: "failed-observation",
temporaryRoot,
});
expect(cleanup.report.terminal_renderer).toEqual({
state: "cleaned",
reason: null,
});
expect(terminalDispose).toHaveBeenCalledTimes(1);
expect(addonDispose).toHaveBeenCalledTimes(1);
await expect(stat(temporaryRoot)).rejects.toMatchObject({
code: "ENOENT",
});
await expect(scope.run(async () => "next capture")).resolves.toBe(
"next capture",
);
await expect(renderer.frames()).rejects.toBe(failure);
} finally {
await scope.close();
terminalDispose.mockRestore();
addonDispose.mockRestore();
await rm(temporaryRoot, { recursive: true, force: true });
}
},
);
it("retains disposal failures for a later cleanup retry", async () => {
const failure = new Error("terminal disposal failed");
const terminalDispose = vi.spyOn(Terminal.prototype, "dispose");
terminalDispose.mockImplementationOnce(() => {
throw failure;
});
const renderer = new TerminalRenderer({
columns: 20,
rows: 4,
scrollback: 10,
maxBytes: 100_000,
normalize: boundedNormalize((value) => value),
});
const scope = new ProcessCaptureResourceScope();
const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-terminal-retry-"));
try {
const cleanup = await scope.release({
timers: new Set(),
terminal: undefined,
renderer,
runId: "failed-disposal",
temporaryRoot,
});
expect(cleanup.report.terminal_renderer).toEqual({
state: "failed",
reason: failure.message,
});
await expect(scope.run(async () => "next capture")).resolves.toBe(
"next capture",
);
expect(terminalDispose).toHaveBeenCalledTimes(2);
} finally {
await scope.close();
terminalDispose.mockRestore();
await rm(temporaryRoot, { recursive: true, force: true });
}
});