Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
152 lines
5.0 KiB
TypeScript
152 lines
5.0 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts";
|
|
import type { ShellOutputUpdate, ShellOutputView } from "../../src/harness/types.ts";
|
|
import { applyShellOutputUpdate, OutputCapture, sanitizeShellOutput } from "../../src/harness/utils/output-capture.ts";
|
|
|
|
function createCapture(options?: { maxBytes?: number; maxLines?: number; retain?: "head" | "tail" }) {
|
|
const updates: ShellOutputUpdate[] = [];
|
|
const errors: unknown[] = [];
|
|
const capture = new OutputCapture(
|
|
{
|
|
limits: {
|
|
maxBytes: options?.maxBytes ?? 50,
|
|
maxLines: options?.maxLines ?? 100,
|
|
retain: options?.retain ?? "tail",
|
|
},
|
|
},
|
|
BACKGROUND_CONTEXT,
|
|
{
|
|
onUpdate: (update) => updates.push(update),
|
|
onError: (error) => errors.push(error),
|
|
},
|
|
);
|
|
return { capture, updates, errors };
|
|
}
|
|
|
|
function fold(updates: ShellOutputUpdate[]): ShellOutputView | undefined {
|
|
let output: ShellOutputView | undefined;
|
|
for (const update of updates) output = applyShellOutputUpdate(output, update);
|
|
return output;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("OutputCapture", () => {
|
|
it("removes invalid control characters without changing text or line boundaries", () => {
|
|
const input = "a\0b\tc\nd\re\u0007f\ufff9g\ufffbh😀";
|
|
expect(sanitizeShellOutput(input)).toBe("ab\tc\ndefgh😀");
|
|
const { capture } = createCapture();
|
|
capture.push(input);
|
|
expect(capture.snapshot().text).toBe("ab\tc\ndefgh😀");
|
|
});
|
|
it("decodes UTF-8 split across raw process chunks", () => {
|
|
const { capture } = createCapture();
|
|
const bytes = new TextEncoder().encode("😀");
|
|
capture.push(bytes.subarray(0, 2));
|
|
expect(capture.snapshot().text).toBe("");
|
|
capture.push(bytes.subarray(2));
|
|
capture.finish();
|
|
expect(capture.snapshot().text).toBe("😀");
|
|
});
|
|
|
|
it("publishes the first bounded view immediately and trickling appends responsively", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
const { capture, updates } = createCapture();
|
|
|
|
capture.push("one");
|
|
expect(updates).toHaveLength(1);
|
|
expect(updates[0]?.kind).toBe("replace");
|
|
|
|
vi.advanceTimersByTime(150);
|
|
capture.push(" two");
|
|
expect(updates).toHaveLength(2);
|
|
expect(updates[1]).toMatchObject({ kind: "append", text: " two" });
|
|
expect(fold(updates)?.text).toBe("one two");
|
|
});
|
|
|
|
it("collapses a burst into one trailing update", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
const { capture, updates } = createCapture();
|
|
capture.push("a");
|
|
capture.push("b");
|
|
capture.push("c");
|
|
expect(updates).toHaveLength(1);
|
|
|
|
vi.advanceTimersByTime(100);
|
|
expect(updates).toHaveLength(2);
|
|
expect(updates[1]).toMatchObject({ kind: "append", text: "bc" });
|
|
expect(fold(updates)?.text).toBe("abc");
|
|
});
|
|
|
|
it("publishes a small slide for post-cap trickle", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
const { capture, updates } = createCapture({ maxBytes: 10 });
|
|
capture.push("abcdefghij");
|
|
vi.advanceTimersByTime(150);
|
|
capture.push("k");
|
|
|
|
expect(updates[1]).toMatchObject({ kind: "slide", drop: 1, text: "k" });
|
|
expect(fold(updates)?.text).toBe("bcdefghijk");
|
|
expect(fold(updates)?.truncation.totalBytes).toBe(11);
|
|
});
|
|
|
|
it("keeps the exact byte count for a single line larger than its working buffer", () => {
|
|
vi.useFakeTimers();
|
|
const { capture } = createCapture({ maxBytes: 10 });
|
|
capture.push("x".repeat(100));
|
|
expect(capture.snapshot()).toMatchObject({
|
|
text: "x".repeat(10),
|
|
lastLineBytes: 100,
|
|
truncation: { lastLinePartial: true },
|
|
});
|
|
});
|
|
|
|
it("uses a cap-bounded replacement after complete turnover", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
const { capture, updates } = createCapture({ maxBytes: 10 });
|
|
capture.push("abcdefghij");
|
|
capture.push("x".repeat(100));
|
|
vi.advanceTimersByTime(100);
|
|
|
|
expect(updates[1]?.kind).toBe("replace");
|
|
expect(fold(updates)?.text).toHaveLength(10);
|
|
expect(fold(updates)?.truncation.totalBytes).toBe(110);
|
|
});
|
|
|
|
it("forces held state and cancels its trailing timer on dispose", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
const { capture, updates } = createCapture();
|
|
capture.push("a");
|
|
capture.push("b");
|
|
capture.flush();
|
|
expect(fold(updates)?.text).toBe("ab");
|
|
capture.dispose();
|
|
vi.advanceTimersByTime(1_000);
|
|
expect(updates).toHaveLength(2);
|
|
});
|
|
|
|
it("preserves the original head after its raw guard is crossed", () => {
|
|
vi.useFakeTimers();
|
|
const { capture } = createCapture({ maxBytes: 100, maxLines: 2, retain: "head" });
|
|
capture.push(`first\nsecond\n${"tail".repeat(100)}`);
|
|
expect(capture.snapshot().text).toBe("first\nsecond");
|
|
});
|
|
|
|
it("publishes spill metadata without resending text", () => {
|
|
vi.useFakeTimers();
|
|
const { capture, updates, errors } = createCapture();
|
|
capture.push("output");
|
|
capture.setSpillPath("/tmp/output.log");
|
|
expect(updates.at(-1)).toMatchObject({ kind: "metadata", metadata: { spillPath: "/tmp/output.log" } });
|
|
expect(fold(updates)?.spillPath).toBe("/tmp/output.log");
|
|
expect(errors).toEqual([]);
|
|
});
|
|
});
|