349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
DEFAULT_MAX_BYTES,
|
|
DEFAULT_MAX_COLUMN,
|
|
DEFAULT_MAX_LINES,
|
|
formatHeadTruncationNotice,
|
|
formatTailTruncationNotice,
|
|
OutputSink,
|
|
TailBuffer,
|
|
truncateHead,
|
|
truncateHeadBytes,
|
|
truncateLine,
|
|
truncateTail,
|
|
truncateTailBytes,
|
|
} from "../src/session/streaming-output";
|
|
|
|
const createdTempDirs: string[] = [];
|
|
const originalForceProtocol = Bun.env.PI_FORCE_IMAGE_PROTOCOL;
|
|
const originalAllowPassthrough = Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH;
|
|
|
|
async function createTempDir(): Promise<string> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "streaming-output-test-"));
|
|
createdTempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function byteLength(text: string): number {
|
|
return Buffer.byteLength(text, "utf-8");
|
|
}
|
|
|
|
afterEach(async () => {
|
|
for (const dir of createdTempDirs.splice(0)) {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
if (originalForceProtocol === undefined) delete Bun.env.PI_FORCE_IMAGE_PROTOCOL;
|
|
else Bun.env.PI_FORCE_IMAGE_PROTOCOL = originalForceProtocol;
|
|
if (originalAllowPassthrough === undefined) delete Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH;
|
|
else Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH = originalAllowPassthrough;
|
|
});
|
|
|
|
describe("streaming-output exports", () => {
|
|
test("exports expected default limits", () => {
|
|
expect(DEFAULT_MAX_LINES).toBe(3000);
|
|
expect(DEFAULT_MAX_BYTES).toBe(50 * 1024);
|
|
expect(DEFAULT_MAX_COLUMN).toBe(1024);
|
|
});
|
|
});
|
|
|
|
describe("truncateTailBytes", () => {
|
|
test("returns source when already under limit", () => {
|
|
const text = "hello";
|
|
expect(truncateTailBytes(text, 10)).toEqual({ text: "hello", bytes: 5 });
|
|
});
|
|
|
|
test("truncates from end without breaking UTF-8 boundaries", () => {
|
|
const text = "a😀b";
|
|
const result = truncateTailBytes(text, 4);
|
|
expect(result).toEqual({ text: "b", bytes: 1 });
|
|
expect(result.text).not.toContain("\uFFFD");
|
|
});
|
|
|
|
test("accepts Uint8Array input", () => {
|
|
const bytes = new TextEncoder().encode("abc😀");
|
|
const result = truncateTailBytes(bytes, 4);
|
|
expect(result.text).toBe("😀");
|
|
expect(result.bytes).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe("truncateHeadBytes", () => {
|
|
test("returns source when already under limit", () => {
|
|
const text = "hello";
|
|
expect(truncateHeadBytes(text, 10)).toEqual({ text: "hello", bytes: 5 });
|
|
});
|
|
|
|
test("truncates from start without breaking UTF-8 boundaries", () => {
|
|
const text = "a😀b";
|
|
const result = truncateHeadBytes(text, 2);
|
|
expect(result).toEqual({ text: "a", bytes: 1 });
|
|
expect(result.text).not.toContain("\uFFFD");
|
|
});
|
|
|
|
test("returns empty when maxBytes is zero", () => {
|
|
const result = truncateHeadBytes("abc", 0);
|
|
expect(result).toEqual({ text: "", bytes: 0 });
|
|
});
|
|
});
|
|
|
|
describe("truncateHead", () => {
|
|
test("returns unmodified content when within limits", () => {
|
|
const content = "a\nb";
|
|
const result = truncateHead(content, { maxLines: 10, maxBytes: 20 });
|
|
expect(result.truncated).toBeUndefined();
|
|
expect(result.content).toBe(content);
|
|
expect(result.truncatedBy).toBeUndefined();
|
|
});
|
|
|
|
test("handles first line exceeding byte limit", () => {
|
|
const result = truncateHead("abcdef\nnext", { maxBytes: 3, maxLines: 10 });
|
|
expect(result.content).toBe("");
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.truncatedBy).toBe("bytes");
|
|
expect(result.firstLineExceedsLimit).toBe(true);
|
|
});
|
|
|
|
test("includes first line when text fits exact byte budget", () => {
|
|
const result = truncateHead("abc\nx", { maxBytes: 3, maxLines: 10 });
|
|
expect(result.content).toBe("abc");
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.truncatedBy).toBe("bytes");
|
|
expect(result.firstLineExceedsLimit).toBe(false);
|
|
expect(result.outputBytes).toBe(byteLength("abc"));
|
|
});
|
|
test("truncates by line count", () => {
|
|
const result = truncateHead("l1\nl2\nl3", { maxLines: 2, maxBytes: 100 });
|
|
expect(result.content).toBe("l1\nl2");
|
|
expect(result.truncatedBy).toBe("lines");
|
|
expect(result.outputLines).toBe(2);
|
|
});
|
|
|
|
test("truncates by byte budget using complete lines", () => {
|
|
const result = truncateHead("12345\nabc\nz", { maxLines: 10, maxBytes: 7 });
|
|
expect(result.content).toBe("12345");
|
|
expect(result.truncatedBy).toBe("bytes");
|
|
expect(result.lastLinePartial).toBe(false);
|
|
expect(result.outputBytes).toBe(byteLength("12345"));
|
|
});
|
|
});
|
|
|
|
describe("truncateTail", () => {
|
|
test("returns unmodified content when within limits", () => {
|
|
const content = "a\nb";
|
|
const result = truncateTail(content, { maxLines: 10, maxBytes: 20 });
|
|
expect(result.truncated).toBeUndefined();
|
|
expect(result.content).toBe(content);
|
|
expect(result.truncatedBy).toBeUndefined();
|
|
});
|
|
|
|
test("truncates by line count", () => {
|
|
const result = truncateTail("l1\nl2\nl3", { maxLines: 2, maxBytes: 100 });
|
|
expect(result.content).toBe("l2\nl3");
|
|
expect(result.truncatedBy).toBe("lines");
|
|
expect(result.outputLines).toBe(2);
|
|
});
|
|
|
|
test("truncates by byte budget while preserving line boundaries", () => {
|
|
const result = truncateTail("aaa\nbbbb\ncc", { maxLines: 10, maxBytes: 6 });
|
|
expect(result.content).toBe("cc");
|
|
expect(result.truncatedBy).toBe("bytes");
|
|
expect(result.lastLinePartial).toBe(false);
|
|
});
|
|
|
|
test("returns partial single line when last line exceeds byte limit", () => {
|
|
const result = truncateTail("abcdefghij", { maxLines: 10, maxBytes: 4 });
|
|
expect(result.content).toBe("ghij");
|
|
expect(result.truncatedBy).toBe("bytes");
|
|
expect(result.lastLinePartial).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("truncateLine", () => {
|
|
test("does not truncate short lines", () => {
|
|
expect(truncateLine("hello", 10)).toEqual({ text: "hello", wasTruncated: false });
|
|
});
|
|
|
|
test("truncates long lines with ellipsis", () => {
|
|
expect(truncateLine("abcdefgh", 5)).toEqual({ text: "abcde…", wasTruncated: true });
|
|
});
|
|
});
|
|
|
|
describe("TailBuffer", () => {
|
|
test("keeps trailing bytes under budget", () => {
|
|
const tail = new TailBuffer(5);
|
|
tail.append("abc");
|
|
tail.append("def");
|
|
expect(tail.text()).toBe("bcdef");
|
|
expect(tail.bytes()).toBe(5);
|
|
});
|
|
|
|
test("handles multibyte data and empty appends", () => {
|
|
const tail = new TailBuffer(4);
|
|
tail.append("");
|
|
tail.append("😀");
|
|
tail.append("x");
|
|
expect(tail.text()).toBe("x");
|
|
expect(tail.bytes()).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("OutputSink", () => {
|
|
test("tracks totals and adds notice in dump", async () => {
|
|
const sink = new OutputSink();
|
|
await sink.push("hello\nworld");
|
|
const dumped = await sink.dump("notice");
|
|
|
|
expect(dumped.output).toBe("[notice]\nhello\nworld");
|
|
expect(dumped.truncated).toBe(false);
|
|
expect(dumped.totalLines).toBe(2);
|
|
expect(dumped.totalBytes).toBe(byteLength("hello\nworld"));
|
|
expect(dumped.outputLines).toBe(2);
|
|
expect(dumped.outputBytes).toBe(byteLength("hello\nworld"));
|
|
});
|
|
|
|
test("counts lines correctly when chunks contain no newlines", async () => {
|
|
const sink = new OutputSink();
|
|
await sink.push("abc");
|
|
await sink.push("def");
|
|
const dumped = await sink.dump();
|
|
|
|
expect(dumped.totalLines).toBe(1);
|
|
expect(dumped.outputLines).toBe(1);
|
|
});
|
|
|
|
test("counts all newline boundaries across chunk splits", async () => {
|
|
const sink = new OutputSink();
|
|
await sink.push("a\n");
|
|
await sink.push("b\n\n");
|
|
await sink.push("c");
|
|
const dumped = await sink.dump();
|
|
|
|
expect(dumped.output).toBe("a\nb\n\nc");
|
|
expect(dumped.totalLines).toBe(4);
|
|
expect(dumped.outputLines).toBe(4);
|
|
});
|
|
test("invokes onChunk callback with sanitized text", async () => {
|
|
const chunks: string[] = [];
|
|
const sink = new OutputSink({ onChunk: chunk => chunks.push(chunk) });
|
|
await sink.push("abc");
|
|
await sink.push("def");
|
|
expect(chunks).toEqual(["abc", "def"]);
|
|
});
|
|
|
|
test("preserves SIXEL chunks when passthrough gates are enabled", async () => {
|
|
const sixel = "\x1bPqabc\x1b\\";
|
|
Bun.env.PI_FORCE_IMAGE_PROTOCOL = "sixel";
|
|
Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH = "1";
|
|
const chunks: string[] = [];
|
|
const sink = new OutputSink({ onChunk: chunk => chunks.push(chunk) });
|
|
await sink.push(`before\n${sixel}\nafter`);
|
|
const dumped = await sink.dump();
|
|
expect(chunks).toHaveLength(1);
|
|
expect(chunks[0]).toContain(sixel);
|
|
expect(dumped.output).toContain(sixel);
|
|
});
|
|
|
|
test("strips SIXEL chunks when passthrough gates are disabled", async () => {
|
|
const sixel = "\x1bPqabc\x1b\\";
|
|
delete Bun.env.PI_FORCE_IMAGE_PROTOCOL;
|
|
delete Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH;
|
|
const sink = new OutputSink();
|
|
await sink.push(sixel);
|
|
const dumped = await sink.dump();
|
|
expect(dumped.output).not.toContain("\x1bPq");
|
|
expect(dumped.output).toBe("");
|
|
});
|
|
|
|
test("truncates in-memory output when spill threshold is exceeded", async () => {
|
|
const sink = new OutputSink({ spillThreshold: 5 });
|
|
await sink.push("abc");
|
|
await sink.push("def");
|
|
|
|
const dumped = await sink.dump();
|
|
expect(dumped.truncated).toBe(true);
|
|
expect(dumped.output).toBe("bcdef");
|
|
expect(dumped.totalBytes).toBe(6);
|
|
expect(dumped.outputBytes).toBe(5);
|
|
});
|
|
|
|
test("spills full output to artifact file when artifact path is provided", async () => {
|
|
const dir = await createTempDir();
|
|
const artifactPath = path.join(dir, "output.log");
|
|
const sink = new OutputSink({
|
|
artifactPath,
|
|
artifactId: "artifact-1",
|
|
spillThreshold: 5,
|
|
});
|
|
|
|
await sink.push("abc");
|
|
await sink.push("def");
|
|
const dumped = await sink.dump();
|
|
const artifactText = await Bun.file(artifactPath).text();
|
|
|
|
expect(dumped.truncated).toBe(true);
|
|
expect(dumped.artifactId).toBe("artifact-1");
|
|
expect(artifactText).toBe("abcdef");
|
|
expect(dumped.output).toBe("bcdef");
|
|
});
|
|
|
|
test("createInput decodes streamed UTF-8 chunks correctly", async () => {
|
|
const sink = new OutputSink();
|
|
const writer = sink.createInput().getWriter();
|
|
const bytes = new TextEncoder().encode("😀X");
|
|
|
|
await writer.write(bytes.subarray(0, 2));
|
|
await writer.write(bytes.subarray(2));
|
|
await writer.close();
|
|
|
|
const dumped = await sink.dump();
|
|
expect(dumped.output).toBe("😀X");
|
|
expect(dumped.totalBytes).toBe(byteLength("😀X"));
|
|
});
|
|
});
|
|
|
|
describe("truncation notice formatting", () => {
|
|
test("formatTailTruncationNotice returns empty string for non-truncated results", () => {
|
|
const truncation = truncateTail("a\nb", { maxLines: 10, maxBytes: 50 });
|
|
expect(formatTailTruncationNotice(truncation)).toBe("");
|
|
});
|
|
|
|
test("formatTailTruncationNotice supports partial-line and complete-line notices", () => {
|
|
const partialLineTruncation = truncateTail("abcdefghij", { maxLines: 10, maxBytes: 4 });
|
|
const partialLineNotice = formatTailTruncationNotice(partialLineTruncation, {
|
|
fullOutputPath: "/tmp/full.log",
|
|
originalContent: "abcdefghij",
|
|
suffix: " [suffix]",
|
|
});
|
|
expect(partialLineNotice).toBe(
|
|
"\n\n[Showing last 4B of line 1 (line is 10B). Full output: /tmp/full.log [suffix]]",
|
|
);
|
|
|
|
const lineTruncation = truncateTail("l1\nl2\nl3", { maxLines: 2, maxBytes: 100 });
|
|
expect(formatTailTruncationNotice(lineTruncation)).toBe("\n\n[Showing lines 2-3 of 3]");
|
|
|
|
const byteTruncation = truncateTail("aaa\nbbbb\ncc", { maxLines: 10, maxBytes: 6 });
|
|
expect(formatTailTruncationNotice(byteTruncation)).toBe("\n\n[Showing lines 3-3 of 3]");
|
|
});
|
|
|
|
test("formatHeadTruncationNotice returns empty string for non-truncated results", () => {
|
|
const truncation = truncateHead("a\nb", { maxLines: 10, maxBytes: 50 });
|
|
expect(formatHeadTruncationNotice(truncation)).toBe("");
|
|
});
|
|
|
|
test("formatHeadTruncationNotice formats head truncation range", () => {
|
|
const lineTruncation = truncateHead("l1\nl2\nl3", { maxLines: 2, maxBytes: 100 });
|
|
expect(formatHeadTruncationNotice(lineTruncation)).toBe("\n\n[Showing lines 1-2 of 3. Use sel=L3 to continue]");
|
|
|
|
const byteTruncation = truncateHead("12345\nabc\nz", { maxLines: 10, maxBytes: 7 });
|
|
expect(
|
|
formatHeadTruncationNotice(byteTruncation, {
|
|
startLine: 100,
|
|
totalFileLines: 500,
|
|
}),
|
|
).toBe("\n\n[Showing lines 100-100 of 500. Use sel=L101 to continue]");
|
|
});
|
|
});
|