- Replaced the streaming edit diff rendering strategy with a fixed-height trailing window so partial re-diff streams stayed anchored and stopped oscillating. - Updated streaming preview tests to assert the window remained saturated without trailing blank padding across chunked updates and still produced a real diff after finalization. - Tuned TUI viewport repaint heuristics for pure appends and multiplexer sessions, and adjusted render-stress handling for preserved tmux scrollback.
2772 lines
90 KiB
TypeScript
2772 lines
90 KiB
TypeScript
import { afterEach, beforeEach, describe, it, vi } from "bun:test";
|
|
import { stripVTControlCharacters } from "node:util";
|
|
import {
|
|
type Component,
|
|
CURSOR_MARKER,
|
|
Ellipsis,
|
|
extractSegments,
|
|
type Focusable,
|
|
type OverlayAnchor,
|
|
type OverlayHandle,
|
|
type OverlayOptions,
|
|
sliceByColumn,
|
|
sliceWithWidth,
|
|
TUI,
|
|
truncateToWidth,
|
|
visibleWidth,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import { VirtualTerminal } from "./virtual-terminal";
|
|
|
|
const BASE_SEEDS = [
|
|
0x00c0ffee, 0x1badb002, 0x5eed1234, 0xdecafbad, 0x8badf00d, 0x0ddc0ffe, 0xcafed00d, 0xb16b00b5,
|
|
] as const;
|
|
const LARGE_SCROLL = 1_000_000;
|
|
const CORE_ITERATIONS = 300;
|
|
const SOAK_ITERATIONS = 600;
|
|
const CORE_BULK_MAX = 1_000;
|
|
const SOAK_BULK_MAX = 1_000;
|
|
const CORE_TIMEOUT_MS = 30_000;
|
|
const SOAK_TIMEOUT_MS = 120_000;
|
|
const EXHAUSTIVE_SCROLLBACK = Bun.env.TUI_STRESS_EXHAUSTIVE_SCROLLBACK === "1";
|
|
|
|
const SEGMENT_RESET = "\x1b[0m";
|
|
const ESC = "\x1b";
|
|
const BEL = "\x07";
|
|
const SMILE = String.fromCodePoint(0x1f642);
|
|
type TestPlatform = "darwin" | "linux" | "win32";
|
|
type TerminalMode = "normal" | "unknown" | "intermittentUnknown" | "staleBottom";
|
|
type GeometryMode = "small" | "large";
|
|
type EnvMode = "plain" | "tmux" | "termux";
|
|
const ENV_KEYS = ["TMUX", "STY", "ZELLIJ", "TERMUX_VERSION"] as const;
|
|
type EnvKey = (typeof ENV_KEYS)[number];
|
|
type JsonPrimitive = string | number | boolean | null;
|
|
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
|
type JsonObject = { [key: string]: JsonValue };
|
|
|
|
type OperationKind =
|
|
| "appendSmall"
|
|
| "appendBulk"
|
|
| "streamOne"
|
|
| "editVisibleLine"
|
|
| "editOffscreenLine"
|
|
| "offscreenEditAppendRepeatedTail"
|
|
| "insertOffscreen"
|
|
| "insertMiddle"
|
|
| "deleteTrailing"
|
|
| "deleteMiddle"
|
|
| "replaceAll"
|
|
| "toggleCollapsible"
|
|
| "tickStatusHeader"
|
|
| "appendRepeatedTail"
|
|
| "injectBlankCluster"
|
|
| "appendDuplicateOfExisting"
|
|
| "highWaterPreviewCollapse"
|
|
| "scrollUp"
|
|
| "scrollToBottom"
|
|
| "scrollPartial"
|
|
| "resizeWidth"
|
|
| "resizeHeight"
|
|
| "forceRender"
|
|
| "toggleFocusInput"
|
|
| "moveCursorVisible"
|
|
| "moveCursorOffscreen"
|
|
| "showOverlay"
|
|
| "hideOverlay"
|
|
| "toggleOverlayHidden"
|
|
| "editOverlay"
|
|
| "moveOverlayCursor"
|
|
| "coalescedBurst"
|
|
| "rotateUp"
|
|
| "collapseToFew"
|
|
| "swapOffscreenRows"
|
|
| "resizeBoth"
|
|
| "resizeNoop"
|
|
| "forceRenderAllowUnknown"
|
|
| "forceRenderClearScrollback"
|
|
| "forceRenderAfterEmptyOverflow"
|
|
| "attachChild"
|
|
| "detachChild"
|
|
| "reorderChildren"
|
|
| "mutateChild";
|
|
|
|
const BURST_STEP_KINDS = [
|
|
"appendSmall",
|
|
"streamOne",
|
|
"appendRepeatedTail",
|
|
"injectBlankCluster",
|
|
"editVisibleLine",
|
|
"editOffscreenLine",
|
|
"tickStatusHeader",
|
|
"resizeWidth",
|
|
"resizeHeight",
|
|
"scrollPartial",
|
|
"scrollToBottom",
|
|
"forceRender",
|
|
] as const;
|
|
type BurstStepKind = (typeof BURST_STEP_KINDS)[number];
|
|
const OVERLAY_ANCHORS = [
|
|
"center",
|
|
"top-left",
|
|
"top-right",
|
|
"bottom-left",
|
|
"bottom-right",
|
|
"top-center",
|
|
"bottom-center",
|
|
"left-center",
|
|
"right-center",
|
|
] as const satisfies readonly OverlayAnchor[];
|
|
const CURSOR_MODES = ["start", "middle", "end", "wideBoundary"] as const;
|
|
type CursorMode = (typeof CURSOR_MODES)[number];
|
|
|
|
interface ExpectedCursor {
|
|
row: number;
|
|
col: number;
|
|
}
|
|
|
|
interface ExpectedFrame {
|
|
frame: string[];
|
|
cursor: ExpectedCursor | null;
|
|
}
|
|
|
|
interface StressOverlayEntry {
|
|
id: number;
|
|
sentinel: string;
|
|
model: StressOverlayModel;
|
|
component: StressOverlayComponent;
|
|
handle: OverlayHandle;
|
|
options: OverlayOptions;
|
|
hidden: boolean;
|
|
detail: JsonObject;
|
|
}
|
|
|
|
interface StressChildEntry {
|
|
id: number;
|
|
model: StressModel;
|
|
component: StressComponent;
|
|
active: boolean;
|
|
}
|
|
|
|
interface LogicalLine {
|
|
id: number;
|
|
text: string;
|
|
}
|
|
|
|
interface Scenario {
|
|
name: string;
|
|
seed: number;
|
|
platform: TestPlatform;
|
|
terminalMode: TerminalMode;
|
|
envMode: EnvMode;
|
|
geometryMode: GeometryMode;
|
|
columns: number;
|
|
rows: number;
|
|
widthChoices: readonly number[];
|
|
heightChoices: readonly number[];
|
|
iterations: number;
|
|
bulkMax: number;
|
|
scrollback: number;
|
|
strictScrollback: boolean;
|
|
timeoutMs: number;
|
|
uniqueContent: boolean;
|
|
}
|
|
|
|
interface Snapshot {
|
|
buffer: string[];
|
|
view: string[];
|
|
position: { baseY: number; viewportY: number };
|
|
cursor: { row: number; col: number };
|
|
expectedCursor: ExpectedCursor | null;
|
|
redraws: number;
|
|
width: number;
|
|
height: number;
|
|
frame: string[];
|
|
atBottom: boolean;
|
|
}
|
|
|
|
interface AppliedOperation {
|
|
kind: OperationKind;
|
|
detail: JsonObject;
|
|
mutatesContent: boolean;
|
|
checksRowAccounting: boolean;
|
|
geometryChanged: boolean;
|
|
forcedRender: boolean;
|
|
checkpoint: boolean;
|
|
mutatesViewport: boolean;
|
|
coalesced?: boolean;
|
|
}
|
|
|
|
interface OperationLogEntry {
|
|
index: number;
|
|
kind: OperationKind | "periodicCheckpoint";
|
|
detail: JsonObject;
|
|
frameLengthBefore: number;
|
|
frameLengthAfter: number;
|
|
bufferLengthBefore: number;
|
|
bufferLengthAfter: number;
|
|
viewportYBefore: number;
|
|
viewportYAfter: number;
|
|
baseYBefore: number;
|
|
baseYAfter: number;
|
|
redrawsBefore: number;
|
|
redrawsAfter: number;
|
|
}
|
|
|
|
class UnknownViewportTerminal extends VirtualTerminal {
|
|
isNativeViewportAtBottom(): undefined {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
class IntermittentUnknownViewportTerminal extends VirtualTerminal {
|
|
#probeCount = 0;
|
|
|
|
isNativeViewportAtBottom(): boolean | undefined {
|
|
this.#probeCount += 1;
|
|
return this.#probeCount % 3 === 0 ? undefined : super.isNativeViewportAtBottom();
|
|
}
|
|
}
|
|
|
|
class StaleBottomTerminal extends VirtualTerminal {
|
|
#previous: boolean | undefined;
|
|
#returnStale = false;
|
|
|
|
isNativeViewportAtBottom(): boolean | undefined {
|
|
const current = super.isNativeViewportAtBottom();
|
|
if (this.#returnStale) {
|
|
this.#returnStale = false;
|
|
const stale = this.#previous;
|
|
this.#previous = current;
|
|
return stale;
|
|
}
|
|
this.#returnStale = true;
|
|
this.#previous = current;
|
|
return current;
|
|
}
|
|
}
|
|
|
|
class MutableLinesComponent implements Component {
|
|
#lines: string[];
|
|
|
|
constructor(lines: readonly string[]) {
|
|
this.#lines = [...lines];
|
|
}
|
|
|
|
setLines(lines: readonly string[]): void {
|
|
this.#lines = [...lines];
|
|
}
|
|
|
|
invalidate(): void {}
|
|
|
|
render(_width: number): string[] {
|
|
return [...this.#lines];
|
|
}
|
|
}
|
|
|
|
class Rng {
|
|
#state: number;
|
|
|
|
constructor(seed: number) {
|
|
this.#state = seed >>> 0;
|
|
}
|
|
|
|
next(): number {
|
|
this.#state = (this.#state + 0x6d2b79f5) >>> 0;
|
|
let t = this.#state;
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
|
|
}
|
|
|
|
int(min: number, max: number): number {
|
|
if (max < min) return min;
|
|
return Math.floor(this.next() * (max - min + 1)) + min;
|
|
}
|
|
|
|
chance(probability: number): boolean {
|
|
return this.next() < probability;
|
|
}
|
|
|
|
pick<T>(items: readonly T[]): T {
|
|
if (items.length === 0) {
|
|
throw new Error("Cannot pick from an empty list");
|
|
}
|
|
return items[this.int(0, items.length - 1)]!;
|
|
}
|
|
}
|
|
|
|
class StressModel {
|
|
readonly lines: LogicalLine[] = [];
|
|
readonly minLines: number;
|
|
#rng: Rng;
|
|
#nextId = 0;
|
|
#collapsibleIds: number[] = [];
|
|
#cursorLineIndex: number | null = null;
|
|
#cursorMode: CursorMode = "end";
|
|
#uniqueContent: boolean;
|
|
#usedText = new Set<string>();
|
|
#labelPrefix: string;
|
|
|
|
constructor(rng: Rng, minLines: number, uniqueContent = false, labelPrefix = "") {
|
|
this.#rng = rng;
|
|
this.minLines = minLines;
|
|
this.#uniqueContent = uniqueContent;
|
|
this.#labelPrefix = labelPrefix;
|
|
const initialLength = minLines + 20;
|
|
for (let i = 0; i < initialLength; i++) {
|
|
this.lines.push(this.#line(this.#initialText(i)));
|
|
}
|
|
}
|
|
|
|
renderedLines(width: number, focused = false): string[] {
|
|
const lines = this.lines.map(line => line.text);
|
|
if (focused && lines.length > 0) {
|
|
const index = this.#clampedCursorLineIndex();
|
|
lines[index] = insertCursorMarker(lines[index] ?? "", this.#cursorMode, width);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
debugLines(): string[] {
|
|
const cursor = this.#cursorLineIndex === null ? "none" : `${this.#cursorLineIndex}:${this.#cursorMode}`;
|
|
return [`cursor:${cursor}`, ...this.lines.map(line => `${line.id}:${JSON.stringify(line.text)}`)];
|
|
}
|
|
|
|
setCursorVisible(height: number, width: number): JsonObject {
|
|
this.#ensureLine();
|
|
const start = Math.max(0, this.lines.length - height);
|
|
const index = this.#rng.int(start, this.lines.length - 1);
|
|
return this.#setCursor(index, width, false);
|
|
}
|
|
|
|
setCursorOffscreen(height: number, width: number): JsonObject {
|
|
while (this.lines.length <= height) {
|
|
this.lines.push(this.#randomLine("u"));
|
|
}
|
|
const limit = Math.max(1, this.lines.length - height);
|
|
const index = this.#rng.int(0, limit - 1);
|
|
return this.#setCursor(index, width, true);
|
|
}
|
|
|
|
appendSmall(): JsonObject {
|
|
const count = this.#rng.int(1, 3);
|
|
for (let i = 0; i < count; i++) {
|
|
this.lines.push(this.#randomLine("a"));
|
|
}
|
|
return { count };
|
|
}
|
|
|
|
appendBulk(maxBulk: number): JsonObject {
|
|
const min = Math.min(20, maxBulk);
|
|
const count = this.#rng.int(min, maxBulk);
|
|
for (let i = 0; i < count; i++) {
|
|
this.lines.push(this.#randomLine("b"));
|
|
}
|
|
return { count };
|
|
}
|
|
|
|
streamOne(): JsonObject {
|
|
this.lines.push(this.#randomLine("s"));
|
|
return { count: 1 };
|
|
}
|
|
|
|
appendRepeatedTail(): JsonObject {
|
|
if (this.#uniqueContent) {
|
|
const line = this.#freshLine("repeatAlt");
|
|
this.lines.push(line);
|
|
return { convertedToUnique: true, text: line.text };
|
|
}
|
|
const text = this.lines[this.lines.length - 1]?.text ?? "";
|
|
this.lines.push(this.#line(text));
|
|
return { text };
|
|
}
|
|
|
|
appendDuplicateOfExisting(): JsonObject {
|
|
const sourceIndex = this.#rng.int(0, this.lines.length - 1);
|
|
if (this.#uniqueContent) {
|
|
const line = this.#freshLine("dupAlt");
|
|
this.lines.push(line);
|
|
return { sourceIndex, convertedToUnique: true, text: line.text };
|
|
}
|
|
const text = this.lines[sourceIndex]?.text ?? "";
|
|
this.lines.push(this.#line(text));
|
|
return { sourceIndex, text };
|
|
}
|
|
|
|
injectBlankCluster(): JsonObject {
|
|
const count = this.#rng.int(2, 8);
|
|
for (let i = 0; i < count; i++) {
|
|
this.lines.push(this.#line(""));
|
|
}
|
|
return { count };
|
|
}
|
|
|
|
editVisibleLine(height: number): JsonObject {
|
|
const start = Math.max(0, this.lines.length - height);
|
|
const index = this.#rng.int(start, this.lines.length - 1);
|
|
const before = this.lines[index]?.text ?? "";
|
|
this.lines[index] = this.#randomLine("v");
|
|
return { index, before, after: this.lines[index]?.text ?? "" };
|
|
}
|
|
|
|
editOffscreenLine(height: number): JsonObject {
|
|
const limit = Math.max(1, this.lines.length - height);
|
|
const index = this.#rng.int(0, limit - 1);
|
|
const before = this.lines[index]?.text ?? "";
|
|
this.lines[index] = this.#randomLine("o");
|
|
return { index, before, after: this.lines[index]?.text ?? "" };
|
|
}
|
|
|
|
offscreenEditAppendRepeatedTail(height: number): JsonObject {
|
|
while (this.lines.length < height + 3) {
|
|
this.lines.push(this.#randomLine("p"));
|
|
}
|
|
const previousLength = this.lines.length;
|
|
const offscreenLimit = Math.max(1, previousLength - height);
|
|
const offscreenIndex = this.#rng.int(0, offscreenLimit - 1);
|
|
const previousLast = this.lines[previousLength - 1]?.text ?? "";
|
|
this.lines[offscreenIndex] = this.#randomLine("x");
|
|
const repeatedIndex = Math.max(0, previousLength - 2);
|
|
this.lines[repeatedIndex] = this.#uniqueContent ? this.#freshLine("xAlt") : this.#line(previousLast);
|
|
this.lines[previousLength - 1] = this.#randomLine("e");
|
|
this.lines.push(this.#randomLine("f"));
|
|
return { offscreenIndex, repeatedIndex, previousLast, previousLength };
|
|
}
|
|
|
|
insertOffscreen(height: number): JsonObject {
|
|
const count = this.#rng.int(1, 4);
|
|
const limit = Math.max(1, this.lines.length - height);
|
|
const index = this.#rng.int(0, limit - 1);
|
|
this.lines.splice(index, 0, ...this.#newLines(count, "i"));
|
|
return { index, count };
|
|
}
|
|
|
|
insertMiddle(): JsonObject {
|
|
const count = this.#rng.int(1, 3);
|
|
const index = this.#rng.int(1, Math.max(1, this.lines.length - 2));
|
|
this.lines.splice(index, 0, ...this.#newLines(count, "m"));
|
|
return { index, count };
|
|
}
|
|
|
|
deleteTrailing(): JsonObject {
|
|
const removable = Math.max(0, this.lines.length - this.minLines);
|
|
if (removable === 0) return { count: 0 };
|
|
const count = Math.min(removable, this.#rng.int(1, 4));
|
|
const removed = this.lines.splice(this.lines.length - count, count);
|
|
return { count, firstRemoved: removed[0]?.text ?? null };
|
|
}
|
|
|
|
deleteMiddle(height: number): JsonObject {
|
|
const removable = Math.max(0, this.lines.length - this.minLines);
|
|
if (removable === 0) return { count: 0 };
|
|
const count = Math.min(removable, this.#rng.int(1, 3));
|
|
const offscreenLimit = Math.max(1, this.lines.length - height - count);
|
|
const index = this.#rng.int(1, Math.max(1, offscreenLimit));
|
|
const removed = this.lines.splice(index, count);
|
|
return { index, count: removed.length, firstRemoved: removed[0]?.text ?? null };
|
|
}
|
|
|
|
replaceAll(): JsonObject {
|
|
const nextLength = this.#rng.int(this.minLines, this.minLines + 40);
|
|
this.lines.splice(0, this.lines.length, ...this.#newLines(nextLength, "r"));
|
|
return { nextLength };
|
|
}
|
|
|
|
toggleCollapsible(): JsonObject {
|
|
if (this.#collapsibleIds.length > 0) {
|
|
const ids = new Set(this.#collapsibleIds);
|
|
const before = this.lines.length;
|
|
for (let i = this.lines.length - 1; i >= 0; i--) {
|
|
const line = this.lines[i];
|
|
if (line && ids.has(line.id)) {
|
|
this.lines.splice(i, 1);
|
|
}
|
|
}
|
|
const removed = before - this.lines.length;
|
|
this.#collapsibleIds = [];
|
|
if (removed > 0) {
|
|
return { expanded: false, removed };
|
|
}
|
|
}
|
|
|
|
const block = this.#uniqueContent
|
|
? [this.#freshLine("blk0"), this.#freshLine("blk1"), this.#freshLine("blk2"), this.#freshLine("blk3")]
|
|
: [
|
|
this.#line(styledText("blk0", 35)),
|
|
this.#line(wideText("blk1")),
|
|
this.#line(linkedText("blk2")),
|
|
this.#line(longText("blk3", 3)),
|
|
];
|
|
this.#collapsibleIds = block.map(line => line.id);
|
|
const index = Math.min(2, this.lines.length);
|
|
this.lines.splice(index, 0, ...block);
|
|
return { expanded: true, inserted: block.length, index };
|
|
}
|
|
|
|
tickStatusHeader(): JsonObject {
|
|
const before = this.lines[0]?.text ?? "";
|
|
this.lines[0] = this.#freshLine("h");
|
|
return { index: 0, before, after: this.lines[0]?.text ?? "" };
|
|
}
|
|
|
|
rotateUp(): JsonObject {
|
|
if (this.lines.length < 2) {
|
|
this.lines.push(this.#freshLine("t"));
|
|
return { dropped: null, appended: this.lines[this.lines.length - 1]?.text ?? "" };
|
|
}
|
|
const dropped = this.lines.shift();
|
|
this.lines.push(this.#randomLine("t"));
|
|
return { dropped: dropped?.text ?? null, appended: this.lines[this.lines.length - 1]?.text ?? "" };
|
|
}
|
|
|
|
collapseToFew(): JsonObject {
|
|
const nextLength = this.#rng.int(0, 2);
|
|
this.lines.splice(0, this.lines.length, ...this.#newLines(nextLength, "c"));
|
|
return { nextLength };
|
|
}
|
|
|
|
clear(): JsonObject {
|
|
const previousLength = this.lines.length;
|
|
this.lines.splice(0, this.lines.length);
|
|
return { previousLength };
|
|
}
|
|
|
|
appendCount(count: number, prefix: string): JsonObject {
|
|
this.lines.push(...this.#newLines(count, prefix));
|
|
return { count };
|
|
}
|
|
|
|
beginHighWaterPreview(height: number): JsonObject {
|
|
while (this.lines.length < height + 8) {
|
|
this.lines.push(this.#freshLine("seed"));
|
|
}
|
|
const start = this.lines.length;
|
|
const count = this.#rng.int(height + 4, height + 14);
|
|
for (let i = 0; i < count; i++) {
|
|
this.lines.push(this.#freshLine("preview"));
|
|
}
|
|
return { start, count };
|
|
}
|
|
|
|
collapseHighWaterPreview(start: number, count: number): JsonObject {
|
|
const removed = this.lines.splice(start, count);
|
|
this.#ensureLine();
|
|
const editedIndex = this.lines.length - 1;
|
|
const before = this.lines[editedIndex]?.text ?? "";
|
|
this.lines[editedIndex] = this.#freshLine("done");
|
|
return { start, count: removed.length, editedIndex, before, after: this.lines[editedIndex]?.text ?? "" };
|
|
}
|
|
|
|
swapOffscreenRows(height: number): JsonObject {
|
|
const offscreenLimit = this.lines.length - height;
|
|
if (offscreenLimit < 2) return { swapped: 0 };
|
|
const i = this.#rng.int(0, offscreenLimit - 1);
|
|
let j = this.#rng.int(0, offscreenLimit - 1);
|
|
if (j === i) j = (j + 1) % offscreenLimit;
|
|
const a = this.lines[i]!;
|
|
const b = this.lines[j]!;
|
|
this.lines[i] = b;
|
|
this.lines[j] = a;
|
|
return { swapped: 2, i, j };
|
|
}
|
|
|
|
#initialText(index: number): string {
|
|
if (this.#uniqueContent) return index % 13 === 0 ? "" : `${this.#labelPrefix}init${index.toString(36)}`;
|
|
if (index % 13 === 0) return "";
|
|
if (index % 23 === 0) return longText(`L${index.toString(36)}`, 4);
|
|
if (index % 19 === 0) return linkedText(`link${index.toString(36)}`);
|
|
if (index % 17 === 0) return styledText(`sg${index.toString(36)}界`, 31 + (index % 6));
|
|
if (index % 11 === 0) return wideText(`w${index.toString(36)}`);
|
|
if (index % 7 === 0) return `r${index % 3}`;
|
|
return `l${index.toString(36)}`;
|
|
}
|
|
|
|
#newLines(count: number, prefix: string): LogicalLine[] {
|
|
const lines: LogicalLine[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
lines.push(this.#randomLine(prefix));
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
#randomLine(prefix: string): LogicalLine {
|
|
if (this.#uniqueContent) return this.#freshLine(prefix);
|
|
const roll = this.#rng.next();
|
|
if (roll < 0.1) return this.#line("");
|
|
if (roll < 0.2) return this.#line(`r${this.#rng.int(0, 3)}`);
|
|
if (roll < 0.34 && this.lines.length > 0) {
|
|
const source = this.lines[this.#rng.int(0, this.lines.length - 1)];
|
|
return this.#line(source?.text ?? "");
|
|
}
|
|
return this.#freshLine(prefix);
|
|
}
|
|
|
|
#freshLine(prefix: string): LogicalLine {
|
|
for (;;) {
|
|
const id = this.#nextId.toString(36);
|
|
const text = randomDecoratedText(this.#rng, `${this.#labelPrefix}${prefix}${id}`);
|
|
if (!this.#uniqueContent || text.length === 0 || !this.#usedText.has(text)) return this.#line(text);
|
|
this.#nextId += 1;
|
|
}
|
|
}
|
|
|
|
#ensureLine(): void {
|
|
if (this.lines.length === 0) {
|
|
this.lines.push(this.#freshLine("q"));
|
|
}
|
|
}
|
|
|
|
#setCursor(index: number, width: number, offscreen: boolean): JsonObject {
|
|
const clampedIndex = Math.max(0, Math.min(index, this.lines.length - 1));
|
|
const text = this.lines[clampedIndex]?.text ?? "";
|
|
const mode = pickCursorMode(this.#rng, text, width);
|
|
this.#cursorLineIndex = clampedIndex;
|
|
this.#cursorMode = mode;
|
|
return { index: clampedIndex, mode, offscreen, text };
|
|
}
|
|
|
|
#clampedCursorLineIndex(): number {
|
|
if (this.lines.length === 0) return 0;
|
|
if (this.#cursorLineIndex === null) return this.lines.length - 1;
|
|
return Math.max(0, Math.min(this.#cursorLineIndex, this.lines.length - 1));
|
|
}
|
|
|
|
#line(text: string): LogicalLine {
|
|
const line = { id: this.#nextId, text };
|
|
this.#nextId += 1;
|
|
if (text.length > 0) this.#usedText.add(text);
|
|
return line;
|
|
}
|
|
}
|
|
|
|
class StressComponent implements Component, Focusable {
|
|
focused = false;
|
|
#model: StressModel;
|
|
|
|
constructor(model: StressModel) {
|
|
this.#model = model;
|
|
}
|
|
|
|
invalidate(): void {}
|
|
|
|
render(width: number): string[] {
|
|
return this.#model.renderedLines(width, this.focused);
|
|
}
|
|
}
|
|
|
|
class StressOverlayModel {
|
|
readonly lines: LogicalLine[] = [];
|
|
readonly sentinel: string;
|
|
#rng: Rng;
|
|
#nextId = 0;
|
|
#cursorLineIndex = 0;
|
|
#cursorMode: CursorMode = "middle";
|
|
|
|
constructor(rng: Rng, id: number) {
|
|
this.#rng = rng;
|
|
this.sentinel = `OV_SENTINEL_${id.toString(36)}_`;
|
|
const count = rng.int(1, 5);
|
|
this.lines.push(this.#line(`${this.sentinel}${randomDecoratedText(rng, `ov${id}-0`)}`));
|
|
for (let i = 1; i < count; i++) {
|
|
this.lines.push(this.#line(randomDecoratedText(rng, `ov${id}-${i}`)));
|
|
}
|
|
}
|
|
|
|
renderedLines(width: number, focused = false): string[] {
|
|
const lines = this.lines.map(line => line.text);
|
|
if (!lines.some(line => line.includes(this.sentinel))) lines.unshift(this.sentinel);
|
|
if (focused && lines.length > 0) {
|
|
const index = this.#clampedCursorLineIndex();
|
|
lines[index] = insertCursorMarker(lines[index] ?? "", this.#cursorMode, width);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
mutate(width: number): JsonObject {
|
|
this.#ensureLine();
|
|
const action = this.#rng.int(0, 3);
|
|
if (action === 0 || this.lines.length === 1) {
|
|
const line = this.#freshLine("oa");
|
|
this.lines.push(line);
|
|
return { action: "append", text: line.text };
|
|
}
|
|
if (action === 1) {
|
|
const index = this.#rng.int(0, this.lines.length - 1);
|
|
const before = this.lines[index]?.text ?? "";
|
|
this.lines[index] = this.#freshLine("oe");
|
|
return { action: "edit", index, before, after: this.lines[index]?.text ?? "" };
|
|
}
|
|
if (action === 2) {
|
|
const index = this.#rng.int(0, this.lines.length - 1);
|
|
const removed = this.lines.splice(index, 1);
|
|
return { action: "delete", index, removed: removed[0]?.text ?? "" };
|
|
}
|
|
return { action: "cursor", ...this.setCursor(width) };
|
|
}
|
|
|
|
setCursor(width: number): JsonObject {
|
|
this.#ensureLine();
|
|
const index = this.#rng.int(0, this.lines.length - 1);
|
|
const text = this.lines[index]?.text ?? "";
|
|
const mode = pickCursorMode(this.#rng, text, width);
|
|
this.#cursorLineIndex = index;
|
|
this.#cursorMode = mode;
|
|
return { index, mode, text };
|
|
}
|
|
|
|
debugLines(): string[] {
|
|
return this.lines.map(line => `${line.id}:${JSON.stringify(line.text)}`);
|
|
}
|
|
|
|
#freshLine(prefix: string): LogicalLine {
|
|
const id = this.#nextId.toString(36);
|
|
return this.#line(randomDecoratedText(this.#rng, `${prefix}${id}`));
|
|
}
|
|
|
|
#ensureLine(): void {
|
|
if (this.lines.length === 0) {
|
|
this.lines.push(this.#freshLine("oq"));
|
|
}
|
|
}
|
|
|
|
#clampedCursorLineIndex(): number {
|
|
return Math.max(0, Math.min(this.#cursorLineIndex, this.lines.length - 1));
|
|
}
|
|
|
|
#line(text: string): LogicalLine {
|
|
const line = { id: this.#nextId, text };
|
|
this.#nextId += 1;
|
|
return line;
|
|
}
|
|
}
|
|
|
|
class StressOverlayComponent implements Component, Focusable {
|
|
focused = false;
|
|
#model: StressOverlayModel;
|
|
|
|
constructor(model: StressOverlayModel) {
|
|
this.#model = model;
|
|
}
|
|
|
|
invalidate(): void {}
|
|
|
|
render(width: number): string[] {
|
|
return this.#model.renderedLines(width, this.focused);
|
|
}
|
|
}
|
|
|
|
class StressDriver {
|
|
#scenario: Scenario;
|
|
#rng: Rng;
|
|
#term: VirtualTerminal;
|
|
#tui: TUI;
|
|
#model: StressModel;
|
|
#component: StressComponent;
|
|
#children: StressChildEntry[] = [];
|
|
#overlays: StressOverlayEntry[] = [];
|
|
#hiddenOverlaySentinels = new Set<string>();
|
|
#nextOverlayId = 0;
|
|
#opLog: OperationLogEntry[] = [];
|
|
#nativeScrollbackAuditBlocked = false;
|
|
|
|
constructor(scenario: Scenario) {
|
|
this.#scenario = scenario;
|
|
this.#rng = new Rng(scenario.seed);
|
|
const maxHeight = maxOf(scenario.heightChoices);
|
|
this.#model = new StressModel(this.#rng, maxHeight + 12, scenario.uniqueContent, "root-");
|
|
this.#component = new StressComponent(this.#model);
|
|
this.#children = [0, 1].map(id => {
|
|
const model = new StressModel(
|
|
this.#rng,
|
|
Math.max(1, Math.min(3, maxHeight)),
|
|
scenario.uniqueContent,
|
|
`child${id}-`,
|
|
);
|
|
return { id, model, component: new StressComponent(model), active: false };
|
|
});
|
|
this.#term = createTerminal(scenario);
|
|
this.#tui = new TUI(this.#term, true);
|
|
this.#tui.addChild(this.#component);
|
|
}
|
|
|
|
async run(): Promise<void> {
|
|
try {
|
|
this.#tui.start();
|
|
await settle(this.#term);
|
|
this.#assertOracles(
|
|
{
|
|
kind: "forceRender",
|
|
detail: { initial: true },
|
|
mutatesContent: false,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: true,
|
|
mutatesViewport: false,
|
|
checkpoint: false,
|
|
},
|
|
this.#snapshot(),
|
|
this.#snapshot(),
|
|
-1,
|
|
);
|
|
|
|
for (let index = 0; index < this.#scenario.iterations; index++) {
|
|
const before = this.#snapshot();
|
|
const kind = this.#chooseOperation(index, before);
|
|
const op = await this.#applyOperation(kind);
|
|
const after = this.#snapshot();
|
|
this.#recordOperation(index, op.kind, op.detail, before, after);
|
|
this.#assertOracles(op, before, after, index);
|
|
|
|
if ((index + 1) % 50 === 0) {
|
|
await this.#checkpoint(index, "periodicCheckpoint");
|
|
}
|
|
}
|
|
} finally {
|
|
this.#tui.stop();
|
|
await this.#term.flush();
|
|
}
|
|
}
|
|
|
|
#snapshot(): Snapshot {
|
|
const position = this.#term.getBufferPosition();
|
|
const expected = this.#expectedFrame();
|
|
const view = normalizeLines(this.#term.getViewport());
|
|
// Tmux pane history is intentionally preserved, so overlay bytes can remain
|
|
// in historical scrollback after resize/reflow. The non-strict tmux stress
|
|
// oracle only checks live viewport behavior; avoid repeatedly materializing
|
|
// huge preserved pane history that no invariant consumes.
|
|
return {
|
|
buffer: this.#scenario.envMode === "tmux" ? view : normalizeLines(this.#term.getScrollBuffer()),
|
|
view,
|
|
position,
|
|
cursor: this.#term.getCursor(),
|
|
expectedCursor: expected.cursor,
|
|
redraws: this.#tui.fullRedraws,
|
|
width: this.#term.columns,
|
|
height: this.#term.rows,
|
|
frame: expected.frame,
|
|
atBottom: position.viewportY >= position.baseY,
|
|
};
|
|
}
|
|
|
|
#expectedFrame(): ExpectedFrame {
|
|
const width = this.#term.columns;
|
|
const height = this.#term.rows;
|
|
const baseLines = this.#baseFrameLines(width);
|
|
const composed = compositeExpectedOverlays(baseLines, this.#overlays, width, height);
|
|
return expectedFrameFromLines(composed, width, height);
|
|
}
|
|
|
|
#baseFrameLines(width: number): string[] {
|
|
return [
|
|
...this.#component.render(width),
|
|
...this.#children.flatMap(child => (child.active ? child.component.render(width) : [])),
|
|
];
|
|
}
|
|
|
|
#hasVisibleOverlay(): boolean {
|
|
return this.#overlays.some(entry => isExpectedOverlayVisible(entry, this.#term.columns, this.#term.rows));
|
|
}
|
|
|
|
#chooseOperation(index: number, before: Snapshot): OperationKind {
|
|
if (
|
|
this.#scenario.strictScrollback &&
|
|
before.atBottom &&
|
|
before.frame.length > before.height + 8 &&
|
|
index % 43 === 0
|
|
) {
|
|
return "collapseToFew";
|
|
}
|
|
if (
|
|
this.#scenario.strictScrollback &&
|
|
before.atBottom &&
|
|
before.frame.length > before.height + 8 &&
|
|
!this.#hasVisibleOverlay() &&
|
|
index % 37 === 0
|
|
) {
|
|
return "highWaterPreviewCollapse";
|
|
}
|
|
if (this.#scenario.strictScrollback && before.atBottom && index % 41 === 0) {
|
|
return "offscreenEditAppendRepeatedTail";
|
|
}
|
|
if (!before.atBottom && this.#rng.chance(0.28)) {
|
|
return "scrollToBottom";
|
|
}
|
|
|
|
const weighted: OperationKind[] = [];
|
|
this.#pushWeighted(weighted, "appendSmall", 14);
|
|
this.#pushWeighted(weighted, "streamOne", 12);
|
|
this.#pushWeighted(weighted, "appendRepeatedTail", this.#scenario.uniqueContent ? 2 : 8);
|
|
this.#pushWeighted(weighted, "appendDuplicateOfExisting", this.#scenario.uniqueContent ? 2 : 8);
|
|
this.#pushWeighted(weighted, "injectBlankCluster", 5);
|
|
this.#pushWeighted(weighted, "appendBulk", 3);
|
|
this.#pushWeighted(weighted, "editVisibleLine", 8);
|
|
this.#pushWeighted(weighted, "editOffscreenLine", 7);
|
|
this.#pushWeighted(weighted, "offscreenEditAppendRepeatedTail", 5);
|
|
this.#pushWeighted(weighted, "insertOffscreen", 3);
|
|
this.#pushWeighted(weighted, "insertMiddle", 2);
|
|
this.#pushWeighted(weighted, "deleteTrailing", 3);
|
|
this.#pushWeighted(weighted, "deleteMiddle", 2);
|
|
this.#pushWeighted(weighted, "replaceAll", 1);
|
|
this.#pushWeighted(weighted, "toggleCollapsible", 2);
|
|
this.#pushWeighted(weighted, "tickStatusHeader", 8);
|
|
this.#pushWeighted(weighted, "scrollUp", before.position.baseY > 0 ? 4 : 0);
|
|
this.#pushWeighted(weighted, "scrollPartial", before.position.baseY > 0 ? 3 : 0);
|
|
this.#pushWeighted(weighted, "scrollToBottom", before.atBottom ? 2 : 8);
|
|
this.#pushWeighted(weighted, "resizeWidth", 3);
|
|
this.#pushWeighted(weighted, "resizeHeight", 3);
|
|
this.#pushWeighted(weighted, "forceRender", 2);
|
|
this.#pushWeighted(weighted, "forceRenderAllowUnknown", 2);
|
|
this.#pushWeighted(weighted, "forceRenderClearScrollback", 1);
|
|
this.#pushWeighted(weighted, "forceRenderAfterEmptyOverflow", 1);
|
|
this.#pushWeighted(weighted, "toggleFocusInput", 2);
|
|
this.#pushWeighted(weighted, "moveCursorVisible", 3);
|
|
this.#pushWeighted(weighted, "moveCursorOffscreen", 2);
|
|
this.#pushWeighted(weighted, "showOverlay", this.#overlays.length < 2 ? 3 : 1);
|
|
this.#pushWeighted(weighted, "hideOverlay", this.#overlays.length > 0 ? 2 : 0);
|
|
this.#pushWeighted(weighted, "toggleOverlayHidden", this.#overlays.length > 0 ? 2 : 0);
|
|
this.#pushWeighted(weighted, "editOverlay", this.#overlays.length > 0 ? 4 : 0);
|
|
this.#pushWeighted(weighted, "moveOverlayCursor", this.#overlays.length > 0 ? 2 : 0);
|
|
this.#pushWeighted(weighted, "coalescedBurst", 6);
|
|
this.#pushWeighted(weighted, "rotateUp", 4);
|
|
this.#pushWeighted(weighted, "swapOffscreenRows", 3);
|
|
this.#pushWeighted(weighted, "collapseToFew", 1);
|
|
this.#pushWeighted(weighted, "highWaterPreviewCollapse", 2);
|
|
this.#pushWeighted(weighted, "resizeBoth", 2);
|
|
this.#pushWeighted(weighted, "resizeNoop", 1);
|
|
this.#pushWeighted(weighted, "attachChild", this.#children.some(child => !child.active) ? 2 : 0);
|
|
this.#pushWeighted(weighted, "detachChild", this.#children.some(child => child.active) ? 2 : 0);
|
|
this.#pushWeighted(weighted, "reorderChildren", this.#children.filter(child => child.active).length > 1 ? 1 : 0);
|
|
this.#pushWeighted(weighted, "mutateChild", this.#children.some(child => child.active) ? 3 : 0);
|
|
return this.#rng.pick(weighted);
|
|
}
|
|
|
|
#pushWeighted(target: OperationKind[], kind: OperationKind, weight: number): void {
|
|
for (let i = 0; i < weight; i++) {
|
|
target.push(kind);
|
|
}
|
|
}
|
|
|
|
async #applyOperation(kind: OperationKind): Promise<AppliedOperation> {
|
|
switch (kind) {
|
|
case "appendSmall":
|
|
return await this.#applyContent(kind, this.#model.appendSmall(), true);
|
|
case "appendBulk":
|
|
return await this.#applyContent(kind, this.#model.appendBulk(this.#scenario.bulkMax), true);
|
|
case "streamOne":
|
|
return await this.#applyContent(kind, this.#model.streamOne(), true);
|
|
case "editVisibleLine":
|
|
return await this.#applyContent(kind, this.#model.editVisibleLine(this.#term.rows), true);
|
|
case "editOffscreenLine":
|
|
return await this.#applyContent(kind, this.#model.editOffscreenLine(this.#term.rows), true);
|
|
case "offscreenEditAppendRepeatedTail":
|
|
return await this.#applyContent(kind, this.#model.offscreenEditAppendRepeatedTail(this.#term.rows), true);
|
|
case "insertOffscreen":
|
|
return await this.#applyContent(kind, this.#model.insertOffscreen(this.#term.rows), true);
|
|
case "insertMiddle":
|
|
return await this.#applyContent(kind, this.#model.insertMiddle(), true);
|
|
case "deleteTrailing":
|
|
return await this.#applyContent(kind, this.#model.deleteTrailing(), false);
|
|
case "deleteMiddle":
|
|
return await this.#applyContent(kind, this.#model.deleteMiddle(this.#term.rows), true);
|
|
case "replaceAll":
|
|
return await this.#applyContent(kind, this.#model.replaceAll(), true);
|
|
case "toggleCollapsible":
|
|
return await this.#applyContent(kind, this.#model.toggleCollapsible(), true);
|
|
case "tickStatusHeader":
|
|
return await this.#applyContent(kind, this.#model.tickStatusHeader(), true);
|
|
case "appendRepeatedTail":
|
|
return await this.#applyContent(kind, this.#model.appendRepeatedTail(), true);
|
|
case "injectBlankCluster":
|
|
return await this.#applyContent(kind, this.#model.injectBlankCluster(), true);
|
|
case "appendDuplicateOfExisting":
|
|
return await this.#applyContent(kind, this.#model.appendDuplicateOfExisting(), true);
|
|
case "highWaterPreviewCollapse":
|
|
return await this.#highWaterPreviewCollapse();
|
|
case "scrollUp":
|
|
return await this.#scrollUp();
|
|
case "scrollToBottom":
|
|
return await this.#scrollToBottom();
|
|
case "scrollPartial":
|
|
return await this.#scrollPartial();
|
|
case "resizeWidth":
|
|
return await this.#resizeWidth();
|
|
case "resizeHeight":
|
|
return await this.#resizeHeight();
|
|
case "forceRender":
|
|
return await this.#forceRender();
|
|
case "forceRenderAllowUnknown":
|
|
return await this.#forceRenderAllowUnknown();
|
|
case "forceRenderClearScrollback":
|
|
return await this.#forceRenderClearScrollback();
|
|
case "forceRenderAfterEmptyOverflow":
|
|
return await this.#forceRenderAfterEmptyOverflow();
|
|
case "toggleFocusInput":
|
|
return await this.#toggleFocusInput();
|
|
case "moveCursorVisible":
|
|
return await this.#moveBaseCursor("moveCursorVisible", false);
|
|
case "moveCursorOffscreen":
|
|
return await this.#moveBaseCursor("moveCursorOffscreen", true);
|
|
case "showOverlay":
|
|
return await this.#showOverlay();
|
|
case "hideOverlay":
|
|
return await this.#hideOverlay();
|
|
case "toggleOverlayHidden":
|
|
return await this.#toggleOverlayHidden();
|
|
case "editOverlay":
|
|
return await this.#editOverlay();
|
|
case "moveOverlayCursor":
|
|
return await this.#moveOverlayCursor();
|
|
case "rotateUp":
|
|
return await this.#applyContent(kind, this.#model.rotateUp(), false);
|
|
case "collapseToFew":
|
|
return await this.#applyContent(kind, this.#model.collapseToFew(), false);
|
|
case "swapOffscreenRows":
|
|
return await this.#applyContent(kind, this.#model.swapOffscreenRows(this.#term.rows), false);
|
|
case "coalescedBurst":
|
|
return await this.#coalescedBurst();
|
|
case "resizeBoth":
|
|
return await this.#resizeBoth();
|
|
case "resizeNoop":
|
|
return await this.#resizeNoop();
|
|
case "attachChild":
|
|
return await this.#attachChild();
|
|
case "detachChild":
|
|
return await this.#detachChild();
|
|
case "reorderChildren":
|
|
return await this.#reorderChildren();
|
|
case "mutateChild":
|
|
return await this.#mutateChild();
|
|
}
|
|
}
|
|
|
|
async #applyContent(
|
|
kind: OperationKind,
|
|
detail: JsonObject,
|
|
checksRowAccounting: boolean,
|
|
): Promise<AppliedOperation> {
|
|
this.#renderContentFrame();
|
|
await settle(this.#term);
|
|
return {
|
|
kind,
|
|
detail,
|
|
mutatesContent: true,
|
|
checksRowAccounting,
|
|
geometryChanged: false,
|
|
forcedRender: false,
|
|
mutatesViewport: false,
|
|
checkpoint: false,
|
|
};
|
|
}
|
|
|
|
#renderContentFrame(): void {
|
|
const position = this.#term.getBufferPosition();
|
|
const atBottom = position.viewportY >= position.baseY;
|
|
if (!this.#scenario.strictScrollback && atBottom) {
|
|
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
|
|
} else {
|
|
const allowUnknownViewportMutation = this.#scenario.terminalMode === "unknown" && atBottom;
|
|
this.#tui.requestRender(
|
|
false,
|
|
allowUnknownViewportMutation ? { allowUnknownViewportMutation: true } : undefined,
|
|
);
|
|
}
|
|
}
|
|
|
|
async #highWaterPreviewCollapse(): Promise<AppliedOperation> {
|
|
const begin = this.#model.beginHighWaterPreview(this.#term.rows);
|
|
this.#renderContentFrame();
|
|
await settle(this.#term);
|
|
const start = typeof begin.start === "number" ? begin.start : 0;
|
|
const count = typeof begin.count === "number" ? begin.count : 0;
|
|
const collapse = this.#model.collapseHighWaterPreview(start, count);
|
|
this.#renderContentFrame();
|
|
await settle(this.#term);
|
|
return {
|
|
kind: "highWaterPreviewCollapse",
|
|
detail: { begin, collapse },
|
|
mutatesContent: true,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: false,
|
|
mutatesViewport: false,
|
|
checkpoint: false,
|
|
};
|
|
}
|
|
|
|
async #coalescedBurst(): Promise<AppliedOperation> {
|
|
const count = this.#rng.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 detail = this.#applyBurstStep(stepKind);
|
|
steps.push({ kind: stepKind, detail });
|
|
mutatesContent ||=
|
|
stepKind !== "resizeWidth" &&
|
|
stepKind !== "resizeHeight" &&
|
|
stepKind !== "scrollPartial" &&
|
|
stepKind !== "scrollToBottom" &&
|
|
stepKind !== "forceRender";
|
|
geometryChanged ||= stepKind === "resizeWidth" || stepKind === "resizeHeight";
|
|
mutatesViewport ||=
|
|
stepKind === "resizeWidth" ||
|
|
stepKind === "resizeHeight" ||
|
|
stepKind === "scrollPartial" ||
|
|
stepKind === "scrollToBottom" ||
|
|
stepKind === "forceRender";
|
|
forcedRender ||= stepKind === "forceRender";
|
|
// Schedule without settling so the throttle coalesces every step into one paint.
|
|
if (stepKind !== "forceRender") this.#tui.requestRender();
|
|
}
|
|
this.#renderContentFrame();
|
|
await settle(this.#term);
|
|
return {
|
|
kind: "coalescedBurst",
|
|
detail: { count, steps },
|
|
mutatesContent,
|
|
checksRowAccounting: false,
|
|
geometryChanged,
|
|
forcedRender,
|
|
mutatesViewport,
|
|
checkpoint: false,
|
|
coalesced: true,
|
|
};
|
|
}
|
|
|
|
#applyBurstStep(kind: BurstStepKind): JsonObject {
|
|
switch (kind) {
|
|
case "appendSmall":
|
|
return this.#model.appendSmall();
|
|
case "streamOne":
|
|
return this.#model.streamOne();
|
|
case "appendRepeatedTail":
|
|
return this.#model.appendRepeatedTail();
|
|
case "injectBlankCluster":
|
|
return this.#model.injectBlankCluster();
|
|
case "editVisibleLine":
|
|
return this.#model.editVisibleLine(this.#term.rows);
|
|
case "editOffscreenLine":
|
|
return this.#model.editOffscreenLine(this.#term.rows);
|
|
case "tickStatusHeader":
|
|
return this.#model.tickStatusHeader();
|
|
case "resizeWidth": {
|
|
const columns = this.#pickDifferent(this.#scenario.widthChoices, this.#term.columns);
|
|
this.#term.resize(columns, this.#term.rows);
|
|
return { columns };
|
|
}
|
|
case "resizeHeight": {
|
|
const rows = this.#pickDifferent(this.#scenario.heightChoices, this.#term.rows);
|
|
this.#term.resize(this.#term.columns, rows);
|
|
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;
|
|
this.#term.scrollLines(direction * amount);
|
|
return { amount: direction * amount };
|
|
}
|
|
case "scrollToBottom":
|
|
this.#term.scrollLines(LARGE_SCROLL);
|
|
return { amount: LARGE_SCROLL };
|
|
case "forceRender":
|
|
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
|
|
return { allowUnknownViewportMutation: true };
|
|
}
|
|
}
|
|
|
|
async #moveBaseCursor(
|
|
kind: "moveCursorVisible" | "moveCursorOffscreen",
|
|
offscreen: boolean,
|
|
): Promise<AppliedOperation> {
|
|
const cursor = offscreen
|
|
? 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 settle(this.#term);
|
|
return this.#viewOperation(kind, { cursor });
|
|
}
|
|
|
|
async #showOverlay(): Promise<AppliedOperation> {
|
|
const id = this.#nextOverlayId;
|
|
this.#nextOverlayId += 1;
|
|
const model = new StressOverlayModel(this.#rng, id);
|
|
const component = new StressOverlayComponent(model);
|
|
const { options, detail } = this.#randomOverlayOptions();
|
|
const handle = this.#tui.showOverlay(component, options);
|
|
const entry: StressOverlayEntry = {
|
|
id,
|
|
sentinel: model.sentinel,
|
|
model,
|
|
component,
|
|
handle,
|
|
options,
|
|
hidden: false,
|
|
detail,
|
|
};
|
|
this.#overlays.push(entry);
|
|
await settle(this.#term);
|
|
return this.#viewOperation("showOverlay", {
|
|
id,
|
|
sentinel: model.sentinel,
|
|
options: detail,
|
|
lines: model.debugLines(),
|
|
});
|
|
}
|
|
|
|
async #hideOverlay(): Promise<AppliedOperation> {
|
|
const entry = this.#pickOverlay();
|
|
if (entry === undefined) return this.#viewOperation("hideOverlay", { skipped: true });
|
|
entry.handle.hide();
|
|
this.#overlays = this.#overlays.filter(overlay => overlay !== entry);
|
|
this.#hiddenOverlaySentinels.add(entry.sentinel);
|
|
await settle(this.#term);
|
|
return this.#viewOperation("hideOverlay", { id: entry.id, sentinel: entry.sentinel });
|
|
}
|
|
|
|
async #toggleOverlayHidden(): Promise<AppliedOperation> {
|
|
const entry = this.#pickOverlay();
|
|
if (entry === undefined) return this.#viewOperation("toggleOverlayHidden", { skipped: true });
|
|
entry.hidden = !entry.hidden;
|
|
entry.handle.setHidden(entry.hidden);
|
|
if (entry.hidden) this.#hiddenOverlaySentinels.add(entry.sentinel);
|
|
await settle(this.#term);
|
|
return this.#viewOperation("toggleOverlayHidden", {
|
|
id: entry.id,
|
|
sentinel: entry.sentinel,
|
|
hidden: entry.hidden,
|
|
});
|
|
}
|
|
|
|
async #editOverlay(): Promise<AppliedOperation> {
|
|
const entry = this.#pickOverlay();
|
|
if (entry === undefined) return this.#viewOperation("editOverlay", { skipped: true });
|
|
const detail = entry.model.mutate(this.#term.columns);
|
|
this.#tui.requestRender(false, { allowUnknownViewportMutation: true });
|
|
await settle(this.#term);
|
|
return this.#viewOperation("editOverlay", { id: entry.id, detail });
|
|
}
|
|
|
|
async #moveOverlayCursor(): Promise<AppliedOperation> {
|
|
const entry = this.#pickOverlay();
|
|
if (entry === undefined) return this.#viewOperation("moveOverlayCursor", { skipped: true });
|
|
const cursor = entry.model.setCursor(this.#term.columns);
|
|
this.#tui.setFocus(entry.component);
|
|
this.#tui.requestRender(false, { allowUnknownViewportMutation: true });
|
|
await settle(this.#term);
|
|
return this.#viewOperation("moveOverlayCursor", { id: entry.id, cursor });
|
|
}
|
|
|
|
#pickOverlay(): StressOverlayEntry | undefined {
|
|
if (this.#overlays.length === 0) return undefined;
|
|
return this.#overlays[this.#rng.int(0, this.#overlays.length - 1)];
|
|
}
|
|
|
|
#randomOverlayOptions(): { options: OverlayOptions; detail: JsonObject } {
|
|
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));
|
|
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));
|
|
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));
|
|
options.minWidth = minWidth;
|
|
detail.minWidth = minWidth;
|
|
}
|
|
if (this.#rng.chance(0.5)) {
|
|
const anchor = this.#rng.pick(OVERLAY_ANCHORS);
|
|
options.anchor = anchor;
|
|
options.offsetX = this.#rng.int(-3, 3);
|
|
options.offsetY = this.#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);
|
|
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);
|
|
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),
|
|
};
|
|
options.margin = margin;
|
|
detail.margin = margin;
|
|
}
|
|
}
|
|
return { options, detail };
|
|
}
|
|
|
|
async #resizeBoth(): Promise<AppliedOperation> {
|
|
const columns = this.#pickDifferent(this.#scenario.widthChoices, this.#term.columns);
|
|
const rows = this.#pickDifferent(this.#scenario.heightChoices, this.#term.rows);
|
|
this.#term.resize(columns, rows);
|
|
if (!this.#scenario.strictScrollback) {
|
|
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
|
|
}
|
|
await settle(this.#term);
|
|
return {
|
|
kind: "resizeBoth",
|
|
detail: { columns, rows },
|
|
mutatesContent: false,
|
|
checksRowAccounting: false,
|
|
geometryChanged: true,
|
|
forcedRender: false,
|
|
mutatesViewport: true,
|
|
checkpoint: false,
|
|
};
|
|
}
|
|
|
|
async #resizeNoop(): Promise<AppliedOperation> {
|
|
this.#term.resize(this.#term.columns, this.#term.rows);
|
|
await settle(this.#term);
|
|
return {
|
|
kind: "resizeNoop",
|
|
detail: { columns: this.#term.columns, rows: this.#term.rows },
|
|
mutatesContent: false,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: false,
|
|
mutatesViewport: false,
|
|
checkpoint: false,
|
|
};
|
|
}
|
|
|
|
async #scrollUp(): Promise<AppliedOperation> {
|
|
const amount = this.#rng.int(1, Math.max(1, this.#term.rows * 2));
|
|
this.#term.scrollLines(-amount);
|
|
await settle(this.#term);
|
|
return this.#viewOperation("scrollUp", { amount });
|
|
}
|
|
|
|
async #scrollToBottom(): Promise<AppliedOperation> {
|
|
this.#term.scrollLines(LARGE_SCROLL);
|
|
this.#tui.requestRender(true, {
|
|
allowUnknownViewportMutation: true,
|
|
clearScrollback: this.#scenario.strictScrollback,
|
|
});
|
|
await settle(this.#term);
|
|
return {
|
|
kind: "scrollToBottom",
|
|
detail: { forcedCheckpoint: this.#scenario.strictScrollback },
|
|
mutatesContent: false,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: true,
|
|
mutatesViewport: true,
|
|
checkpoint: 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;
|
|
this.#term.scrollLines(direction * amount);
|
|
await settle(this.#term);
|
|
return this.#viewOperation("scrollPartial", { amount: direction * amount });
|
|
}
|
|
async #resizeWidth(): Promise<AppliedOperation> {
|
|
const columns = this.#pickDifferent(this.#scenario.widthChoices, this.#term.columns);
|
|
this.#term.resize(columns, this.#term.rows);
|
|
if (!this.#scenario.strictScrollback) {
|
|
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
|
|
}
|
|
await settle(this.#term);
|
|
return {
|
|
kind: "resizeWidth",
|
|
detail: { columns },
|
|
mutatesContent: false,
|
|
checksRowAccounting: false,
|
|
geometryChanged: true,
|
|
forcedRender: false,
|
|
mutatesViewport: true,
|
|
checkpoint: false,
|
|
};
|
|
}
|
|
|
|
async #resizeHeight(): Promise<AppliedOperation> {
|
|
const rows = this.#pickDifferent(this.#scenario.heightChoices, this.#term.rows);
|
|
this.#term.resize(this.#term.columns, rows);
|
|
if (!this.#scenario.strictScrollback) {
|
|
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
|
|
}
|
|
await settle(this.#term);
|
|
return {
|
|
kind: "resizeHeight",
|
|
detail: { rows },
|
|
mutatesContent: false,
|
|
checksRowAccounting: false,
|
|
geometryChanged: true,
|
|
forcedRender: false,
|
|
mutatesViewport: true,
|
|
checkpoint: false,
|
|
};
|
|
}
|
|
|
|
async #forceRender(): Promise<AppliedOperation> {
|
|
this.#tui.requestRender(true);
|
|
await settle(this.#term);
|
|
return this.#forceOperation("forceRender", {});
|
|
}
|
|
|
|
async #forceRenderAllowUnknown(): Promise<AppliedOperation> {
|
|
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
|
|
await settle(this.#term);
|
|
return this.#forceOperation("forceRenderAllowUnknown", { allowUnknownViewportMutation: true });
|
|
}
|
|
|
|
async #forceRenderClearScrollback(): Promise<AppliedOperation> {
|
|
this.#term.scrollLines(LARGE_SCROLL);
|
|
this.#tui.requestRender(true, { allowUnknownViewportMutation: true, clearScrollback: true });
|
|
await settle(this.#term);
|
|
return { ...this.#forceOperation("forceRenderClearScrollback", { clearScrollback: true }), checkpoint: true };
|
|
}
|
|
|
|
async #forceRenderAfterEmptyOverflow(): Promise<AppliedOperation> {
|
|
const detachedChildren: number[] = [];
|
|
for (const child of this.#children) {
|
|
if (!child.active) continue;
|
|
child.active = false;
|
|
detachedChildren.push(child.id);
|
|
this.#tui.removeChild(child.component);
|
|
}
|
|
const empty = this.#model.clear();
|
|
this.#tui.requestRender(true, { allowUnknownViewportMutation: true, clearScrollback: true });
|
|
await settle(this.#term);
|
|
const overflow = this.#model.appendCount(this.#term.rows + this.#rng.int(1, 4), "overflow");
|
|
this.#tui.requestRender(true, { allowUnknownViewportMutation: true });
|
|
await settle(this.#term);
|
|
return {
|
|
...this.#forceOperation("forceRenderAfterEmptyOverflow", { detachedChildren, empty, overflow }),
|
|
mutatesContent: true,
|
|
};
|
|
}
|
|
|
|
#forceOperation(kind: OperationKind, detail: JsonObject): AppliedOperation {
|
|
return {
|
|
kind,
|
|
detail,
|
|
mutatesContent: false,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: true,
|
|
mutatesViewport: kind === "forceRenderClearScrollback" || kind === "forceRenderAfterEmptyOverflow",
|
|
checkpoint: false,
|
|
};
|
|
}
|
|
|
|
async #toggleFocusInput(): Promise<AppliedOperation> {
|
|
let cursor: JsonObject | null = null;
|
|
if (this.#component.focused) {
|
|
this.#tui.setFocus(null);
|
|
} else {
|
|
cursor = this.#rng.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 settle(this.#term);
|
|
return {
|
|
kind: "toggleFocusInput",
|
|
detail: { focused: this.#component.focused, cursor },
|
|
mutatesContent: false,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: false,
|
|
mutatesViewport: false,
|
|
checkpoint: false,
|
|
};
|
|
}
|
|
|
|
// Container.addChild appends and Container.render walks children in array
|
|
// order, so re-attaching a lower-id child after a higher-id one is already
|
|
// active would leave the TUI ordered [child1, child0] while #expectedFrame
|
|
// renders them in this.#children index order [child0, child1]. Rebuild the
|
|
// TUI child list from the canonical this.#children order so the model and the
|
|
// real frame always agree regardless of attach/detach sequencing.
|
|
#syncChildOrder(): void {
|
|
for (const child of this.#children) this.#tui.removeChild(child.component);
|
|
this.#tui.removeChild(this.#component);
|
|
this.#tui.addChild(this.#component);
|
|
for (const child of this.#children) {
|
|
if (child.active) this.#tui.addChild(child.component);
|
|
}
|
|
}
|
|
|
|
async #attachChild(): Promise<AppliedOperation> {
|
|
const child = this.#children.find(entry => !entry.active);
|
|
if (child === undefined) return this.#viewOperation("attachChild", { skipped: true });
|
|
child.active = true;
|
|
this.#syncChildOrder();
|
|
this.#renderContentFrame();
|
|
await settle(this.#term);
|
|
return {
|
|
kind: "attachChild",
|
|
detail: { id: child.id, lines: child.model.debugLines() },
|
|
mutatesContent: true,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: false,
|
|
mutatesViewport: false,
|
|
checkpoint: 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)];
|
|
if (child === undefined) return this.#viewOperation("detachChild", { skipped: true });
|
|
child.active = false;
|
|
this.#tui.removeChild(child.component);
|
|
this.#renderContentFrame();
|
|
await settle(this.#term);
|
|
return {
|
|
kind: "detachChild",
|
|
detail: { id: child.id },
|
|
mutatesContent: true,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: false,
|
|
mutatesViewport: false,
|
|
checkpoint: false,
|
|
};
|
|
}
|
|
|
|
async #reorderChildren(): Promise<AppliedOperation> {
|
|
const active = this.#children.filter(entry => entry.active);
|
|
if (active.length < 2) return this.#viewOperation("reorderChildren", { skipped: true });
|
|
const first = this.#children.shift();
|
|
if (first !== undefined) this.#children.push(first);
|
|
this.#syncChildOrder();
|
|
this.#renderContentFrame();
|
|
await settle(this.#term);
|
|
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,
|
|
};
|
|
}
|
|
|
|
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)];
|
|
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);
|
|
this.#renderContentFrame();
|
|
await settle(this.#term);
|
|
return {
|
|
kind: "mutateChild",
|
|
detail: { id: child.id, detail },
|
|
mutatesContent: true,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: false,
|
|
mutatesViewport: false,
|
|
checkpoint: false,
|
|
};
|
|
}
|
|
|
|
#viewOperation(kind: OperationKind, detail: JsonObject): AppliedOperation {
|
|
return {
|
|
kind,
|
|
detail,
|
|
mutatesContent: false,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: false,
|
|
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);
|
|
}
|
|
|
|
async #checkpoint(index: number, kind: "periodicCheckpoint"): Promise<void> {
|
|
const before = this.#snapshot();
|
|
this.#term.scrollLines(LARGE_SCROLL);
|
|
this.#tui.requestRender(true, {
|
|
allowUnknownViewportMutation: true,
|
|
clearScrollback: this.#scenario.strictScrollback,
|
|
});
|
|
await settle(this.#term);
|
|
const after = this.#snapshot();
|
|
this.#recordOperation(index, kind, { forcedCheckpoint: this.#scenario.strictScrollback }, before, after);
|
|
this.#assertOracles(
|
|
{
|
|
kind: "scrollToBottom",
|
|
detail: { periodic: true },
|
|
mutatesContent: false,
|
|
checksRowAccounting: false,
|
|
geometryChanged: false,
|
|
forcedRender: true,
|
|
mutatesViewport: true,
|
|
checkpoint: true,
|
|
},
|
|
before,
|
|
after,
|
|
index,
|
|
);
|
|
}
|
|
|
|
#recordOperation(
|
|
index: number,
|
|
kind: OperationKind | "periodicCheckpoint",
|
|
detail: JsonObject,
|
|
before: Snapshot,
|
|
after: Snapshot,
|
|
): void {
|
|
this.#opLog.push({
|
|
index,
|
|
kind,
|
|
detail,
|
|
frameLengthBefore: before.frame.length,
|
|
frameLengthAfter: after.frame.length,
|
|
bufferLengthBefore: before.buffer.length,
|
|
bufferLengthAfter: after.buffer.length,
|
|
viewportYBefore: before.position.viewportY,
|
|
viewportYAfter: after.position.viewportY,
|
|
baseYBefore: before.position.baseY,
|
|
baseYAfter: after.position.baseY,
|
|
redrawsBefore: before.redraws,
|
|
redrawsAfter: after.redraws,
|
|
});
|
|
}
|
|
#assertOracles(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
this.#assertViewportFidelity(op, before, after, index);
|
|
this.#assertCleanBufferWhenAligned(op, before, after, index);
|
|
this.#assertNoFrameNeutralScrollbackGrowth(op, before, after, index);
|
|
this.#assertCursor(op, before, after, index);
|
|
this.#assertScrolledDeferral(op, before, after, index);
|
|
this.#assertRowAccounting(op, before, after, index);
|
|
this.#assertScrollbackGrowthMatchesFrameGrowth(op, before, after, index);
|
|
this.#assertHistoryPrefixStability(op, before, after, index);
|
|
this.#assertNativeScrollbackReplay(op, before, after, index);
|
|
this.#assertNoStaleOverlaySentinels(op, before, after, index);
|
|
this.#assertUniqueContentNoUnexpectedDuplicates(op, before, after, index);
|
|
if (op.checkpoint && this.#scenario.strictScrollback) {
|
|
this.#assertCleanBuffer(op, before, after, index);
|
|
}
|
|
}
|
|
|
|
#assertViewportFidelity(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
if (this.#hasVisibleOverlay()) return;
|
|
if (!after.atBottom) return;
|
|
// Strict bottom-anchoring only holds when the buffer carries no ghost/stale
|
|
// extra rows. A trailing shrink clears the bottom row in place (it cannot pull
|
|
// a scrollback line down without a disruptive full repaint), leaving the
|
|
// content top-aligned with a ghost blank below — buffer.length then exceeds
|
|
// the clean expectation until the next forced repaint/checkpoint re-anchors it.
|
|
if (after.buffer.length !== this.#expectedScrollbackBuffer(after).length) return;
|
|
const expected = expectedViewport(after.frame, after.height);
|
|
if (!sameLines(after.view, expected)) {
|
|
this.#fail("viewport fidelity", op, before, after, index, { expected });
|
|
}
|
|
}
|
|
|
|
#assertCleanBufferWhenAligned(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
if (!this.#scenario.strictScrollback || !after.atBottom || op.geometryChanged) return;
|
|
if (this.#hasVisibleOverlay()) return;
|
|
if (!this.#bufferReflectsFrame(before.buffer, before.frame, before.height)) return;
|
|
const expected = this.#expectedScrollbackBuffer(after);
|
|
if (after.buffer.length !== expected.length) return;
|
|
if (!sameLines(after.buffer, expected)) {
|
|
this.#fail("aligned buffer fidelity", op, before, after, index, {
|
|
expectedLength: expected.length,
|
|
actualLength: after.buffer.length,
|
|
});
|
|
}
|
|
}
|
|
|
|
#assertNoFrameNeutralScrollbackGrowth(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
if (this.#hasVisibleOverlay()) return;
|
|
if (!this.#scenario.strictScrollback || op.checkpoint || op.geometryChanged) return;
|
|
if (!before.atBottom || !after.atBottom) return;
|
|
if (!sameLines(before.frame, after.frame)) return;
|
|
if (after.buffer.length > before.buffer.length) {
|
|
if (this.#isCleanBuffer(after.buffer, after.frame, after.height)) return;
|
|
this.#fail("frame-neutral scrollback growth", op, before, after, index, {
|
|
beforeLength: before.buffer.length,
|
|
afterLength: after.buffer.length,
|
|
});
|
|
}
|
|
}
|
|
|
|
#assertCursor(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
if (this.#hasVisibleOverlay()) return;
|
|
if (after.cursor.row < 0 || after.cursor.row >= after.height || after.cursor.col < 0) {
|
|
this.#fail("cursor bounds", op, before, after, index, { cursor: cursorObject(after) });
|
|
}
|
|
const expectedCursor = after.expectedCursor;
|
|
if (expectedCursor === null || !after.atBottom) return;
|
|
// Exact cursor parking is only predictable when the buffer is bottom-anchored
|
|
// (no ghost/stale rows). After a trailing shrink the cursor sits on the
|
|
// de-anchored last content row, which is checked once a repaint re-anchors.
|
|
if (after.buffer.length !== this.#expectedScrollbackBuffer(after).length) return;
|
|
if (after.cursor.row !== expectedCursor.row) {
|
|
this.#fail("focused cursor row", op, before, after, index, {
|
|
expectedRow: expectedCursor.row,
|
|
actualRow: after.cursor.row,
|
|
actualCol: after.cursor.col,
|
|
});
|
|
}
|
|
// Cursor column is a terminal cell offset, not a UTF-16 length. When the
|
|
// marker is at or beyond the right margin, CHA clamping/pending-wrap details
|
|
// are terminal-dependent, so only assert exact columns that fit in-view.
|
|
if (expectedCursor.col < after.width && after.cursor.col !== expectedCursor.col) {
|
|
this.#fail("focused cursor column", op, before, after, index, {
|
|
expectedCol: expectedCursor.col,
|
|
actualCol: after.cursor.col,
|
|
actualRow: after.cursor.row,
|
|
});
|
|
}
|
|
}
|
|
|
|
#assertScrolledDeferral(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
if (!op.mutatesContent || before.atBottom) return;
|
|
if (op.mutatesViewport || op.geometryChanged || op.checkpoint) return;
|
|
if (this.#scenario.terminalMode !== "normal" && this.#scenario.platform !== "win32") return;
|
|
if (after.position.viewportY !== before.position.viewportY) {
|
|
this.#fail("scrolled viewport moved during content mutation", op, before, after, index, {
|
|
expectedViewportY: before.position.viewportY,
|
|
actualViewportY: after.position.viewportY,
|
|
});
|
|
}
|
|
|
|
// The anti-yank contract while scrolled into history: the viewport must not
|
|
// move (asserted above) and the visible rows that come from committed
|
|
// scrollback (history) must not be rewritten by a deferred content mutation.
|
|
// Rows below the history boundary belong to the live region and may legitimately
|
|
// repaint — e.g. a deferred shrink pads and repaints the live viewport, and a
|
|
// partial scroll (by < height) keeps the top live row on screen.
|
|
const historyVisible = Math.max(0, Math.min(before.position.baseY - before.position.viewportY, before.height));
|
|
for (let i = 0; i < historyVisible; i++) {
|
|
if (after.view[i] !== before.view[i]) {
|
|
this.#fail("scrolled history row rewritten during deferred content mutation", op, before, after, index, {
|
|
row: i,
|
|
historyVisible,
|
|
beforeRow: before.view[i] ?? null,
|
|
afterRow: after.view[i] ?? null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#assertRowAccounting(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
if (!this.#scenario.strictScrollback || this.#hasVisibleOverlay()) return;
|
|
if (!op.mutatesContent || !op.checksRowAccounting || op.geometryChanged || op.forcedRender) return;
|
|
if (!before.atBottom || !after.atBottom) return;
|
|
if (this.#scrollbackCapReached(before) || this.#scrollbackCapReached(after)) return;
|
|
if (before.redraws !== after.redraws) return;
|
|
// Row accounting is only meaningful once content overflows the viewport. While
|
|
// content fits within `height`, xterm pins buffer.length at `height`, so a
|
|
// content row added inside the viewport grows the buffer by 0 — `ΔB == ΔF`
|
|
// does not apply until rows are actually being pushed into scrollback.
|
|
if (before.frame.length < before.height) return;
|
|
const deltaFrame = after.frame.length - before.frame.length;
|
|
if (deltaFrame < 0) return;
|
|
const deltaBuffer = after.buffer.length - before.buffer.length;
|
|
const incremental = deltaBuffer === deltaFrame;
|
|
const clean = this.#isCleanBuffer(after.buffer, after.frame, after.height);
|
|
if (!incremental && !clean) {
|
|
this.#fail("buffer row accounting", op, before, after, index, {
|
|
deltaFrame,
|
|
deltaBuffer,
|
|
clean,
|
|
expected: "deltaBuffer === deltaFrame OR clean full reconstruction",
|
|
});
|
|
}
|
|
}
|
|
|
|
#assertScrollbackGrowthMatchesFrameGrowth(
|
|
op: AppliedOperation,
|
|
before: Snapshot,
|
|
after: Snapshot,
|
|
index: number,
|
|
): void {
|
|
if (!this.#scenario.strictScrollback || this.#hasVisibleOverlay()) return;
|
|
if (op.checkpoint || op.geometryChanged) return;
|
|
if (!before.atBottom || !after.atBottom) return;
|
|
const deltaBuffer = after.buffer.length - before.buffer.length;
|
|
if (this.#scrollbackCapReached(before) || this.#scrollbackCapReached(after)) return;
|
|
if (deltaBuffer <= 0) return;
|
|
const clean = this.#isCleanBuffer(after.buffer, after.frame, after.height);
|
|
if (clean) return;
|
|
const deltaFrame = Math.max(0, after.frame.length - before.frame.length);
|
|
if (deltaBuffer > deltaFrame) {
|
|
this.#fail("scrollback grew faster than frame", op, before, after, index, {
|
|
deltaFrame,
|
|
deltaBuffer,
|
|
expected: "dirty live scrollback growth must not exceed logical frame growth",
|
|
});
|
|
}
|
|
const expectedTail = after.frame.slice(after.frame.length - deltaBuffer);
|
|
const actualTail = after.buffer.slice(after.buffer.length - deltaBuffer);
|
|
if (!sameLines(actualTail, expectedTail)) {
|
|
this.#fail("scrollback growth tail mismatch", op, before, after, index, {
|
|
deltaBuffer,
|
|
expectedTail,
|
|
actualTail,
|
|
});
|
|
}
|
|
}
|
|
|
|
#assertHistoryPrefixStability(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
if (!this.#scenario.strictScrollback) return;
|
|
if (this.#scrollbackCapReached(before) || this.#scrollbackCapReached(after)) return;
|
|
if (!op.mutatesContent || before.redraws !== after.redraws) return;
|
|
const prefixLength = Math.max(0, Math.min(before.position.viewportY, before.buffer.length));
|
|
const beforePrefix = before.buffer.slice(0, prefixLength);
|
|
const afterPrefix = after.buffer.slice(0, prefixLength);
|
|
if (!sameLines(beforePrefix, afterPrefix)) {
|
|
this.#fail("scrollback prefix changed without redraw", op, before, after, index, {
|
|
prefixLength,
|
|
beforePrefix,
|
|
afterPrefix,
|
|
});
|
|
}
|
|
}
|
|
|
|
#assertNativeScrollbackReplay(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
if (!this.#scenario.strictScrollback) return;
|
|
if (op.geometryChanged) {
|
|
this.#nativeScrollbackAuditBlocked = true;
|
|
return;
|
|
}
|
|
if (this.#hasVisibleOverlay()) return;
|
|
if (this.#nativeScrollbackAuditBlocked && !op.checkpoint) return;
|
|
if (!after.atBottom) return;
|
|
if (!op.mutatesContent && !op.forcedRender && !op.checkpoint) return;
|
|
const expected = this.#expectedScrollbackBuffer(after);
|
|
if (!sameLines(after.buffer, expected)) {
|
|
const mismatch = firstMismatchIndex(after.buffer, expected);
|
|
this.#fail("native scrollback buffer fidelity", op, before, after, index, {
|
|
expectedLength: expected.length,
|
|
actualLength: after.buffer.length,
|
|
firstMismatch: mismatch,
|
|
expectedWindow: windowAround(expected, mismatch),
|
|
actualWindow: windowAround(after.buffer, mismatch),
|
|
});
|
|
}
|
|
this.#nativeScrollbackAuditBlocked = false;
|
|
|
|
const probes = scrollbackProbePositions(after.position.baseY, expected.length, after.height);
|
|
try {
|
|
for (const viewportY of probes) {
|
|
const current = this.#term.getBufferPosition().viewportY;
|
|
this.#term.scrollLines(viewportY - current);
|
|
const actual = normalizeLines(this.#term.getViewport());
|
|
const expectedView = fixedViewportSlice(expected, viewportY, after.height);
|
|
if (!sameLines(actual, expectedView)) {
|
|
this.#fail("native scrollback viewport fidelity", op, before, after, index, {
|
|
viewportY,
|
|
expected: expectedView,
|
|
actual,
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
this.#term.scrollLines(LARGE_SCROLL);
|
|
}
|
|
}
|
|
|
|
#assertCleanBuffer(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
if (this.#hasVisibleOverlay()) return;
|
|
const expected = this.#expectedScrollbackBuffer(after);
|
|
if (!sameLines(after.buffer, expected)) {
|
|
this.#fail("clean checkpoint reconstruction", op, before, after, index, {
|
|
expectedLength: expected.length,
|
|
actualLength: after.buffer.length,
|
|
});
|
|
}
|
|
}
|
|
|
|
#expectedScrollbackBuffer(snapshot: Snapshot): string[] {
|
|
return expectedScrollbackBuffer(snapshot.frame, snapshot.height, this.#scenario.scrollback);
|
|
}
|
|
#scrollbackCapReached(snapshot: Snapshot): boolean {
|
|
return Math.max(snapshot.height, snapshot.frame.length) > snapshot.height + this.#scenario.scrollback;
|
|
}
|
|
|
|
#bufferReflectsFrame(buffer: readonly string[], frame: readonly string[], height: number): boolean {
|
|
return sameLines(buffer, expectedScrollbackBuffer(frame, height, this.#scenario.scrollback));
|
|
}
|
|
|
|
#isCleanBuffer(buffer: readonly string[], frame: readonly string[], height: number): boolean {
|
|
return this.#bufferReflectsFrame(buffer, frame, height);
|
|
}
|
|
|
|
#assertNoStaleOverlaySentinels(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void {
|
|
if (this.#hiddenOverlaySentinels.size === 0) return;
|
|
const visibleSentinels = new Set(
|
|
this.#overlays
|
|
.filter(entry => isExpectedOverlayVisible(entry, this.#term.columns, this.#term.rows))
|
|
.map(entry => entry.sentinel),
|
|
);
|
|
// Multiplexers preserve pane history and do not allow the renderer to scrub
|
|
// scrollback safely. A hidden overlay must disappear from the live viewport;
|
|
// historical copies can remain after tmux resize/reflow.
|
|
const nativeText =
|
|
this.#scenario.envMode === "tmux"
|
|
? after.view.join("\n")
|
|
: `${after.buffer.join("\n")}\n${after.view.join("\n")}`;
|
|
for (const sentinel of this.#hiddenOverlaySentinels) {
|
|
if (visibleSentinels.has(sentinel)) continue;
|
|
if (nativeText.includes(sentinel)) {
|
|
this.#fail("stale overlay sentinel", op, before, after, index, { sentinel });
|
|
}
|
|
}
|
|
}
|
|
|
|
#assertUniqueContentNoUnexpectedDuplicates(
|
|
op: AppliedOperation,
|
|
before: Snapshot,
|
|
after: Snapshot,
|
|
index: number,
|
|
): void {
|
|
if (!this.#scenario.uniqueContent || this.#hasVisibleOverlay() || !after.atBottom) return;
|
|
const allowed = duplicateNonblankLines(after.frame);
|
|
const seen = new Set<string>();
|
|
for (const line of after.buffer) {
|
|
if (line.length === 0) continue;
|
|
if (seen.has(line) && !allowed.has(line)) {
|
|
this.#fail("unexpected duplicate native scrollback line", op, before, after, index, { line });
|
|
}
|
|
seen.add(line);
|
|
}
|
|
}
|
|
|
|
#fail(
|
|
message: string,
|
|
op: AppliedOperation,
|
|
before: Snapshot,
|
|
after: Snapshot,
|
|
index: number,
|
|
extra: JsonObject,
|
|
): never {
|
|
const dump = {
|
|
message,
|
|
scenario: this.#scenario.name,
|
|
seed: formatSeed(this.#scenario.seed),
|
|
opIndex: index,
|
|
op: { kind: op.kind, detail: op.detail },
|
|
extra,
|
|
before: snapshotDump(before),
|
|
after: snapshotDump(after),
|
|
model: this.#model.debugLines(),
|
|
opLog: this.#opLog,
|
|
};
|
|
throw new Error(`TUI render stress invariant failed: ${message}\n${JSON.stringify(dump, null, 2)}`);
|
|
}
|
|
}
|
|
|
|
function createTerminal(scenario: Scenario): VirtualTerminal {
|
|
switch (scenario.terminalMode) {
|
|
case "unknown":
|
|
return new UnknownViewportTerminal(scenario.columns, scenario.rows, scenario.scrollback);
|
|
case "intermittentUnknown":
|
|
return new IntermittentUnknownViewportTerminal(scenario.columns, scenario.rows, scenario.scrollback);
|
|
case "staleBottom":
|
|
return new StaleBottomTerminal(scenario.columns, scenario.rows, scenario.scrollback);
|
|
case "normal":
|
|
return new VirtualTerminal(scenario.columns, scenario.rows, scenario.scrollback);
|
|
}
|
|
}
|
|
|
|
function normalizeLines(lines: readonly string[]): string[] {
|
|
return lines.map(line => line.trimEnd());
|
|
}
|
|
|
|
function expectedViewport(frame: readonly string[], height: number): string[] {
|
|
return fixedViewportSlice(frame, Math.max(0, frame.length - height), height);
|
|
}
|
|
|
|
function fixedViewportSlice(frame: readonly string[], start: number, height: number): string[] {
|
|
const view: string[] = [];
|
|
for (let i = 0; i < height; i++) {
|
|
view.push(frame[start + i] ?? "");
|
|
}
|
|
return view;
|
|
}
|
|
|
|
function sameLines(left: readonly string[], right: readonly string[]): boolean {
|
|
if (left.length !== right.length) return false;
|
|
for (let i = 0; i < left.length; i++) {
|
|
if (left[i] !== right[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function firstMismatchIndex(left: readonly string[], right: readonly string[]): number {
|
|
const maxLength = Math.max(left.length, right.length);
|
|
for (let i = 0; i < maxLength; i++) {
|
|
if (left[i] !== right[i]) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function windowAround(lines: readonly string[], center: number): string[] {
|
|
const safeCenter = center < 0 ? 0 : center;
|
|
const start = Math.max(0, safeCenter - 3);
|
|
const end = Math.min(lines.length, safeCenter + 4);
|
|
return lines.slice(start, end);
|
|
}
|
|
|
|
function expectedScrollbackBuffer(frame: readonly string[], height: number, scrollback: number): string[] {
|
|
const expected = [...frame];
|
|
while (expected.length < height) {
|
|
expected.push("");
|
|
}
|
|
const cap = height + scrollback;
|
|
return expected.length > cap ? expected.slice(expected.length - cap) : expected;
|
|
}
|
|
|
|
function scrollbackProbePositions(maxViewportY: number, frameLength: number, height: number): number[] {
|
|
const maxY = Math.max(0, maxViewportY);
|
|
const positions = new Set<number>();
|
|
const add = (value: number): void => {
|
|
positions.add(Math.max(0, Math.min(maxY, value)));
|
|
};
|
|
add(0);
|
|
add(maxY);
|
|
add(Math.floor(maxY / 2));
|
|
add(Math.max(0, frameLength - height));
|
|
add(frameLength - 1);
|
|
add(frameLength);
|
|
if (EXHAUSTIVE_SCROLLBACK || maxY <= 32) {
|
|
for (let y = 0; y <= maxY; y++) add(y);
|
|
}
|
|
return [...positions].sort((left, right) => left - right);
|
|
}
|
|
|
|
function duplicateNonblankLines(lines: readonly string[]): Set<string> {
|
|
const seen = new Set<string>();
|
|
const duplicates = new Set<string>();
|
|
for (const line of lines) {
|
|
if (line.length === 0) continue;
|
|
if (seen.has(line)) duplicates.add(line);
|
|
seen.add(line);
|
|
}
|
|
return duplicates;
|
|
}
|
|
|
|
function expectedTerminalLine(line: string, width: number): string {
|
|
const safeWidth = Math.max(1, width);
|
|
const fitted = visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth, Ellipsis.Omit) : line;
|
|
return stripPlainTerminalText(fitted).trimEnd();
|
|
}
|
|
|
|
function stripPlainTerminalText(text: string): string {
|
|
return stripVTControlCharacters(text)
|
|
.replace(/\]8;;[^\x07]*(?:\x07)?/g, "")
|
|
.replaceAll(BEL, "");
|
|
}
|
|
|
|
function expectedFrameFromLines(lines: readonly string[], width: number, height: number): ExpectedFrame {
|
|
const stripped = [...lines];
|
|
const viewportTop = Math.max(0, stripped.length - height);
|
|
let cursor: ExpectedCursor | null = null;
|
|
for (let row = stripped.length - 1; row >= 0; row--) {
|
|
const line = stripped[row] ?? "";
|
|
const markerIndex = line.indexOf(CURSOR_MARKER);
|
|
if (markerIndex === -1) continue;
|
|
if (cursor === null && row >= viewportTop) {
|
|
cursor = { row: row - viewportTop, col: visibleWidth(line.slice(0, markerIndex)) };
|
|
}
|
|
stripped[row] = removeCursorMarkers(line);
|
|
}
|
|
return { frame: stripped.map(line => expectedTerminalLine(line, width)), cursor };
|
|
}
|
|
|
|
function removeCursorMarkers(line: string): string {
|
|
return line.includes(CURSOR_MARKER) ? line.split(CURSOR_MARKER).join("") : line;
|
|
}
|
|
|
|
function compositeExpectedOverlays(
|
|
lines: readonly string[],
|
|
overlays: readonly StressOverlayEntry[],
|
|
termWidth: number,
|
|
termHeight: number,
|
|
): string[] {
|
|
if (overlays.length === 0) return [...lines];
|
|
const result = [...lines];
|
|
const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
|
|
let minLinesNeeded = result.length;
|
|
for (const entry of overlays) {
|
|
if (!isExpectedOverlayVisible(entry, termWidth, termHeight)) continue;
|
|
const firstLayout = resolveExpectedOverlayLayout(entry.options, 0, termWidth, termHeight);
|
|
let overlayLines = entry.component.render(firstLayout.width);
|
|
if (firstLayout.maxHeight !== undefined && overlayLines.length > firstLayout.maxHeight) {
|
|
overlayLines = overlayLines.slice(0, firstLayout.maxHeight);
|
|
}
|
|
const layout = resolveExpectedOverlayLayout(entry.options, overlayLines.length, termWidth, termHeight);
|
|
rendered.push({ overlayLines, row: layout.row, col: layout.col, w: layout.width });
|
|
minLinesNeeded = Math.max(minLinesNeeded, layout.row + overlayLines.length);
|
|
}
|
|
const workingHeight = Math.max(result.length, minLinesNeeded);
|
|
while (result.length < workingHeight) {
|
|
result.push("");
|
|
}
|
|
const viewportStart = Math.max(0, workingHeight - termHeight);
|
|
for (const { overlayLines, row, col, w } of rendered) {
|
|
for (let i = 0; i < overlayLines.length; i++) {
|
|
const index = viewportStart + row + i;
|
|
if (index < 0 || index >= result.length) continue;
|
|
const overlayLine = overlayLines[i] ?? "";
|
|
const truncatedOverlayLine =
|
|
visibleWidth(overlayLine) > w ? sliceByColumn(overlayLine, 0, w, true) : overlayLine;
|
|
result[index] = compositeExpectedLineAt(result[index] ?? "", truncatedOverlayLine, col, w, termWidth);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function isExpectedOverlayVisible(entry: StressOverlayEntry, termWidth: number, termHeight: number): boolean {
|
|
if (entry.hidden) return false;
|
|
return entry.options.visible?.(termWidth, termHeight) ?? true;
|
|
}
|
|
|
|
function resolveExpectedOverlayLayout(
|
|
options: OverlayOptions | undefined,
|
|
overlayHeight: number,
|
|
termWidth: number,
|
|
termHeight: number,
|
|
): { width: number; row: number; col: number; maxHeight: number | undefined } {
|
|
const opt = options ?? {};
|
|
const margin =
|
|
typeof opt.margin === "number"
|
|
? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
|
|
: (opt.margin ?? {});
|
|
const marginTop = Math.max(0, margin.top ?? 0);
|
|
const marginRight = Math.max(0, margin.right ?? 0);
|
|
const marginBottom = Math.max(0, margin.bottom ?? 0);
|
|
const marginLeft = Math.max(0, margin.left ?? 0);
|
|
const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
|
|
const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
|
|
let width = parseOverlaySizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
|
|
if (opt.minWidth !== undefined) {
|
|
width = Math.max(width, opt.minWidth);
|
|
}
|
|
width = Math.max(1, Math.min(width, availWidth));
|
|
let maxHeight = parseOverlaySizeValue(opt.maxHeight, termHeight);
|
|
if (maxHeight !== undefined) {
|
|
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
|
}
|
|
const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
|
|
let row: number;
|
|
let col: number;
|
|
if (opt.row !== undefined) {
|
|
row =
|
|
typeof opt.row === "string"
|
|
? resolveOverlayPercentPosition(opt.row, Math.max(0, availHeight - effectiveHeight), marginTop)
|
|
: opt.row;
|
|
} else {
|
|
row = resolveExpectedAnchorRow(opt.anchor ?? "center", effectiveHeight, availHeight, marginTop);
|
|
}
|
|
if (opt.col !== undefined) {
|
|
col =
|
|
typeof opt.col === "string"
|
|
? resolveOverlayPercentPosition(opt.col, Math.max(0, availWidth - width), marginLeft)
|
|
: opt.col;
|
|
} else {
|
|
col = resolveExpectedAnchorCol(opt.anchor ?? "center", width, availWidth, marginLeft);
|
|
}
|
|
if (opt.offsetY !== undefined) row += opt.offsetY;
|
|
if (opt.offsetX !== undefined) col += opt.offsetX;
|
|
row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
|
|
col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
|
|
return { width, row, col, maxHeight };
|
|
}
|
|
|
|
function parseOverlaySizeValue(value: OverlayOptions["width"] | undefined, referenceSize: number): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (typeof value === "number") return value;
|
|
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
|
|
return match ? Math.floor((referenceSize * Number.parseFloat(match[1] ?? "0")) / 100) : undefined;
|
|
}
|
|
|
|
function resolveOverlayPercentPosition(value: string, maxPosition: number, margin: number): number {
|
|
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
|
|
if (!match) return margin + Math.floor(maxPosition / 2);
|
|
return margin + Math.floor(maxPosition * (Number.parseFloat(match[1] ?? "0") / 100));
|
|
}
|
|
|
|
function resolveExpectedAnchorRow(
|
|
anchor: OverlayAnchor,
|
|
height: number,
|
|
availHeight: number,
|
|
marginTop: number,
|
|
): number {
|
|
switch (anchor) {
|
|
case "top-left":
|
|
case "top-center":
|
|
case "top-right":
|
|
return marginTop;
|
|
case "bottom-left":
|
|
case "bottom-center":
|
|
case "bottom-right":
|
|
return marginTop + availHeight - height;
|
|
case "left-center":
|
|
case "center":
|
|
case "right-center":
|
|
return marginTop + Math.floor((availHeight - height) / 2);
|
|
}
|
|
}
|
|
|
|
function resolveExpectedAnchorCol(
|
|
anchor: OverlayAnchor,
|
|
width: number,
|
|
availWidth: number,
|
|
marginLeft: number,
|
|
): number {
|
|
switch (anchor) {
|
|
case "top-left":
|
|
case "left-center":
|
|
case "bottom-left":
|
|
return marginLeft;
|
|
case "top-right":
|
|
case "right-center":
|
|
case "bottom-right":
|
|
return marginLeft + availWidth - width;
|
|
case "top-center":
|
|
case "center":
|
|
case "bottom-center":
|
|
return marginLeft + Math.floor((availWidth - width) / 2);
|
|
}
|
|
}
|
|
|
|
function compositeExpectedLineAt(
|
|
baseLine: string,
|
|
overlayLine: string,
|
|
startCol: number,
|
|
overlayWidth: number,
|
|
totalWidth: number,
|
|
): string {
|
|
const afterStart = startCol + overlayWidth;
|
|
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
|
|
const beforePad = Math.max(0, startCol - base.beforeWidth);
|
|
const overlayPad = Math.max(0, overlayWidth - overlay.width);
|
|
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
|
|
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
|
|
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
|
|
const afterPad = Math.max(0, afterTarget - base.afterWidth);
|
|
const result =
|
|
base.before +
|
|
" ".repeat(beforePad) +
|
|
SEGMENT_RESET +
|
|
overlay.text +
|
|
" ".repeat(overlayPad) +
|
|
SEGMENT_RESET +
|
|
base.after +
|
|
" ".repeat(afterPad);
|
|
return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
|
|
}
|
|
|
|
function wideText(label: string): string {
|
|
return `${label}界${SMILE}한`;
|
|
}
|
|
|
|
function styledText(label: string, color: number): string {
|
|
return `${ESC}[${color}m${label}${ESC}[0m`;
|
|
}
|
|
|
|
function linkedText(label: string): string {
|
|
return `${ESC}]8;;https://example.test/${label}${BEL}${label}-link${ESC}]8;;${BEL}`;
|
|
}
|
|
|
|
function longText(label: string, repeats: number): string {
|
|
let text = `${label}-`;
|
|
for (let i = 0; i < repeats; i++) {
|
|
text += `${i}界`;
|
|
}
|
|
return `${text}-${label}`;
|
|
}
|
|
|
|
function randomDecoratedText(rng: Rng, label: string): string {
|
|
const roll = rng.next();
|
|
if (roll < 0.22) return wideText(label);
|
|
if (roll < 0.42) return styledText(`${label}界`, 31 + rng.int(0, 6));
|
|
if (roll < 0.62) return linkedText(label);
|
|
if (roll < 0.82) return longText(label, rng.int(2, 6));
|
|
return label;
|
|
}
|
|
|
|
function pickCursorMode(rng: Rng, text: string, width: number): CursorMode {
|
|
if (text.includes("\x1b") || visibleWidth(text) === 0 || width <= 1) {
|
|
return rng.chance(0.5) ? "start" : "end";
|
|
}
|
|
return rng.pick(CURSOR_MODES);
|
|
}
|
|
|
|
function insertCursorMarker(text: string, mode: CursorMode, width: number): string {
|
|
const index = cursorInsertionIndex(text, mode, width);
|
|
return `${text.slice(0, index)}${CURSOR_MARKER}${text.slice(index)}`;
|
|
}
|
|
|
|
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
|
|
function cursorInsertionIndex(text: string, mode: CursorMode, width: number): number {
|
|
if (mode === "start") return 0;
|
|
if (mode === "end" || text.includes("\x1b")) return text.length;
|
|
const textWidth = visibleWidth(text);
|
|
const target = mode === "wideBoundary" ? Math.max(0, Math.min(width - 1, textWidth)) : Math.floor(textWidth / 2);
|
|
let offset = 0;
|
|
let col = 0;
|
|
for (const segment of SEGMENTER.segment(text)) {
|
|
const nextCol = col + visibleWidth(segment.segment);
|
|
if (nextCol > target) break;
|
|
offset = segment.index + segment.segment.length;
|
|
col = nextCol;
|
|
if (col >= target) break;
|
|
}
|
|
return offset;
|
|
}
|
|
|
|
function snapshotDump(snapshot: Snapshot): JsonObject {
|
|
return {
|
|
buffer: snapshot.buffer,
|
|
view: snapshot.view,
|
|
position: { baseY: snapshot.position.baseY, viewportY: snapshot.position.viewportY },
|
|
cursor: cursorObject(snapshot),
|
|
expectedCursor:
|
|
snapshot.expectedCursor === null
|
|
? null
|
|
: { row: snapshot.expectedCursor.row, col: snapshot.expectedCursor.col },
|
|
redraws: snapshot.redraws,
|
|
width: snapshot.width,
|
|
height: snapshot.height,
|
|
frame: snapshot.frame,
|
|
atBottom: snapshot.atBottom,
|
|
};
|
|
}
|
|
|
|
function cursorObject(snapshot: Snapshot): JsonObject {
|
|
return { row: snapshot.cursor.row, col: snapshot.cursor.col };
|
|
}
|
|
|
|
function maxOf(values: readonly number[]): number {
|
|
let max = values[0] ?? 0;
|
|
for (const value of values) {
|
|
if (value > max) max = value;
|
|
}
|
|
return max;
|
|
}
|
|
|
|
async function settle(term: VirtualTerminal): Promise<void> {
|
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
process.nextTick(resolve);
|
|
await promise;
|
|
await Bun.sleep(1);
|
|
await term.flush();
|
|
}
|
|
|
|
function parsePositiveInt(name: string, fallback: number): number {
|
|
const raw = Bun.env[name];
|
|
if (raw === undefined || raw.length === 0) return fallback;
|
|
const parsed = Number.parseInt(raw, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function formatSeed(seed: number): string {
|
|
return `0x${(seed >>> 0).toString(16).padStart(8, "0")}`;
|
|
}
|
|
|
|
function scenarioEnv(envMode: EnvMode): Record<EnvKey, string | undefined> {
|
|
return {
|
|
TMUX: envMode === "tmux" ? "1" : undefined,
|
|
TERMUX_VERSION: envMode === "termux" ? "0.118.0" : undefined,
|
|
STY: undefined,
|
|
ZELLIJ: undefined,
|
|
};
|
|
}
|
|
|
|
function buildScenarios(): Scenario[] {
|
|
const soak = Bun.env.TUI_STRESS_SOAK === "1";
|
|
const templates = soak ? soakTemplates() : coreTemplates();
|
|
const replay = parseReplay(templates);
|
|
if (replay !== null) {
|
|
const maxHeight = maxOf(replay.template.heightChoices);
|
|
return [
|
|
materializeScenario(
|
|
replay.template,
|
|
replay.seed,
|
|
replay.iterations,
|
|
SOAK_BULK_MAX,
|
|
SOAK_TIMEOUT_MS,
|
|
maxHeight,
|
|
),
|
|
];
|
|
}
|
|
const defaultSeedCount = soak ? Math.max(BASE_SEEDS.length, templates.length) : BASE_SEEDS.length;
|
|
const seedCount = parsePositiveInt("TUI_STRESS_SEEDS", defaultSeedCount);
|
|
const iterations = parsePositiveInt("TUI_STRESS_ITER", soak ? SOAK_ITERATIONS : CORE_ITERATIONS);
|
|
const bulkMax = soak ? SOAK_BULK_MAX : CORE_BULK_MAX;
|
|
const timeoutMs = soak ? SOAK_TIMEOUT_MS : CORE_TIMEOUT_MS;
|
|
const seeds = buildSeeds(seedCount);
|
|
const scenarios: Scenario[] = [];
|
|
for (let i = 0; i < seeds.length; i++) {
|
|
const template = templates[i % templates.length]!;
|
|
const maxHeight = maxOf(template.heightChoices);
|
|
scenarios.push(materializeScenario(template, seeds[i]!, iterations, bulkMax, timeoutMs, maxHeight));
|
|
}
|
|
return scenarios;
|
|
}
|
|
|
|
function materializeScenario(
|
|
template: ScenarioTemplate,
|
|
seed: number,
|
|
iterations: number,
|
|
bulkMax: number,
|
|
timeoutMs: number,
|
|
maxHeight: number,
|
|
): Scenario {
|
|
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",
|
|
timeoutMs,
|
|
uniqueContent: template.uniqueContent ?? false,
|
|
};
|
|
}
|
|
|
|
function parseReplay(
|
|
templates: readonly ScenarioTemplate[],
|
|
): { template: ScenarioTemplate; seed: number; iterations: number } | null {
|
|
const raw = Bun.env.TUI_STRESS_REPLAY;
|
|
if (raw === undefined || raw.length === 0) return null;
|
|
const parsed = JSON.parse(raw) as JsonObject;
|
|
const scenario = typeof parsed.scenario === "string" ? parsed.scenario : "";
|
|
const template = templates.find(candidate => candidate.name === scenario);
|
|
if (template === undefined) throw new Error(`Unknown TUI_STRESS_REPLAY scenario: ${scenario}`);
|
|
const iterations =
|
|
typeof parsed.iterations === "number" && Number.isFinite(parsed.iterations)
|
|
? Math.max(1, Math.floor(parsed.iterations))
|
|
: CORE_ITERATIONS;
|
|
const seed = parseReplaySeed(parsed.seed);
|
|
return { template, seed, iterations };
|
|
}
|
|
|
|
function parseReplaySeed(seed: JsonValue | undefined): number {
|
|
if (typeof seed === "number" && Number.isFinite(seed)) return seed >>> 0;
|
|
if (typeof seed === "string") {
|
|
const parsed = Number.parseInt(seed, seed.startsWith("0x") || seed.startsWith("0X") ? 16 : 10);
|
|
if (Number.isFinite(parsed)) return parsed >>> 0;
|
|
}
|
|
return BASE_SEEDS[0];
|
|
}
|
|
|
|
function buildSeeds(count: number): number[] {
|
|
const seeds: number[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const fixed = BASE_SEEDS[i];
|
|
seeds.push(fixed === undefined ? (0x9e3779b9 + Math.imul(i + 1, 0x85ebca6b)) >>> 0 : fixed);
|
|
}
|
|
return seeds;
|
|
}
|
|
|
|
type ScenarioTemplate = Omit<
|
|
Scenario,
|
|
"seed" | "iterations" | "bulkMax" | "scrollback" | "strictScrollback" | "timeoutMs" | "uniqueContent"
|
|
> & {
|
|
scrollbackRows?: number;
|
|
uniqueContent?: boolean;
|
|
};
|
|
|
|
function coreTemplates(): ScenarioTemplate[] {
|
|
return [
|
|
{
|
|
name: "darwin-normal-small",
|
|
platform: "darwin",
|
|
terminalMode: "normal",
|
|
envMode: "plain",
|
|
geometryMode: "small",
|
|
columns: 32,
|
|
rows: 4,
|
|
widthChoices: [10, 16, 24, 32, 40],
|
|
heightChoices: [3, 4, 6],
|
|
scrollbackRows: 5,
|
|
},
|
|
{
|
|
name: "linux-normal-small",
|
|
platform: "linux",
|
|
terminalMode: "normal",
|
|
envMode: "plain",
|
|
geometryMode: "small",
|
|
columns: 40,
|
|
rows: 6,
|
|
widthChoices: [10, 18, 32, 40],
|
|
heightChoices: [3, 4, 6],
|
|
},
|
|
{
|
|
name: "darwin-normal-large",
|
|
platform: "darwin",
|
|
terminalMode: "normal",
|
|
envMode: "plain",
|
|
geometryMode: "large",
|
|
columns: 80,
|
|
rows: 12,
|
|
widthChoices: [40, 80, 120],
|
|
heightChoices: [12, 24],
|
|
},
|
|
{
|
|
name: "win32-intermittentUnknown-small",
|
|
platform: "win32",
|
|
terminalMode: "intermittentUnknown",
|
|
envMode: "plain",
|
|
geometryMode: "small",
|
|
columns: 32,
|
|
rows: 4,
|
|
widthChoices: [10, 16, 32],
|
|
heightChoices: [3, 4, 6],
|
|
},
|
|
{
|
|
name: "darwin-normal-tmux-small",
|
|
platform: "darwin",
|
|
terminalMode: "normal",
|
|
envMode: "tmux",
|
|
geometryMode: "small",
|
|
columns: 32,
|
|
rows: 4,
|
|
widthChoices: [10, 16, 32],
|
|
heightChoices: [3, 4, 6],
|
|
},
|
|
{
|
|
name: "linux-staleBottom-large",
|
|
platform: "linux",
|
|
terminalMode: "staleBottom",
|
|
envMode: "plain",
|
|
geometryMode: "large",
|
|
columns: 120,
|
|
rows: 24,
|
|
widthChoices: [80, 120],
|
|
heightChoices: [12, 24],
|
|
},
|
|
{
|
|
name: "darwin-normal-tiny",
|
|
platform: "darwin",
|
|
terminalMode: "normal",
|
|
envMode: "plain",
|
|
geometryMode: "small",
|
|
columns: 6,
|
|
rows: 1,
|
|
widthChoices: [1, 2, 6, 12],
|
|
heightChoices: [1, 2, 3],
|
|
uniqueContent: true,
|
|
},
|
|
{
|
|
name: "linux-normal-termux-small",
|
|
platform: "linux",
|
|
terminalMode: "normal",
|
|
envMode: "termux",
|
|
geometryMode: "small",
|
|
columns: 32,
|
|
rows: 4,
|
|
widthChoices: [10, 16, 32],
|
|
heightChoices: [1, 2, 3, 4, 6],
|
|
},
|
|
];
|
|
}
|
|
|
|
function soakTemplates(): ScenarioTemplate[] {
|
|
const templates: ScenarioTemplate[] = [];
|
|
const platforms: readonly TestPlatform[] = ["darwin", "linux", "win32"];
|
|
const terminalModes: readonly TerminalMode[] = ["normal", "unknown", "intermittentUnknown", "staleBottom"];
|
|
const envModes: readonly EnvMode[] = ["plain", "tmux", "termux"];
|
|
const geometries: readonly GeometryMode[] = ["small", "large"];
|
|
for (const platform of platforms) {
|
|
for (const terminalMode of terminalModes) {
|
|
for (const envMode of envModes) {
|
|
for (const geometryMode of geometries) {
|
|
const large = geometryMode === "large";
|
|
templates.push({
|
|
name: `${platform}-${terminalMode}-${envMode}-${geometryMode}`,
|
|
platform,
|
|
terminalMode,
|
|
envMode,
|
|
geometryMode,
|
|
columns: large ? 80 : 32,
|
|
rows: large ? 12 : 4,
|
|
widthChoices: large ? [80, 120] : [2, 10, 16, 24, 32, 40],
|
|
heightChoices: large ? [12, 24] : [3, 4, 6],
|
|
...(!large && terminalMode === "normal" && envMode === "plain"
|
|
? { scrollbackRows: 5, uniqueContent: true }
|
|
: {}),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return templates;
|
|
}
|
|
|
|
async function withPatchedGlobals<T>(scenario: Scenario, run: () => Promise<T>): Promise<T> {
|
|
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
|
const envPatch = scenarioEnv(scenario.envMode);
|
|
const savedBunEnv: Record<EnvKey, string | undefined> = {
|
|
TMUX: undefined,
|
|
STY: undefined,
|
|
ZELLIJ: undefined,
|
|
TERMUX_VERSION: undefined,
|
|
};
|
|
const savedProcessEnv: Record<EnvKey, string | undefined> = {
|
|
TMUX: undefined,
|
|
STY: undefined,
|
|
ZELLIJ: undefined,
|
|
TERMUX_VERSION: undefined,
|
|
};
|
|
for (const key of ENV_KEYS) {
|
|
savedBunEnv[key] = Bun.env[key];
|
|
savedProcessEnv[key] = process.env[key];
|
|
const value = envPatch[key];
|
|
if (value === undefined) {
|
|
delete Bun.env[key];
|
|
delete process.env[key];
|
|
} else {
|
|
Bun.env[key] = value;
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
Object.defineProperty(process, "platform", { configurable: true, value: scenario.platform });
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
if (platformDescriptor !== undefined) {
|
|
Object.defineProperty(process, "platform", platformDescriptor);
|
|
}
|
|
for (const key of ENV_KEYS) {
|
|
const bunValue = savedBunEnv[key];
|
|
if (bunValue === undefined) {
|
|
delete Bun.env[key];
|
|
} else {
|
|
Bun.env[key] = bunValue;
|
|
}
|
|
const processValue = savedProcessEnv[key];
|
|
if (processValue === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = processValue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("TUI randomized render stress", () => {
|
|
let monotonicNow = 0;
|
|
|
|
beforeEach(() => {
|
|
monotonicNow = 0;
|
|
vi.spyOn(performance, "now").mockImplementation(() => {
|
|
monotonicNow += 20;
|
|
return monotonicNow;
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("preserves preexisting shell scrollback during visible structural mutations", async () => {
|
|
const term = new VirtualTerminal(40, 5, 100);
|
|
term.write(`${Array.from({ length: 12 }, (_value, index) => `shell-${index}`).join("\r\n")}\r\n`);
|
|
await settle(term);
|
|
|
|
const tui = new TUI(term, true);
|
|
const component = new MutableLinesComponent(["ui-0", "ui-1", "ui-2"]);
|
|
tui.addChild(component);
|
|
|
|
try {
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
const externalRows = normalizeLines(term.getScrollBuffer()).filter(line => line.startsWith("shell-"));
|
|
if (externalRows.length === 0) {
|
|
throw new Error("Test setup failed: preexisting shell scrollback did not survive initial TUI paint");
|
|
}
|
|
|
|
const frames = [
|
|
["ui-0", "inserted-0", "ui-1", "ui-2"],
|
|
["ui-0", "inserted-1", "ui-1", "ui-2"],
|
|
["ui-0", "ui-1", "ui-2"],
|
|
["prefix", "ui-0", "ui-1", "ui-2"],
|
|
] as const;
|
|
|
|
for (let index = 0; index < frames.length; index++) {
|
|
component.setLines(frames[index]!);
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
const buffer = normalizeLines(term.getScrollBuffer());
|
|
for (const row of externalRows) {
|
|
if (!buffer.includes(row)) {
|
|
throw new Error(
|
|
`Preexisting shell scrollback was cleared by visible structural mutation\n${JSON.stringify(
|
|
{ mutationIndex: index, missing: row, externalRows, buffer },
|
|
null,
|
|
2,
|
|
)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
for (const scenario of buildScenarios()) {
|
|
it(
|
|
`${scenario.name} seed=${formatSeed(scenario.seed)} ops=${scenario.iterations}`,
|
|
async () => {
|
|
await withPatchedGlobals(scenario, async () => {
|
|
const driver = new StressDriver(scenario);
|
|
await driver.run();
|
|
});
|
|
},
|
|
scenario.timeoutMs,
|
|
);
|
|
}
|
|
});
|