feat(tui): added replay-based stress-test reduction and CLI runner support

- Added replay parsing and a reducer CLI for op logs and reduced outputs.
- Added chunk-based `reduceFailingOperations` to shrink failure traces while preserving repro cases.
- Added a dedicated `StressRenderScheduler` with immediate and scheduled callback queues.
- Added checkpoint failure dumps to record operation log paths and replay artifacts.
This commit is contained in:
can1357
2026-06-03 22:43:31 +02:00
parent bb01f1420f
commit 8dac5d5790
5 changed files with 482 additions and 256 deletions
+1
View File
@@ -4,6 +4,7 @@
### Added
- Added an injectable render scheduler for TUI tests, allowing deterministic render drains without patching global clocks or event-loop timing.
- Added `ImageBudget`, an inline-image cap that keeps only the most recent N images as live terminal graphics and demotes older ones to their text fallback. Once a new image pushes the count past the cap, the renderer hides the oldest via a full redraw plus an explicit Kitty graphics purge (`a=d,d=I`) — text-clear escapes (`CSI 2 J`/`CSI 3 J`) do not remove Kitty images. Inline images now also carry a stable Kitty image id (`i=`) keyed off the host's `imageKey`, so repaints replace placements instead of stacking duplicate images in the terminal's store. Configure the cap via `TUI#setMaxInlineImages` (`0` disables it).
### Fixed
+310 -256
View File
@@ -1,3 +1,6 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { stripVTControlCharacters } from "node:util";
import { TERMINAL } from "../src/terminal-capabilities";
import {
@@ -7,10 +10,10 @@ import {
type OverlayAnchor,
type OverlayHandle,
type OverlayOptions,
type RenderScheduler,
TUI,
} from "../src/tui";
import { Ellipsis, extractSegments, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth } from "../src/utils";
import { StressRenderScheduler } from "./render-stress-scheduler";
import { VirtualTerminal, type VirtualTerminalWidthModel } from "./virtual-terminal";
const BASE_SEEDS = [
@@ -33,6 +36,15 @@ type TestPlatform = "darwin" | "linux" | "win32";
type TerminalMode = "normal" | "unknown" | "intermittentUnknown" | "staleBottom";
type GeometryMode = "small" | "large";
type EnvMode = "plain" | "tmux" | "termux" | "appleTerminal" | "iterm2" | "wsl" | "vteNoSync" | "ghostty";
export type ScenarioTag =
| "small"
| "large"
| "tmux"
| "strictScrollback"
| "unknownViewport"
| "foregroundStream"
| "ed3Risk"
| "modernWidth";
const ENV_KEYS = [
"TMUX",
"STY",
@@ -55,7 +67,7 @@ type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
type JsonObject = { [key: string]: JsonValue };
type OperationKind =
export type OperationKind =
| "appendSmall"
| "appendExactWidth"
| "appendBulk"
@@ -104,6 +116,61 @@ type OperationKind =
| "reorderChildren"
| "mutateChild";
export const OPERATION_KINDS = [
"appendSmall",
"appendExactWidth",
"appendBulk",
"streamOne",
"editVisibleLine",
"editOffscreenLine",
"offscreenEditAppendRepeatedTail",
"insertOffscreen",
"insertMiddle",
"deleteTrailing",
"deleteMiddle",
"replaceAll",
"toggleCollapsible",
"tickStatusHeader",
"appendRepeatedTail",
"injectBlankCluster",
"appendDuplicateOfExisting",
"highWaterPreviewCollapse",
"eagerStreamingMutation",
"scrollUp",
"scrollToBottom",
"scrollPartial",
"resizeWidth",
"resizeHeight",
"resizeWithAppend",
"forceRender",
"toggleFocusInput",
"moveCursorVisible",
"moveCursorOffscreen",
"showOverlay",
"hideOverlay",
"toggleOverlayHidden",
"editOverlay",
"moveOverlayCursor",
"coalescedBurst",
"rotateUp",
"collapseToFew",
"swapOffscreenRows",
"resizeBoth",
"resizeNoop",
"forceRenderAllowUnknown",
"forceRenderClearScrollback",
"forceRenderAfterEmptyOverflow",
"attachChild",
"detachChild",
"reorderChildren",
"mutateChild",
] as const satisfies readonly OperationKind[];
const OPERATION_KIND_SET = new Set<string>(OPERATION_KINDS);
export function isOperationKind(value: unknown): value is OperationKind {
return typeof value === "string" && OPERATION_KIND_SET.has(value);
}
const BURST_STEP_KINDS = [
"appendSmall",
"streamOne",
@@ -208,6 +275,8 @@ export interface Scenario {
// fixed-line components never produced. Paired with the modern width model so
// the wrap agrees with the terminal's cell widths.
reflow: boolean;
tags: readonly ScenarioTag[];
replayOperations?: readonly OperationKind[];
}
type ViewportProbeTrait = "known" | "unknown" | "intermittentUnknown" | "staleBottom";
@@ -265,6 +334,51 @@ interface AppliedOperation {
reconcilesNativeScrollback?: boolean;
}
type AppliedOperationOverrides = Partial<Omit<AppliedOperation, "kind" | "detail">>;
function appliedOperation(
kind: OperationKind,
detail: JsonObject,
overrides: AppliedOperationOverrides,
): AppliedOperation {
return {
kind,
detail,
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
checkpoint: false,
mutatesViewport: false,
...overrides,
};
}
function contentOperation(
kind: OperationKind,
detail: JsonObject,
checksRowAccounting: boolean,
overrides: AppliedOperationOverrides = {},
): AppliedOperation {
return appliedOperation(kind, detail, { mutatesContent: true, checksRowAccounting, ...overrides });
}
function viewOperation(
kind: OperationKind,
detail: JsonObject,
overrides: AppliedOperationOverrides = {},
): AppliedOperation {
return appliedOperation(kind, detail, overrides);
}
function forceRenderOperation(
kind: OperationKind,
detail: JsonObject,
overrides: AppliedOperationOverrides = {},
): AppliedOperation {
return appliedOperation(kind, detail, { forcedRender: true, ...overrides });
}
type OperationLogKind = OperationKind | "periodicCheckpoint";
interface OperationLogEntry {
@@ -356,52 +470,6 @@ class MutableLinesComponent implements Component {
}
}
class StressRenderScheduler implements RenderScheduler {
#time = 0;
#nextTimerId = 0;
#immediateCallbacks: (() => void)[] = [];
#renderCallbacks = new Map<number, () => void>();
now(): number {
this.#time += 20;
return this.#time;
}
scheduleImmediate(callback: () => void): void {
this.#immediateCallbacks.push(callback);
}
scheduleRender(callback: () => void, _delayMs: number): { cancel(): void } {
const id = this.#nextTimerId;
this.#nextTimerId += 1;
this.#renderCallbacks.set(id, callback);
return {
cancel: () => {
this.#renderCallbacks.delete(id);
},
};
}
async drain(term: VirtualTerminal): Promise<void> {
let rounds = 0;
while (this.#immediateCallbacks.length > 0 || this.#renderCallbacks.size > 0) {
rounds += 1;
if (rounds > 100) {
throw new Error("Render scheduler did not settle after 100 drain rounds");
}
const immediate = this.#immediateCallbacks;
this.#immediateCallbacks = [];
for (const callback of immediate) callback();
if (this.#renderCallbacks.size === 0) continue;
const render = [...this.#renderCallbacks.values()];
this.#renderCallbacks.clear();
for (const callback of render) callback();
}
await term.flush();
}
}
class Rng {
#state: number;
@@ -434,6 +502,40 @@ class Rng {
}
}
interface StressRandomStreams {
readonly ops: Rng;
readonly content: Rng;
readonly overlay: Rng;
readonly geometry: Rng;
readonly cursor: Rng;
readonly children: Rng;
}
function createRandomStreams(seed: number): StressRandomStreams {
return {
ops: new Rng(mixSeed(seed, 0x01)),
content: new Rng(mixSeed(seed, 0x02)),
overlay: new Rng(mixSeed(seed, 0x03)),
geometry: new Rng(mixSeed(seed, 0x04)),
cursor: new Rng(mixSeed(seed, 0x05)),
children: new Rng(mixSeed(seed, 0x06)),
};
}
function mixSeed(seed: number, stream: number): number {
let mixed = (seed ^ Math.imul(stream, 0x9e3779b9)) >>> 0;
mixed = Math.imul(mixed ^ (mixed >>> 16), 0x7feb352d);
mixed = Math.imul(mixed ^ (mixed >>> 15), 0x846ca68b);
return (mixed ^ (mixed >>> 16)) >>> 0;
}
function isEd3RiskScenario(terminalMode: TerminalMode, envMode: EnvMode): boolean {
return (
terminalMode === "unknown" &&
(envMode === "appleTerminal" || envMode === "iterm2" || envMode === "wsl" || envMode === "ghostty")
);
}
interface WeightedCandidate<T> {
readonly item: T;
readonly weight: number;
@@ -465,18 +567,28 @@ function terminalStressTraits(scenario: Scenario): TerminalStressTraits {
strictNativeScrollback: scenario.strictScrollback,
syncOutputDisabled: scenario.envMode === "vteNoSync",
viewportProbe: scenario.terminalMode === "normal" ? "known" : scenario.terminalMode,
ed3ScrollbackEraseRisk:
scenario.terminalMode === "unknown" &&
(scenario.envMode === "appleTerminal" ||
scenario.envMode === "iterm2" ||
scenario.envMode === "wsl" ||
scenario.envMode === "ghostty"),
ed3ScrollbackEraseRisk: isEd3RiskScenario(scenario.terminalMode, scenario.envMode),
conptyHostScrollbackUnobservable: scenario.platform === "win32" && scenario.terminalMode === "unknown",
foregroundStreaming: scenario.foregroundStream,
widthModel: scenario.widthModel ?? "legacy",
};
}
function scenarioTags(
template: Pick<Scenario, "envMode" | "terminalMode" | "geometryMode" | "widthModel">,
strictNativeScrollback: boolean,
foregroundStreaming: boolean,
): readonly ScenarioTag[] {
const tags: ScenarioTag[] = [template.geometryMode];
if (template.envMode === "tmux") tags.push("tmux");
if (strictNativeScrollback) tags.push("strictScrollback");
if (template.terminalMode !== "normal") tags.push("unknownViewport");
if (foregroundStreaming) tags.push("foregroundStream");
if (isEd3RiskScenario(template.terminalMode, template.envMode)) tags.push("ed3Risk");
if (template.widthModel === "modern") tags.push("modernWidth");
return tags;
}
class StressModel {
readonly lines: LogicalLine[] = [];
readonly minLines: number;
@@ -991,7 +1103,7 @@ class StressOverlayComponent implements Component, Focusable {
class StressDriver {
#scenario: Scenario;
#rng: Rng;
#streams: StressRandomStreams;
#traits: TerminalStressTraits;
#scheduler: StressRenderScheduler;
#term: VirtualTerminal;
@@ -1026,15 +1138,15 @@ class StressDriver {
constructor(scenario: Scenario) {
this.#scenario = scenario;
this.#rng = new Rng(scenario.seed);
this.#streams = createRandomStreams(scenario.seed);
this.#traits = terminalStressTraits(scenario);
this.#scheduler = new StressRenderScheduler();
const maxHeight = maxOf(scenario.heightChoices);
this.#model = new StressModel(this.#rng, maxHeight + 12, scenario.uniqueContent, "root-");
this.#model = new StressModel(this.#streams.content, maxHeight + 12, scenario.uniqueContent, "root-");
this.#component = new StressComponent(this.#model, scenario.reflow);
this.#children = [0, 1].map(id => {
const model = new StressModel(
this.#rng,
this.#streams.children,
Math.max(1, Math.min(3, maxHeight)),
scenario.uniqueContent,
`child${id}-`,
@@ -1084,7 +1196,7 @@ class StressDriver {
for (let index = 0; index < this.#scenario.iterations; index++) {
const before = this.#snapshot();
const kind = this.#chooseOperation(index, before);
const kind = this.#scenario.replayOperations?.[index] ?? this.#chooseOperation(index, before);
const op = await this.#applyOperation(kind);
const after = this.#snapshot();
this.#recordOperation(index, op.kind, op.detail, before, after);
@@ -1183,7 +1295,7 @@ class StressDriver {
if (this.#traits.strictNativeScrollback && before.atBottom && index % 41 === 0) {
return "offscreenEditAppendRepeatedTail";
}
if (!before.atBottom && this.#rng.chance(0.28)) {
if (!before.atBottom && this.#streams.ops.chance(0.28)) {
return "scrollToBottom";
}
@@ -1248,7 +1360,7 @@ class StressDriver {
{ item: "reorderChildren", weight: this.#children.filter(child => child.active).length > 1 ? 1 : 0 },
{ item: "mutateChild", weight: this.#children.some(child => child.active) ? 3 : 0 },
];
return weightedPick(this.#rng, weighted);
return weightedPick(this.#streams.ops, weighted);
}
async #applyOperation(kind: OperationKind): Promise<AppliedOperation> {
@@ -1359,38 +1471,22 @@ class StressDriver {
): Promise<AppliedOperation> {
this.#renderContentFrame();
await this.#settle();
return {
kind,
detail,
mutatesContent: true,
checksRowAccounting,
geometryChanged: false,
forcedRender: false,
mutatesViewport: false,
checkpoint: false,
};
return contentOperation(kind, detail, checksRowAccounting);
}
async #eagerStreamingMutation(): Promise<AppliedOperation> {
this.#tui.setEagerNativeScrollbackRebuild(true);
let detail: JsonObject = {};
try {
detail = this.#rng.chance(0.5) ? this.#model.streamOne() : this.#model.editOffscreenLine(this.#term.rows);
detail = this.#streams.content.chance(0.5)
? this.#model.streamOne()
: this.#model.editOffscreenLine(this.#term.rows);
this.#renderContentFrame();
await this.#settle();
} finally {
this.#tui.setEagerNativeScrollbackRebuild(false);
}
return {
kind: "eagerStreamingMutation",
detail,
mutatesContent: true,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
mutatesViewport: false,
checkpoint: false,
};
return contentOperation("eagerStreamingMutation", detail, false);
}
#renderContentFrame(): void {
@@ -1452,14 +1548,14 @@ class StressDriver {
}
async #coalescedBurst(): Promise<AppliedOperation> {
const count = this.#rng.int(2, 6);
const count = this.#streams.ops.int(2, 6);
const steps: JsonValue[] = [];
let mutatesContent = false;
let geometryChanged = false;
let forcedRender = false;
let mutatesViewport = false;
for (let i = 0; i < count; i++) {
const stepKind = this.#rng.pick(BURST_STEP_KINDS);
const stepKind = this.#streams.ops.pick(BURST_STEP_KINDS);
const detail = this.#applyBurstStep(stepKind);
steps.push({ kind: stepKind, detail });
const metadata = BURST_STEP_METADATA[stepKind];
@@ -1512,8 +1608,8 @@ class StressDriver {
return { rows };
}
case "scrollPartial": {
const amount = this.#rng.int(1, Math.max(1, this.#term.rows));
const direction = this.#rng.chance(0.5) ? -1 : 1;
const amount = this.#streams.geometry.int(1, Math.max(1, this.#term.rows));
const direction = this.#streams.geometry.chance(0.5) ? -1 : 1;
this.#term.scrollLines(direction * amount);
return { amount: direction * amount };
}
@@ -1544,7 +1640,7 @@ class StressDriver {
async #showOverlay(): Promise<AppliedOperation> {
const id = this.#nextOverlayId;
this.#nextOverlayId += 1;
const model = new StressOverlayModel(this.#rng, id);
const model = new StressOverlayModel(this.#streams.overlay, id);
const component = new StressOverlayComponent(model);
const { options, detail } = this.#randomOverlayOptions();
const handle = this.#tui.showOverlay(component, options);
@@ -1613,62 +1709,63 @@ class StressDriver {
#pickOverlay(): StressOverlayEntry | undefined {
if (this.#overlays.length === 0) return undefined;
return this.#overlays[this.#rng.int(0, this.#overlays.length - 1)];
return this.#overlays[this.#streams.overlay.int(0, this.#overlays.length - 1)];
}
#randomOverlayOptions(): { options: OverlayOptions; detail: JsonObject } {
const rng = this.#streams.overlay;
const options: OverlayOptions = {};
const detail: JsonObject = {};
if (this.#rng.chance(0.75)) {
const width = this.#rng.chance(0.35)
? (`${this.#rng.pick([25, 40, 60, 80])}%` as `${number}%`)
: this.#rng.int(1, Math.max(1, this.#term.columns + 8));
if (rng.chance(0.75)) {
const width = rng.chance(0.35)
? (`${rng.pick([25, 40, 60, 80])}%` as `${number}%`)
: rng.int(1, Math.max(1, this.#term.columns + 8));
options.width = width;
detail.width = width;
}
if (this.#rng.chance(0.35)) {
const maxHeight = this.#rng.chance(0.35)
? (`${this.#rng.pick([25, 50, 75])}%` as `${number}%`)
: this.#rng.int(1, Math.max(1, this.#term.rows));
if (rng.chance(0.35)) {
const maxHeight = rng.chance(0.35)
? (`${rng.pick([25, 50, 75])}%` as `${number}%`)
: rng.int(1, Math.max(1, this.#term.rows));
options.maxHeight = maxHeight;
detail.maxHeight = maxHeight;
}
if (this.#rng.chance(0.25)) {
const minWidth = this.#rng.int(1, Math.max(1, this.#term.columns + 4));
if (rng.chance(0.25)) {
const minWidth = rng.int(1, Math.max(1, this.#term.columns + 4));
options.minWidth = minWidth;
detail.minWidth = minWidth;
}
if (this.#rng.chance(0.5)) {
const anchor = this.#rng.pick(OVERLAY_ANCHORS);
if (rng.chance(0.5)) {
const anchor = rng.pick(OVERLAY_ANCHORS);
options.anchor = anchor;
options.offsetX = this.#rng.int(-3, 3);
options.offsetY = this.#rng.int(-2, 2);
options.offsetX = rng.int(-3, 3);
options.offsetY = rng.int(-2, 2);
detail.anchor = anchor;
detail.offsetX = options.offsetX;
detail.offsetY = options.offsetY;
} else {
const row = this.#rng.chance(0.45)
? (`${this.#rng.pick([0, 25, 50, 75, 100])}%` as `${number}%`)
: this.#rng.int(-2, this.#term.rows + 2);
const col = this.#rng.chance(0.45)
? (`${this.#rng.pick([0, 25, 50, 75, 100])}%` as `${number}%`)
: this.#rng.int(-4, this.#term.columns + 4);
const row = rng.chance(0.45)
? (`${rng.pick([0, 25, 50, 75, 100])}%` as `${number}%`)
: rng.int(-2, this.#term.rows + 2);
const col = rng.chance(0.45)
? (`${rng.pick([0, 25, 50, 75, 100])}%` as `${number}%`)
: rng.int(-4, this.#term.columns + 4);
options.row = row;
options.col = col;
detail.row = row;
detail.col = col;
}
if (this.#rng.chance(0.6)) {
if (this.#rng.chance(0.5)) {
const margin = this.#rng.int(0, 2);
if (rng.chance(0.6)) {
if (rng.chance(0.5)) {
const margin = rng.int(0, 2);
options.margin = margin;
detail.margin = margin;
} else {
const margin = {
top: this.#rng.int(0, 2),
right: this.#rng.int(0, 2),
bottom: this.#rng.int(0, 2),
left: this.#rng.int(0, 2),
top: rng.int(0, 2),
right: rng.int(0, 2),
bottom: rng.int(0, 2),
left: rng.int(0, 2),
};
options.margin = margin;
detail.margin = margin;
@@ -1688,35 +1785,17 @@ class StressDriver {
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
}
await this.#settle();
return {
kind: "resizeBoth",
detail: { columns, rows },
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: true,
forcedRender: false,
mutatesViewport: true,
checkpoint: false,
};
return viewOperation("resizeBoth", { columns, rows }, { geometryChanged: true, mutatesViewport: true });
}
async #resizeNoop(): Promise<AppliedOperation> {
this.#term.resize(this.#term.columns, this.#term.rows);
await this.#settle();
return {
kind: "resizeNoop",
detail: { columns: this.#term.columns, rows: this.#term.rows },
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
mutatesViewport: false,
checkpoint: false,
};
return viewOperation("resizeNoop", { columns: this.#term.columns, rows: this.#term.rows });
}
async #scrollUp(): Promise<AppliedOperation> {
const amount = this.#rng.int(1, Math.max(1, this.#term.rows * 2));
const amount = this.#streams.geometry.int(1, Math.max(1, this.#term.rows * 2));
this.#term.scrollLines(-amount);
await this.#settle();
return this.#viewOperation("scrollUp", { amount });
@@ -1729,21 +1808,16 @@ class StressDriver {
clearScrollback: this.#traits.strictNativeScrollback,
});
await this.#settle();
return {
kind: "scrollToBottom",
detail: { forcedCheckpoint: this.#traits.strictNativeScrollback },
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: true,
mutatesViewport: true,
checkpoint: true,
};
return forceRenderOperation(
"scrollToBottom",
{ forcedCheckpoint: this.#traits.strictNativeScrollback },
{ checkpoint: true, mutatesViewport: true },
);
}
async #scrollPartial(): Promise<AppliedOperation> {
const amount = this.#rng.int(1, Math.max(1, this.#term.rows));
const direction = this.#rng.chance(0.5) ? -1 : 1;
const amount = this.#streams.geometry.int(1, Math.max(1, this.#term.rows));
const direction = this.#streams.geometry.chance(0.5) ? -1 : 1;
this.#term.scrollLines(direction * amount);
await this.#settle();
return this.#viewOperation("scrollPartial", { amount: direction * amount });
@@ -1755,16 +1829,7 @@ class StressDriver {
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
}
await this.#settle();
return {
kind: "resizeWidth",
detail: { columns },
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: true,
forcedRender: false,
mutatesViewport: true,
checkpoint: false,
};
return viewOperation("resizeWidth", { columns }, { geometryChanged: true, mutatesViewport: true });
}
async #resizeHeight(): Promise<AppliedOperation> {
@@ -1774,16 +1839,7 @@ class StressDriver {
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
}
await this.#settle();
return {
kind: "resizeHeight",
detail: { rows },
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: true,
forcedRender: false,
mutatesViewport: true,
checkpoint: false,
};
return viewOperation("resizeHeight", { rows }, { geometryChanged: true, mutatesViewport: true });
}
// SIGWINCH racing a streamed token: the model grows and the terminal
@@ -1794,21 +1850,15 @@ class StressDriver {
async #resizeWithAppend(): Promise<AppliedOperation> {
const appended = this.#model.appendSmall();
const rows = this.#pickDifferent(this.#scenario.heightChoices, this.#term.rows);
const columns = this.#rng.chance(0.5)
const columns = this.#streams.geometry.chance(0.5)
? this.#pickDifferent(this.#scenario.widthChoices, this.#term.columns)
: this.#term.columns;
this.#term.resize(columns, rows);
await this.#settle();
return {
kind: "resizeWithAppend",
detail: { appended, columns, rows },
mutatesContent: true,
checksRowAccounting: false,
return contentOperation("resizeWithAppend", { appended, columns, rows }, false, {
geometryChanged: true,
forcedRender: false,
mutatesViewport: true,
checkpoint: false,
};
});
}
async #forceRender(): Promise<AppliedOperation> {
@@ -1845,7 +1895,7 @@ class StressDriver {
// header + residual rows), so the transient bound must cover everything
// this op writes: the cleared frame plus the fresh overflow appends.
const clearedFrameLength = this.#expectedFrame().frame.length;
const overflowCount = this.#term.rows + this.#rng.int(1, 4);
const overflowCount = this.#term.rows + this.#streams.geometry.int(1, 4);
const overflow = this.#model.appendCount(overflowCount, "overflow");
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
await this.#settle();
@@ -1859,16 +1909,9 @@ class StressDriver {
}
#forceOperation(kind: OperationKind, detail: JsonObject): AppliedOperation {
return {
kind,
detail,
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: true,
return forceRenderOperation(kind, detail, {
mutatesViewport: kind === "forceRenderClearScrollback" || kind === "forceRenderAfterEmptyOverflow",
checkpoint: false,
};
});
}
async #toggleFocusInput(): Promise<AppliedOperation> {
@@ -1876,23 +1919,14 @@ class StressDriver {
if (this.#component.focused) {
this.#tui.setFocus(null);
} else {
cursor = this.#rng.chance(0.25)
cursor = this.#streams.cursor.chance(0.25)
? this.#model.setCursorOffscreen(this.#term.rows, this.#term.columns)
: this.#model.setCursorVisible(this.#term.rows, this.#term.columns);
this.#tui.setFocus(this.#component);
}
this.#tui.requestRender(false, { allowUnknownViewportMutation: true });
await this.#settle();
return {
kind: "toggleFocusInput",
detail: { focused: this.#component.focused, cursor },
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
mutatesViewport: false,
checkpoint: false,
};
return viewOperation("toggleFocusInput", { focused: this.#component.focused, cursor });
}
// Container.addChild appends and Container.render walks children in array
@@ -1917,36 +1951,18 @@ class StressDriver {
this.#syncChildOrder();
this.#renderContentFrame();
await this.#settle();
return {
kind: "attachChild",
detail: { id: child.id, lines: child.model.debugLines() },
mutatesContent: true,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
mutatesViewport: false,
checkpoint: false,
};
return contentOperation("attachChild", { id: child.id, lines: child.model.debugLines() }, false);
}
async #detachChild(): Promise<AppliedOperation> {
const active = this.#children.filter(entry => entry.active);
const child = active.length === 0 ? undefined : active[this.#rng.int(0, active.length - 1)];
const child = active.length === 0 ? undefined : active[this.#streams.children.int(0, active.length - 1)];
if (child === undefined) return this.#viewOperation("detachChild", { skipped: true });
child.active = false;
this.#tui.removeChild(child.component);
this.#renderContentFrame();
await this.#settle();
return {
kind: "detachChild",
detail: { id: child.id },
mutatesContent: true,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
mutatesViewport: false,
checkpoint: false,
};
return contentOperation("detachChild", { id: child.id }, false);
}
async #reorderChildren(): Promise<AppliedOperation> {
@@ -1957,53 +1973,34 @@ class StressDriver {
this.#syncChildOrder();
this.#renderContentFrame();
await this.#settle();
return {
kind: "reorderChildren",
detail: { activeOrder: this.#children.filter(child => child.active).map(child => child.id) },
mutatesContent: true,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
mutatesViewport: false,
checkpoint: false,
};
return contentOperation(
"reorderChildren",
{ activeOrder: this.#children.filter(child => child.active).map(child => child.id) },
false,
);
}
async #mutateChild(): Promise<AppliedOperation> {
const active = this.#children.filter(entry => entry.active);
const child = active.length === 0 ? undefined : active[this.#rng.int(0, active.length - 1)];
const child = active.length === 0 ? undefined : active[this.#streams.children.int(0, active.length - 1)];
if (child === undefined) return this.#viewOperation("mutateChild", { skipped: true });
const detail = this.#rng.chance(0.5) ? child.model.appendSmall() : child.model.editVisibleLine(this.#term.rows);
const detail = this.#streams.children.chance(0.5)
? child.model.appendSmall()
: child.model.editVisibleLine(this.#term.rows);
this.#renderContentFrame();
await this.#settle();
return {
kind: "mutateChild",
detail: { id: child.id, detail },
mutatesContent: true,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
mutatesViewport: false,
checkpoint: false,
};
return contentOperation("mutateChild", { id: child.id, detail }, false);
}
#viewOperation(kind: OperationKind, detail: JsonObject): AppliedOperation {
return {
kind,
detail,
mutatesContent: false,
checksRowAccounting: false,
geometryChanged: false,
forcedRender: false,
return viewOperation(kind, detail, {
mutatesViewport: kind === "scrollUp" || kind === "scrollPartial",
checkpoint: false,
};
});
}
#pickDifferent(values: readonly number[], current: number): number {
const candidates = values.filter(value => value !== current);
return candidates.length === 0 ? current : this.#rng.pick(candidates);
return candidates.length === 0 ? current : this.#streams.geometry.pick(candidates);
}
async #checkpoint(index: number, kind: "periodicCheckpoint"): Promise<void> {
@@ -2619,11 +2616,13 @@ class StressDriver {
index: number,
extra: JsonObject,
): never {
const replayLogPath = writeReplayLog(this.#scenario, this.#opLog);
const replay = `TUI_STRESS_REPLAY=${JSON.stringify({
scenario: this.#scenario.name,
seed: formatSeed(this.#scenario.seed),
iterations: index + 1,
})}`;
const replayLog = `TUI_STRESS_REPLAY_LOG=${replayLogPath}`;
const fullDump = Bun.env.TUI_STRESS_FULL_DUMP === "1";
const dump = {
message,
@@ -2631,9 +2630,12 @@ class StressDriver {
seed: formatSeed(this.#scenario.seed),
opIndex: index,
replay,
replayLog,
replayLogPath,
op: { kind: op.kind, detail: op.detail },
extra,
traits: this.#traits,
tags: this.#scenario.tags,
operationCoverage: Object.fromEntries(this.#operationCoverage.entries()),
lastOperations: this.#opLog.slice(-50),
children: this.#children.map(child => ({
@@ -3257,16 +3259,21 @@ export function buildScenarios(): Scenario[] {
const soak = Bun.env.TUI_STRESS_SOAK === "1";
const templates = soak ? soakTemplates() : coreTemplates();
const replay = parseReplay(templates);
const replayOperations = parseReplayOperations();
if (replayOperations !== null && replay === null) {
throw new Error("TUI_STRESS_REPLAY_LOG requires TUI_STRESS_REPLAY to select the scenario and seed");
}
if (replay !== null) {
const maxHeight = maxOf(replay.template.heightChoices);
return [
materializeScenario(
replay.template,
replay.seed,
replay.iterations,
replayOperations?.length ?? replay.iterations,
SOAK_BULK_MAX,
SOAK_TIMEOUT_MS,
maxHeight,
replayOperations ?? undefined,
),
];
}
@@ -3296,19 +3303,25 @@ function materializeScenario(
bulkMax: number,
timeoutMs: number,
maxHeight: number,
replayOperations?: readonly OperationKind[],
): Scenario {
const strictScrollback =
template.envMode !== "tmux" && template.terminalMode === "normal" && template.platform !== "win32";
const foregroundStream = template.foregroundStream ?? false;
const reflow = template.reflow ?? false;
return {
...template,
seed,
iterations,
bulkMax,
scrollback: template.scrollbackRows ?? Math.max(10_000, maxHeight + 64 + iterations * (bulkMax + 8)),
strictScrollback:
template.envMode !== "tmux" && template.terminalMode === "normal" && template.platform !== "win32",
strictScrollback,
timeoutMs,
uniqueContent: template.uniqueContent ?? false,
foregroundStream: template.foregroundStream ?? false,
reflow: template.reflow ?? false,
foregroundStream,
reflow,
tags: scenarioTags(template, strictScrollback, foregroundStream),
replayOperations,
};
}
@@ -3360,6 +3373,36 @@ function parseReplaySeed(seed: unknown): number {
throw new Error("Invalid TUI_STRESS_REPLAY.seed: expected a number or integer string");
}
function parseReplayOperations(): readonly OperationKind[] | null {
const path = Bun.env.TUI_STRESS_REPLAY_LOG;
if (path === undefined || path.length === 0) return null;
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(path, "utf8"));
} catch (error) {
throw new Error(`Invalid TUI_STRESS_REPLAY_LOG JSON: ${path}`, { cause: error });
}
const entries = Array.isArray(parsed)
? parsed
: isJsonRecord(parsed) && Array.isArray(parsed.operations)
? parsed.operations
: null;
if (entries === null) {
throw new Error("Invalid TUI_STRESS_REPLAY_LOG: expected an operation array or { operations } object");
}
const operations: OperationKind[] = [];
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
const kind = isJsonRecord(entry) ? entry.kind : entry;
if (kind === "periodicCheckpoint") continue;
if (!isOperationKind(kind)) {
throw new Error(`Invalid TUI_STRESS_REPLAY_LOG operation at index ${index}`);
}
operations.push(kind);
}
return operations;
}
function buildSeeds(count: number): number[] {
const seeds: number[] = [];
for (let i = 0; i < count; i++) {
@@ -3380,6 +3423,8 @@ type ScenarioTemplate = Omit<
| "uniqueContent"
| "foregroundStream"
| "reflow"
| "tags"
| "replayOperations"
> & {
scrollbackRows?: number;
uniqueContent?: boolean;
@@ -3387,6 +3432,15 @@ type ScenarioTemplate = Omit<
reflow?: boolean;
};
function writeReplayLog(scenario: Scenario, operations: readonly OperationLogEntry[]): string {
const filePath = path.join(
os.tmpdir(),
`omp-tui-stress-${scenario.name}-${(scenario.seed >>> 0).toString(16)}-${Date.now().toString(36)}.json`,
);
fs.writeFileSync(filePath, JSON.stringify(operations, null, 2));
return filePath;
}
function coreTemplates(): ScenarioTemplate[] {
return [
{
@@ -0,0 +1,12 @@
import { describe, expect, it } from "bun:test";
import { reduceFailingOperations } from "./render-stress-reducer";
describe("render stress reducer", () => {
it("shrinks a failing prefix while preserving failure", async () => {
const reduced = await reduceFailingOperations(
["a", "b", "c", "d", "e"],
async candidate => candidate.includes("b") && candidate.includes("d"),
);
expect(reduced).toEqual(["b", "d"]);
});
});
+111
View File
@@ -0,0 +1,111 @@
import * as fs from "node:fs";
import * as path from "node:path";
import {
buildScenarios,
isOperationKind,
type OperationKind,
runStressScenario,
type Scenario,
} from "./render-stress-harness";
function extractReplayOperations(parsed: unknown): OperationKind[] {
const entries = Array.isArray(parsed)
? parsed
: isRecord(parsed) && Array.isArray(parsed.opLog)
? parsed.opLog
: isRecord(parsed) && Array.isArray(parsed.operations)
? parsed.operations
: null;
if (entries === null) {
throw new Error("Replay log must be an array, { opLog }, or { operations }.");
}
const operations: OperationKind[] = [];
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
const kind = isRecord(entry) ? entry.kind : entry;
if (kind === "periodicCheckpoint") continue;
if (!isOperationKind(kind)) {
throw new Error(`Invalid replay operation at index ${index}.`);
}
operations.push(kind);
}
return operations;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function reduceFailingOperations<T>(
items: readonly T[],
fails: (candidate: readonly T[]) => Promise<boolean>,
): Promise<readonly T[]> {
let current = [...items];
if (!(await fails(current))) {
throw new Error("Initial operation log does not reproduce the failure.");
}
let chunks = 2;
while (current.length >= 2) {
const chunkSize = Math.ceil(current.length / chunks);
let reduced = false;
for (let index = 0; index < current.length; index += chunkSize) {
const candidate = current.slice(0, index).concat(current.slice(index + chunkSize));
if (candidate.length === 0) continue;
if (await fails(candidate)) {
current = candidate;
chunks = Math.max(2, chunks - 1);
reduced = true;
break;
}
}
if (reduced) continue;
if (chunks >= current.length) break;
chunks = Math.min(current.length, chunks * 2);
}
return current;
}
async function loadReplayScenario(): Promise<Scenario> {
const savedReplayLog = Bun.env.TUI_STRESS_REPLAY_LOG;
delete Bun.env.TUI_STRESS_REPLAY_LOG;
try {
const scenarios = buildScenarios();
if (scenarios.length !== 1) {
throw new Error("Set TUI_STRESS_REPLAY to exactly one scenario before running the reducer.");
}
return scenarios[0]!;
} finally {
if (savedReplayLog === undefined) {
delete Bun.env.TUI_STRESS_REPLAY_LOG;
} else {
Bun.env.TUI_STRESS_REPLAY_LOG = savedReplayLog;
}
}
}
async function main(): Promise<void> {
const replayPath = Bun.env.TUI_STRESS_REPLAY_LOG;
if (replayPath === undefined || replayPath.length === 0) {
throw new Error("Set TUI_STRESS_REPLAY_LOG to the failing operation log JSON.");
}
const parsed = JSON.parse(fs.readFileSync(replayPath, "utf8"));
const operations = extractReplayOperations(parsed);
const scenario = await loadReplayScenario();
const reduced = await reduceFailingOperations(operations, async candidate => {
try {
await runStressScenario({ ...scenario, iterations: candidate.length, replayOperations: candidate });
return false;
} catch {
return true;
}
});
const outPath =
Bun.env.TUI_STRESS_REDUCED_LOG ??
path.join(path.dirname(replayPath), `${path.basename(replayPath, ".json")}.reduced.json`);
fs.writeFileSync(outPath, JSON.stringify(reduced, null, 2));
console.log(outPath);
}
if (import.meta.main) {
await main();
}
@@ -0,0 +1,48 @@
import type { RenderScheduler } from "../src/tui";
import type { VirtualTerminal } from "./virtual-terminal";
export class StressRenderScheduler implements RenderScheduler {
#time = 0;
#nextTimerId = 0;
#immediateCallbacks: (() => void)[] = [];
#renderCallbacks = new Map<number, () => void>();
now(): number {
this.#time += 20;
return this.#time;
}
scheduleImmediate(callback: () => void): void {
this.#immediateCallbacks.push(callback);
}
scheduleRender(callback: () => void, _delayMs: number): { cancel(): void } {
const id = this.#nextTimerId;
this.#nextTimerId += 1;
this.#renderCallbacks.set(id, callback);
return {
cancel: () => {
this.#renderCallbacks.delete(id);
},
};
}
async drain(term: VirtualTerminal): Promise<void> {
let rounds = 0;
while (this.#immediateCallbacks.length > 0 || this.#renderCallbacks.size > 0) {
rounds += 1;
if (rounds > 100) {
throw new Error("Render scheduler did not settle after 100 drain rounds");
}
const immediate = this.#immediateCallbacks;
this.#immediateCallbacks = [];
for (const callback of immediate) callback();
if (this.#renderCallbacks.size === 0) continue;
const render = [...this.#renderCallbacks.values()];
this.#renderCallbacks.clear();
for (const callback of render) callback();
}
await term.flush();
}
}