Files
dev/third_party/pi/packages/chord/test/output.test.ts
alexion 6081e3e325 chore(vendor): import pi 1.3.0
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: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

312 lines
11 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { boundOutput, OutputBuffer, type OutputLimits, Progress, sanitizeOutput } from "../src/output.ts";
const head = (maxLines: number, maxBytes = 1000): OutputLimits => ({ maxBytes, maxLines, retain: "head" });
const tail = (maxLines: number, maxBytes = 1000): OutputLimits => ({ maxBytes, maxLines, retain: "tail" });
function bufferTail(content: string, maxBytes: number): string {
const bytes = Buffer.from(content, "utf8");
if (bytes.length <= maxBytes) return content;
let start = bytes.length - maxBytes;
while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start++;
return bytes.subarray(start).toString("utf8");
}
/** A single line longer than the byte limit is cut like Buffer's tail slice on a character boundary. */
function assertMatchesBufferTail(input: string, maxByteValues?: readonly number[]): void {
const totalBytes = Buffer.byteLength(input, "utf8");
const values = maxByteValues ?? Array.from({ length: totalBytes + 5 }, (_, maxBytes) => maxBytes);
for (const maxBytes of values) {
const kept = boundOutput(input, { maxBytes, maxLines: 10, retain: "tail" }).text;
const expected = bufferTail(input, maxBytes);
if (kept !== expected) {
throw new Error(
`tail mismatch input=${JSON.stringify(input)} maxBytes=${maxBytes} expected=${JSON.stringify(expected)} actual=${JSON.stringify(kept)}`,
);
}
if (Buffer.byteLength(kept, "utf8") > maxBytes) throw new Error(`tail output exceeded ${maxBytes} bytes`);
}
}
function sampledByteLimits(input: string): number[] {
const totalBytes = Buffer.byteLength(input, "utf8");
const candidates = [
0,
1,
2,
3,
4,
5,
8,
Math.floor(totalBytes / 2),
totalBytes - 4,
totalBytes - 1,
totalBytes,
totalBytes + 1,
];
return [...new Set(candidates.filter((value) => value >= 0))].sort((a, b) => a - b);
}
function bound(text: string, limits: OutputLimits) {
const { text: kept, droppedBytes, droppedLines } = boundOutput(text, limits);
return { kept, droppedBytes, droppedLines };
}
describe("tool output bounds", () => {
it("removes control characters but keeps tabs, newlines, and other text", () => {
expect(sanitizeOutput("a\0b\tc\nd\re\u0007f\ufff9g\ufffbh😀")).toBe("ab\tc\ndefgh😀");
});
it("keeps output within the limits unchanged", () => {
expect(bound("a\nb\n", head(2))).toEqual({ kept: "a\nb\n", droppedBytes: 0, droppedLines: 0 });
expect(bound("a\nb", tail(2))).toEqual({ kept: "a\nb", droppedBytes: 0, droppedLines: 0 });
expect(bound("", tail(2))).toEqual({ kept: "", droppedBytes: 0, droppedLines: 0 });
});
it("keeps nothing with a zero limit", () => {
expect(bound("ab\ncd\n", head(10, 0))).toEqual({ kept: "", droppedBytes: 6, droppedLines: 2 });
expect(bound("ab\ncd\n", tail(0))).toEqual({ kept: "", droppedBytes: 6, droppedLines: 2 });
});
it("keeps exact slices of whole lines, trailing newline included", () => {
expect(bound("a\nb\nc\n", head(2))).toEqual({ kept: "a\nb\n", droppedBytes: 2, droppedLines: 1 });
expect(bound("a\nb\nc\n", tail(2))).toEqual({ kept: "b\nc\n", droppedBytes: 2, droppedLines: 1 });
expect(bound("a\nb\nc", tail(2))).toEqual({ kept: "b\nc", droppedBytes: 2, droppedLines: 1 });
// Blank lines are lines.
expect(bound("a\nb\nc\n\n", tail(3))).toEqual({ kept: "b\nc\n\n", droppedBytes: 2, droppedLines: 1 });
});
it("cuts at the byte limit on whole lines when possible", () => {
expect(bound("aa\nbb\ncc\n", head(10, 7))).toEqual({ kept: "aa\nbb\n", droppedBytes: 3, droppedLines: 1 });
expect(bound("aa\nbb\ncc\n", tail(10, 7))).toEqual({ kept: "bb\ncc\n", droppedBytes: 3, droppedLines: 1 });
});
it("cuts a single line longer than the byte limit on a character boundary", () => {
// "é" is two bytes; five bytes hold two whole characters.
expect(bound("ééé\n", head(10, 5))).toEqual({ kept: "éé", droppedBytes: 3, droppedLines: 0 });
expect(bound("x\néééé", tail(10, 5))).toEqual({ kept: "éé", droppedBytes: 6, droppedLines: 1 });
});
it("keeps a U+FEFF at the start of a kept slice", () => {
expect(bound("x\ufeffa", tail(10, 4))).toEqual({ kept: "\ufeffa", droppedBytes: 1, droppedLines: 0 });
});
it("cuts tails of surrogate edge cases exactly like Buffer", () => {
const inputs = ["a\ud83d", "\ude42b", "a\ude42b", "\ud83d\ud83d\ude42", "\ud83d\ude42\ude42", "👩‍💻"];
for (const input of inputs) assertMatchesBufferTail(input);
});
it("cuts tails exactly like Buffer across deterministic fuzz cases", () => {
const alphabet = [
"a",
"\u007f",
"\u0080",
"é",
"\u07ff",
"\u0800",
"中",
"\ud7ff",
"\ud800",
"\ud83d",
"\udc00",
"\ude42",
"🙂",
"\ue000",
"\uffff",
];
function checkExhaustive(prefix: string, depth: number): void {
assertMatchesBufferTail(prefix, sampledByteLimits(prefix));
if (depth === 0) return;
for (const character of alphabet) checkExhaustive(prefix + character, depth - 1);
}
checkExhaustive("", 3);
let seed = 0x12345678;
const random = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 0x100000000;
};
for (let i = 0; i < 1_000; i++) {
let input = "";
const length = Math.floor(random() * 80);
for (let j = 0; j < length; j++) input += alphabet[Math.floor(random() * alphabet.length)];
assertMatchesBufferTail(input, sampledByteLimits(input));
}
});
});
describe("OutputBuffer", () => {
it("keeps exact totals across chunks and decodes UTF-8 split across byte chunks", () => {
const buffer = new OutputBuffer(tail(2));
const bytes = new TextEncoder().encode("😀\n");
buffer.push("a\nb\n");
buffer.push(bytes.subarray(0, 2));
buffer.push(bytes.subarray(2));
expect(buffer.snapshot()).toEqual({ text: "b\n😀\n", droppedBytes: 2, droppedLines: 1 });
});
it("sanitizes the retained text but counts the raw stream", () => {
const buffer = new OutputBuffer(tail(1));
buffer.push("a\u0007\n");
expect(buffer.snapshot()).toEqual({ text: "a\n", droppedBytes: 0, droppedLines: 0 });
buffer.push("b\u001b\n");
expect(buffer.snapshot()).toEqual({ text: "b\n", droppedBytes: 3, droppedLines: 1 });
});
it("drops a byte-order mark only at the start of the output", () => {
const bom = Uint8Array.of(0xef, 0xbb, 0xbf);
const buffer = new OutputBuffer(tail(10));
buffer.push(bom.subarray(0, 1));
buffer.push(Uint8Array.of(0xbb, 0xbf, 0x61));
buffer.push("b");
// A U+FEFF after a string chunk is text.
buffer.push(Uint8Array.of(...bom, 0x63));
buffer.end();
expect(buffer.snapshot().text).toBe("ab\ufeffc");
});
it("flushes an incomplete character before a string chunk and at the end", () => {
const buffer = new OutputBuffer(tail(10));
const euro = new TextEncoder().encode("€");
buffer.push(euro.subarray(0, 1));
buffer.push("x");
buffer.push(euro.subarray(0, 2));
buffer.end();
expect(buffer.snapshot().text).toBe("\ufffdx\ufffd");
});
it("matches bounding the whole stream when several chunks arrive between snapshots", () => {
for (const limits of [head(3, 40), tail(3, 40), head(50, 25), tail(50, 25)]) {
const buffer = new OutputBuffer(limits);
let stream = "";
for (let index = 0; index < 300; index++) {
const chunk = index % 7 === 0 ? `${"é".repeat(index % 30)}\n` : `line ${index}\n`;
stream += chunk;
buffer.push(chunk);
if (index % 5 !== 4) continue;
const expected = boundOutput(stream, limits);
expect(buffer.snapshot()).toEqual({
text: expected.text,
droppedBytes: expected.droppedBytes,
droppedLines: expected.droppedLines,
});
}
}
});
it("stops storing head output once the window is full", () => {
const buffer = new OutputBuffer(head(2));
for (let index = 0; index < 1000; index++) buffer.push(`line ${index}\n`);
expect(buffer.storedBytes).toBeLessThan(20);
expect(buffer.snapshot()).toEqual({ text: "line 0\nline 1\n", droppedBytes: 8876, droppedLines: 998 });
});
it("stores only the tail window after each snapshot", () => {
const buffer = new OutputBuffer(tail(3, 100));
let stream = "";
for (let index = 0; index < 2000; index++) {
const chunk = `line ${index}\n\n`;
stream += chunk;
buffer.push(chunk);
const snapshot = buffer.snapshot();
expect(buffer.storedBytes).toBeLessThanOrEqual(100);
expect(snapshot.text).toBe(boundOutput(stream, tail(3, 100)).text);
}
});
});
describe("Progress", () => {
afterEach(() => {
vi.useRealTimers();
});
it("commits the first change at once, then waits at least 100 ms and the written size at 100 KiB/s", async () => {
vi.useFakeTimers({ now: 0 });
const commits: number[] = [];
let size = 50 * 1024;
const progress = new Progress(
async () => {
commits.push(Date.now());
return size;
},
() => {},
100,
);
progress.mark();
await vi.advanceTimersByTimeAsync(0);
expect(commits).toEqual([0]);
// 50 KiB buys 500 ms; changes meanwhile coalesce into one commit.
size = 10;
progress.mark();
progress.mark();
await vi.advanceTimersByTimeAsync(499);
expect(commits).toEqual([0]);
await vi.advanceTimersByTimeAsync(1);
expect(commits).toEqual([0, 500]);
// A small commit still waits the minimum 100 ms.
progress.mark();
await vi.advanceTimersByTimeAsync(99);
expect(commits).toEqual([0, 500]);
await vi.advanceTimersByTimeAsync(1);
expect(commits).toEqual([0, 500, 600]);
});
it("waits the configured minimum interval between small commits", async () => {
vi.useFakeTimers({ now: 0 });
const commits: number[] = [];
const progress = new Progress(
async () => {
commits.push(Date.now());
return 10;
},
() => {},
500,
);
progress.mark();
await vi.advanceTimersByTimeAsync(0);
progress.mark();
await vi.advanceTimersByTimeAsync(499);
expect(commits).toEqual([0]);
await vi.advanceTimersByTimeAsync(1);
expect(commits).toEqual([0, 500]);
});
it("rejects the waiters of a failed commit and reports its error", async () => {
const errors: unknown[] = [];
const failure = new Error("commit failed");
const progress = new Progress(
async () => {
throw failure;
},
(error) => errors.push(error),
100,
);
await expect(progress.markAndWait()).rejects.toBe(failure);
expect(errors).toEqual([failure]);
});
it("stops: waits for the commit in flight and hands back waiters no commit covered yet", async () => {
vi.useFakeTimers({ now: 0 });
let release!: () => void;
const inFlight = new Promise<void>((resolve) => {
release = resolve;
});
const progress = new Progress(
async () => {
await inFlight;
return 0;
},
() => {},
100,
);
const first = progress.markAndWait();
const second = progress.markAndWait();
const stopped = progress.stop();
release();
const pending = await stopped;
await first;
expect(pending).toHaveLength(1);
pending[0]!.resolve();
await second;
});
});