Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
277 lines
9.9 KiB
TypeScript
277 lines
9.9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import type { Terminal as XTerm } from "@xterm/xterm";
|
|
|
|
import {
|
|
getTerminalOutputPressure,
|
|
isTerminalScrollbackSaturated,
|
|
noteTerminalOutputPressureData,
|
|
resetTerminalOutputPressure,
|
|
setTerminalOutputPressureVisibility,
|
|
shouldDegradeTerminalSideWork,
|
|
shouldDegradeTerminalKeywordHighlight,
|
|
shouldSkipTerminalLineTimestamps,
|
|
} from "./terminalOutputPressure.ts";
|
|
import { TERMINAL_LONG_LINE_PRESSURE_BYTES } from "./terminalFlowConstants.ts";
|
|
import { XTERM_PERFORMANCE_CONFIG } from "../../../infrastructure/config/xtermPerformance.ts";
|
|
|
|
const createFakeTerm = (overrides: Record<string, unknown> = {}) => ({
|
|
rows: 24,
|
|
options: { scrollback: 1000 },
|
|
buffer: { active: { length: 10, baseY: 0 } },
|
|
...overrides,
|
|
}) as unknown as XTerm;
|
|
|
|
test("tracks long unbroken terminal output pressure until a line break arrives", () => {
|
|
const term = createFakeTerm();
|
|
|
|
noteTerminalOutputPressureData(term, "x".repeat(TERMINAL_LONG_LINE_PRESSURE_BYTES - 1));
|
|
assert.equal(getTerminalOutputPressure(term).longLine, false);
|
|
|
|
noteTerminalOutputPressureData(term, "x");
|
|
assert.equal(getTerminalOutputPressure(term).longLine, true);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "long-line");
|
|
|
|
noteTerminalOutputPressureData(term, "\nshort");
|
|
assert.equal(getTerminalOutputPressure(term).longLine, false);
|
|
// Crossing the long-line threshold also arms the high-rate large-output window.
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
|
|
|
|
resetTerminalOutputPressure(term);
|
|
});
|
|
|
|
test("reports newline-terminated long terminal lines as long-line pressure", () => {
|
|
const term = createFakeTerm();
|
|
|
|
noteTerminalOutputPressureData(term, `${"x".repeat(TERMINAL_LONG_LINE_PRESSURE_BYTES)}\n`);
|
|
assert.equal(getTerminalOutputPressure(term).longLine, true);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "long-line");
|
|
assert.equal(getTerminalOutputPressure(term).consecutiveUnbrokenBytes, 0);
|
|
|
|
noteTerminalOutputPressureData(term, "short\n");
|
|
assert.equal(getTerminalOutputPressure(term).longLine, false);
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
|
|
|
|
resetTerminalOutputPressure(term);
|
|
});
|
|
|
|
test("reports background pressure separately from output volume", () => {
|
|
const term = createFakeTerm();
|
|
|
|
setTerminalOutputPressureVisibility(term, false);
|
|
assert.equal(getTerminalOutputPressure(term).background, true);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "background");
|
|
assert.equal(shouldDegradeTerminalSideWork(term), false);
|
|
|
|
setTerminalOutputPressureVisibility(term, true);
|
|
assert.equal(getTerminalOutputPressure(term).background, false);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "normal");
|
|
|
|
resetTerminalOutputPressure(term);
|
|
});
|
|
|
|
test("keeps large-output pressure through small input echoes until output is quiet", () => {
|
|
const term = createFakeTerm();
|
|
const originalNow = performance.now.bind(performance);
|
|
let now = 1_000;
|
|
|
|
Object.defineProperty(performance, "now", {
|
|
configurable: true,
|
|
value: () => now,
|
|
});
|
|
|
|
try {
|
|
noteTerminalOutputPressureData(term, "x\n".repeat(Math.ceil(TERMINAL_LONG_LINE_PRESSURE_BYTES / 2)));
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
|
|
|
|
now += 16;
|
|
noteTerminalOutputPressureData(term, "a");
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
|
|
|
|
now += XTERM_PERFORMANCE_CONFIG.highlighting.largeOutputQuietMs + 1;
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, false);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "normal");
|
|
} finally {
|
|
Object.defineProperty(performance, "now", {
|
|
configurable: true,
|
|
value: originalNow,
|
|
});
|
|
resetTerminalOutputPressure(term);
|
|
}
|
|
});
|
|
|
|
test("detects large-output pressure from high-rate small chunks", () => {
|
|
const term = createFakeTerm();
|
|
const originalNow = performance.now.bind(performance);
|
|
let now = 5_000;
|
|
|
|
Object.defineProperty(performance, "now", {
|
|
configurable: true,
|
|
value: () => now,
|
|
});
|
|
|
|
try {
|
|
// ~16KB of short lines inside the 100ms rate window (not one unbroken run).
|
|
// Threshold is intentionally below one Tabby-sized xterm shard.
|
|
for (let index = 0; index < 16; index += 1) {
|
|
noteTerminalOutputPressureData(term, `${"y".repeat(1023)}\n`);
|
|
}
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
|
|
assert.equal(getTerminalOutputPressure(term).longLine, false);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
|
|
} finally {
|
|
Object.defineProperty(performance, "now", {
|
|
configurable: true,
|
|
value: originalNow,
|
|
});
|
|
resetTerminalOutputPressure(term);
|
|
}
|
|
});
|
|
|
|
test("arms large-output early on multi-line writes when scrollback is saturated", () => {
|
|
// rows(24) + scrollback(1000) = 1024 max; length near cap → second-seq path.
|
|
const term = createFakeTerm({
|
|
rows: 24,
|
|
options: { scrollback: 1000 },
|
|
buffer: { active: { length: 1020, baseY: 996 } },
|
|
});
|
|
const originalNow = performance.now.bind(performance);
|
|
let now = 9_000;
|
|
|
|
Object.defineProperty(performance, "now", {
|
|
configurable: true,
|
|
value: () => now,
|
|
});
|
|
|
|
try {
|
|
assert.equal(isTerminalScrollbackSaturated(term), true);
|
|
// Far below the long-line / 16KB rate thresholds — only saturated multi-line
|
|
// arming should trip bulk mode (second `seq` cold start).
|
|
noteTerminalOutputPressureData(term, "1\n2\n3\n4\n5\n");
|
|
assert.equal(getTerminalOutputPressure(term).scrollbackSaturated, true);
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
|
|
assert.equal(shouldDegradeTerminalSideWork(term), true);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
|
|
|
|
// Quiet window is extended while saturated so prompt-echo gaps do not
|
|
// reopen the expensive timestamp/highlight path before a second dump.
|
|
now += XTERM_PERFORMANCE_CONFIG.highlighting.largeOutputQuietMs + 1;
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
|
|
now += XTERM_PERFORMANCE_CONFIG.highlighting.largeOutputQuietMs + 1;
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, false);
|
|
} finally {
|
|
Object.defineProperty(performance, "now", {
|
|
configurable: true,
|
|
value: originalNow,
|
|
});
|
|
resetTerminalOutputPressure(term);
|
|
}
|
|
});
|
|
|
|
test("a one-line prompt does not degrade keyword coloring on saturated scrollback", () => {
|
|
const term = createFakeTerm({
|
|
rows: 24,
|
|
options: { scrollback: 1000 },
|
|
buffer: { active: { length: 1020, baseY: 996 } },
|
|
});
|
|
const prompt = "\r\nplain prompt # ";
|
|
|
|
noteTerminalOutputPressureData(term, prompt);
|
|
|
|
assert.equal(shouldDegradeTerminalSideWork(term), true);
|
|
assert.equal(shouldDegradeTerminalKeywordHighlight(term, prompt), false);
|
|
resetTerminalOutputPressure(term);
|
|
});
|
|
|
|
test("does not treat an empty buffer as scrollback-saturated", () => {
|
|
const term = createFakeTerm({
|
|
rows: 24,
|
|
options: { scrollback: 1000 },
|
|
buffer: { active: { length: 12, baseY: 0 } },
|
|
});
|
|
assert.equal(isTerminalScrollbackSaturated(term), false);
|
|
noteTerminalOutputPressureData(term, "1\n2\n3\n");
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, false);
|
|
assert.equal(getTerminalOutputPressure(term).scrollbackSaturated, false);
|
|
resetTerminalOutputPressure(term);
|
|
});
|
|
|
|
test("saturated multi-line degrades side work but keeps line timestamps", () => {
|
|
const term = createFakeTerm({
|
|
rows: 24,
|
|
options: { scrollback: 1000 },
|
|
buffer: { active: { length: 1020, baseY: 996 } },
|
|
});
|
|
|
|
// docker-ps-sized multi-line on a full scrollback: highlight/prep may degrade,
|
|
// but per-line gutter timestamps must still stamp.
|
|
noteTerminalOutputPressureData(term, "CONTAINER ID IMAGE\n".repeat(20));
|
|
assert.equal(shouldDegradeTerminalSideWork(term), true);
|
|
assert.equal(
|
|
shouldDegradeTerminalKeywordHighlight(term, "CONTAINER ID IMAGE\n".repeat(20)),
|
|
false,
|
|
);
|
|
assert.equal(shouldSkipTerminalLineTimestamps(term), false);
|
|
|
|
resetTerminalOutputPressure(term);
|
|
});
|
|
|
|
test("true flood rate skips line timestamps", () => {
|
|
const term = createFakeTerm();
|
|
const originalNow = performance.now.bind(performance);
|
|
let now = 20_000;
|
|
|
|
Object.defineProperty(performance, "now", {
|
|
configurable: true,
|
|
value: () => now,
|
|
});
|
|
|
|
try {
|
|
// 64KB+ inside the rate window → timestamp flood gate.
|
|
for (let index = 0; index < 64; index += 1) {
|
|
noteTerminalOutputPressureData(term, `${"z".repeat(1023)}\n`);
|
|
}
|
|
assert.equal(shouldSkipTerminalLineTimestamps(term), true);
|
|
assert.equal(shouldDegradeTerminalSideWork(term), true);
|
|
} finally {
|
|
Object.defineProperty(performance, "now", {
|
|
configurable: true,
|
|
value: originalNow,
|
|
});
|
|
resetTerminalOutputPressure(term);
|
|
}
|
|
});
|
|
|
|
test("rate detector uses a true rolling window across early tiny samples", () => {
|
|
const term = createFakeTerm();
|
|
const originalNow = performance.now.bind(performance);
|
|
let now = 1;
|
|
|
|
Object.defineProperty(performance, "now", {
|
|
configurable: true,
|
|
value: () => now,
|
|
});
|
|
|
|
try {
|
|
noteTerminalOutputPressureData(term, "x\n");
|
|
now = 60;
|
|
noteTerminalOutputPressureData(term, `${"x".repeat(40 * 1024)}\n`);
|
|
now = 102;
|
|
noteTerminalOutputPressureData(term, `${"x".repeat(40 * 1024)}\n`);
|
|
// 80KB arrived within 42ms; the tiny sample at t=1 should age out, not reset the window.
|
|
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
|
|
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
|
|
} finally {
|
|
Object.defineProperty(performance, "now", {
|
|
configurable: true,
|
|
value: originalNow,
|
|
});
|
|
resetTerminalOutputPressure(term);
|
|
}
|
|
});
|