170 lines
5.3 KiB
TypeScript
170 lines
5.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { truncateHead, truncateTail } from "../../src/harness/utils/truncate.ts";
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
function byteLength(content: string): number {
|
|
return encoder.encode(content).length;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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 result = truncateTail(input, { maxBytes, maxLines: 10 });
|
|
const expected = bufferTail(input, maxBytes);
|
|
if (result.content !== expected) {
|
|
throw new Error(
|
|
`tail mismatch input=${JSON.stringify(input)} maxBytes=${maxBytes} expected=${JSON.stringify(expected)} actual=${JSON.stringify(result.content)}`,
|
|
);
|
|
}
|
|
const outputBytes = Buffer.byteLength(result.content, "utf8");
|
|
if (outputBytes > maxBytes) {
|
|
throw new Error(
|
|
`tail output exceeded byte limit input=${JSON.stringify(input)} maxBytes=${maxBytes} outputBytes=${outputBytes}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function sampledByteLimits(input: string): number[] {
|
|
const totalBytes = Buffer.byteLength(input, "utf8");
|
|
const candidates = [
|
|
0,
|
|
1,
|
|
2,
|
|
3,
|
|
4,
|
|
5,
|
|
8,
|
|
Math.floor(totalBytes / 2) - 1,
|
|
Math.floor(totalBytes / 2),
|
|
Math.floor(totalBytes / 2) + 1,
|
|
totalBytes - 8,
|
|
totalBytes - 5,
|
|
totalBytes - 4,
|
|
totalBytes - 3,
|
|
totalBytes - 2,
|
|
totalBytes - 1,
|
|
totalBytes,
|
|
totalBytes + 1,
|
|
totalBytes + 4,
|
|
];
|
|
return [...new Set(candidates.filter((value) => value >= 0))].sort((a, b) => a - b);
|
|
}
|
|
|
|
describe("truncate utilities", () => {
|
|
it("counts UTF-8 bytes without Node Buffer", () => {
|
|
const content = "aé🙂\nb";
|
|
const result = truncateHead(content, { maxBytes: 100, maxLines: 10 });
|
|
|
|
expect(result.truncated).toBe(false);
|
|
expect(result.totalBytes).toBe(byteLength(content));
|
|
expect(result.outputBytes).toBe(byteLength(content));
|
|
expect(result.totalBytes).toBe(9);
|
|
});
|
|
|
|
it("truncates head on UTF-8 byte limits without partial lines", () => {
|
|
const content = "éé\nabc";
|
|
const result = truncateHead(content, { maxBytes: 4, maxLines: 10 });
|
|
|
|
expect(result.content).toBe("éé");
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.truncatedBy).toBe("bytes");
|
|
expect(result.outputBytes).toBe(4);
|
|
expect(result.firstLineExceedsLimit).toBe(false);
|
|
});
|
|
|
|
it("reports head truncation when the first line exceeds the byte limit", () => {
|
|
const result = truncateHead("éé\nabc", { maxBytes: 3, maxLines: 10 });
|
|
|
|
expect(result.content).toBe("");
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.truncatedBy).toBe("bytes");
|
|
expect(result.firstLineExceedsLimit).toBe(true);
|
|
});
|
|
|
|
it("truncates tail on UTF-8 boundaries when only a partial last line fits", () => {
|
|
const result = truncateTail("aé🙂b", { maxBytes: 5, maxLines: 10 });
|
|
|
|
expect(result.content).toBe("🙂b");
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.truncatedBy).toBe("bytes");
|
|
expect(result.lastLinePartial).toBe(true);
|
|
expect(result.outputBytes).toBe(5);
|
|
});
|
|
|
|
it("truncates an oversized single line with a trailing newline", () => {
|
|
const input = `${"X".repeat(300_000)}\n`;
|
|
const result = truncateTail(input, { maxBytes: 1024, maxLines: 100 });
|
|
|
|
expect(result.content).toBe("X".repeat(1024));
|
|
expect(result.outputBytes).toBe(1024);
|
|
expect(result.outputLines).toBe(1);
|
|
expect(result.lastLinePartial).toBe(true);
|
|
expect(result.truncatedBy).toBe("bytes");
|
|
});
|
|
|
|
it("drops an oversized trailing character when it cannot fit in tail byte limit", () => {
|
|
const result = truncateTail("abc🙂", { maxBytes: 3, maxLines: 10 });
|
|
|
|
expect(result.content).toBe("");
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.truncatedBy).toBe("bytes");
|
|
expect(result.lastLinePartial).toBe(true);
|
|
expect(result.outputBytes).toBe(0);
|
|
});
|
|
|
|
it("matches Buffer tail truncation semantics for surrogate edge cases", () => {
|
|
const inputs = ["a\ud83d", "\ude42b", "a\ude42b", "\ud83d\ud83d\ude42", "\ud83d\ude42\ude42", "👩💻"];
|
|
for (const input of inputs) assertMatchesBufferTail(input);
|
|
});
|
|
|
|
it("matches Buffer tail truncation semantics 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;
|
|
function 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));
|
|
}
|
|
});
|
|
});
|