604 lines
18 KiB
TypeScript
604 lines
18 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import {
|
|
type Component,
|
|
Container,
|
|
Editor,
|
|
type Focusable,
|
|
type NativeScrollbackCommittedRows,
|
|
type NativeScrollbackLiveRegion,
|
|
type NativeScrollbackReplay,
|
|
TUI,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import { StressRenderScheduler } from "./render-stress-scheduler";
|
|
import { defaultEditorTheme } from "./test-themes";
|
|
import { VirtualTerminal } from "./virtual-terminal";
|
|
|
|
const ORIGINAL_HERDR_ENV = Bun.env.HERDR_ENV;
|
|
|
|
beforeEach(() => {
|
|
delete Bun.env.HERDR_ENV;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (ORIGINAL_HERDR_ENV === undefined) delete Bun.env.HERDR_ENV;
|
|
else Bun.env.HERDR_ENV = ORIGINAL_HERDR_ENV;
|
|
});
|
|
|
|
// Behavioral tests for TUI.requestComponentRender: a component whose own
|
|
// content changed (spinner frame, blink) asks for a component-scoped frame.
|
|
// When every request since the last frame is component-scoped and the frame is
|
|
// otherwise quiet, the compose re-renders only the root subtrees containing
|
|
// the requesting components and reuses the previous segment — rows and seam
|
|
// report — of every other root child. Any concurrent full request or unsafe
|
|
// condition must downgrade to a normal full compose.
|
|
|
|
/** Ref-stable leaf: fresh array per change, counts render() calls. */
|
|
class CountingLines implements Component {
|
|
renders = 0;
|
|
#lines: string[];
|
|
|
|
constructor(lines: string[]) {
|
|
this.#lines = lines;
|
|
}
|
|
|
|
set(lines: string[]): void {
|
|
this.#lines = lines;
|
|
}
|
|
|
|
invalidate(): void {}
|
|
|
|
render(_width: number): readonly string[] {
|
|
this.renders++;
|
|
return this.#lines;
|
|
}
|
|
}
|
|
|
|
/** Transcript-shaped head: final rows committed, the last row stays live. */
|
|
class LiveHead extends CountingLines implements NativeScrollbackLiveRegion {
|
|
#seam = 0;
|
|
|
|
setSeam(seam: number): void {
|
|
this.#seam = seam;
|
|
}
|
|
|
|
getNativeScrollbackLiveRegionStart(): number | undefined {
|
|
return this.#seam;
|
|
}
|
|
}
|
|
|
|
class AnchoredStatusContainer extends Container implements NativeScrollbackLiveRegion {
|
|
getNativeScrollbackLiveRegionStart(): number | undefined {
|
|
const hasAnchoredRows = this.children.length > 0;
|
|
return hasAnchoredRows ? 0 : undefined;
|
|
}
|
|
}
|
|
|
|
function strip(rows: string[]): string[] {
|
|
return rows.map(row => Bun.stripANSI(row).trimEnd());
|
|
}
|
|
|
|
function visible(term: VirtualTerminal): string[] {
|
|
return strip(term.getViewport()).filter(row => row.length > 0);
|
|
}
|
|
|
|
class RenderCountingTUI extends TUI {
|
|
renders = 0;
|
|
|
|
override render(width: number): readonly string[] {
|
|
this.renders++;
|
|
return super.render(width);
|
|
}
|
|
}
|
|
|
|
class ReplayVirtualizedLines implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
|
|
readonly lines: readonly string[];
|
|
replayPreparations = 0;
|
|
#compacted = false;
|
|
#replayPending = false;
|
|
|
|
constructor(lines: readonly string[]) {
|
|
this.lines = lines;
|
|
}
|
|
|
|
invalidate(): void {}
|
|
|
|
setNativeScrollbackCommittedRows(rows: number): void {
|
|
if (rows >= 4) this.#compacted = true;
|
|
}
|
|
|
|
prepareNativeScrollbackReplay(): void {
|
|
this.replayPreparations++;
|
|
this.#replayPending = true;
|
|
}
|
|
|
|
render(_width: number): readonly string[] {
|
|
if (this.#replayPending) {
|
|
this.#replayPending = false;
|
|
return this.lines;
|
|
}
|
|
return this.#compacted ? this.lines.slice(4) : this.lines;
|
|
}
|
|
}
|
|
|
|
describe("TUI native scrollback replay", () => {
|
|
it("rehydrates virtualized roots before a destructive full paint", async () => {
|
|
const term = new VirtualTerminal(40, 4, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new ReplayVirtualizedLines([
|
|
"history-0",
|
|
"history-1",
|
|
"history-2",
|
|
"history-3",
|
|
"tail-0",
|
|
"tail-1",
|
|
"tail-2",
|
|
"tail-3",
|
|
]);
|
|
tui.addChild(transcript);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
tui.requestRender();
|
|
await scheduler.drain(term);
|
|
|
|
tui.requestRender(true, { clearScrollback: true });
|
|
await scheduler.drain(term);
|
|
|
|
expect(transcript.replayPreparations).toBe(1);
|
|
const buffer = strip(term.getScrollBuffer());
|
|
expect(buffer).toContain("history-0");
|
|
expect(buffer).toContain("tail-3");
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("TUI.requestComponentRender", () => {
|
|
it("re-renders only the requesting subtree on a quiet frame", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0", "msg-1", "msg-2"]);
|
|
const status = new Container();
|
|
const spinner = new CountingLines(["spin-0"]);
|
|
status.addChild(spinner);
|
|
tui.addChild(transcript);
|
|
tui.addChild(status);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
expect(visible(term)).toEqual(["msg-0", "msg-1", "msg-2", "spin-0"]);
|
|
const transcriptRenders = transcript.renders;
|
|
|
|
// Spinner tick: component-scoped request, nested one level deep.
|
|
spinner.set(["spin-1"]);
|
|
tui.requestComponentRender(spinner);
|
|
await scheduler.drain(term);
|
|
|
|
expect(visible(term)).toEqual(["msg-0", "msg-1", "msg-2", "spin-1"]);
|
|
// The transcript subtree was reused, not re-rendered.
|
|
expect(transcript.renders).toBe(transcriptRenders);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("downgrades to a full compose when a full request shares the frame", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0"]);
|
|
const spinner = new CountingLines(["spin-0"]);
|
|
tui.addChild(transcript);
|
|
tui.addChild(spinner);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
const transcriptRenders = transcript.renders;
|
|
|
|
// Both a component-scoped and a full request coalesce into one
|
|
// frame; the full request wins regardless of arrival order.
|
|
spinner.set(["spin-1"]);
|
|
tui.requestComponentRender(spinner);
|
|
transcript.set(["msg-0", "msg-edited"]);
|
|
tui.requestRender();
|
|
await scheduler.drain(term);
|
|
|
|
expect(visible(term)).toEqual(["msg-0", "msg-edited", "spin-1"]);
|
|
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("falls back to a full compose while an overlay is up", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0"]);
|
|
const spinner = new CountingLines(["spin-0"]);
|
|
tui.addChild(transcript);
|
|
tui.addChild(spinner);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
tui.showOverlay(new CountingLines(["modal"]), { width: 10 });
|
|
await scheduler.drain(term);
|
|
const transcriptRenders = transcript.renders;
|
|
|
|
spinner.set(["spin-1"]);
|
|
tui.requestComponentRender(spinner);
|
|
await scheduler.drain(term);
|
|
|
|
// Unsafe condition: the frame rendered fully (and correctly).
|
|
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("falls back to a full compose when the root child list changed", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0"]);
|
|
const spinner = new CountingLines(["spin-0"]);
|
|
tui.addChild(transcript);
|
|
tui.addChild(spinner);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
|
|
// Structural change with only a component-scoped request pending:
|
|
// the segment ledger no longer matches the root list, so the frame
|
|
// must compose fully and paint the new child.
|
|
tui.addChild(new CountingLines(["banner"]));
|
|
spinner.set(["spin-1"]);
|
|
tui.requestComponentRender(spinner);
|
|
await scheduler.drain(term);
|
|
|
|
expect(visible(term)).toEqual(["msg-0", "spin-1", "banner"]);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("falls back to a full compose when the component is not in the tree", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0"]);
|
|
const status = new Container();
|
|
const spinner = new CountingLines(["spin-0"]);
|
|
status.addChild(spinner);
|
|
tui.addChild(transcript);
|
|
tui.addChild(status);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
const transcriptRenders = transcript.renders;
|
|
|
|
// A detached component (cleared status container) can still fire a
|
|
// trailing tick; the frame must not skip anything based on it.
|
|
status.removeChild(spinner);
|
|
tui.requestComponentRender(spinner);
|
|
await scheduler.drain(term);
|
|
|
|
expect(visible(term)).toEqual(["msg-0"]);
|
|
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("replays the seam report of a skipped root child across partial frames", async () => {
|
|
const term = new VirtualTerminal(40, 4, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const markers = Array.from({ length: 8 }, (_unused, i) => `ROW-${String(i).padStart(3, "0")}`);
|
|
// All but the last head row are final; the tail row stays live.
|
|
const head = new LiveHead([...markers, "streaming"]);
|
|
head.setSeam(markers.length);
|
|
const spinner = new CountingLines(["spin-0"]);
|
|
tui.addChild(head);
|
|
tui.addChild(spinner);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
const headRenders = head.renders;
|
|
|
|
// Several spinner-only frames while the head (and its commit seam)
|
|
// ride the reused segment.
|
|
for (let tick = 1; tick <= 3; tick++) {
|
|
spinner.set([`spin-${tick}`]);
|
|
tui.requestComponentRender(spinner);
|
|
await scheduler.drain(term);
|
|
}
|
|
expect(head.renders).toBe(headRenders);
|
|
expect(visible(term).at(-1)).toBe("spin-3");
|
|
|
|
// A later full frame must still commit exactly once: every final
|
|
// row appears exactly once across history + grid, in order.
|
|
head.set([...markers, "streamed-final", "tail"]);
|
|
head.setSeam(markers.length + 2);
|
|
tui.requestRender();
|
|
await scheduler.drain(term);
|
|
|
|
const buffer = strip(term.getScrollBuffer()).join("\n");
|
|
const missing = markers.filter(mark => buffer.split(mark).length - 1 === 0);
|
|
const duplicated = markers.filter(mark => buffer.split(mark).length - 1 > 1);
|
|
expect(missing).toEqual([]);
|
|
expect(duplicated).toEqual([]);
|
|
const observed = Array.from(buffer.matchAll(/ROW-\d{3}/g), match => match[0]);
|
|
expect(observed).toEqual(markers);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("TUI keystroke-scoped render", () => {
|
|
it("fully composes callback-driven sibling updates without explicit scoped opt-in", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const status = new CountingLines(["status-idle"]);
|
|
const input: Component & Focusable = {
|
|
focused: false,
|
|
invalidate() {},
|
|
render() {
|
|
const state = this.focused ? "focused" : "idle";
|
|
return [`input-${state}`];
|
|
},
|
|
handleInput() {
|
|
status.set(["status-submitted"]);
|
|
},
|
|
};
|
|
tui.addChild(status);
|
|
tui.addChild(input);
|
|
tui.setFocus(input);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
const statusRenders = status.renders;
|
|
|
|
term.sendInput("x");
|
|
await scheduler.drain(term);
|
|
|
|
expect(status.renders).toBeGreaterThan(statusRenders);
|
|
expect(visible(term)).toEqual(["status-submitted", "input-focused"]);
|
|
expect(tui.getFocused()).toBe(input);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("does not re-render a quiet sibling transcript while typing in the focused editor", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0", "msg-1", "msg-2"]);
|
|
const editor = new Editor(defaultEditorTheme);
|
|
tui.enableScopedInputRender(editor);
|
|
tui.addChild(transcript);
|
|
tui.addChild(editor);
|
|
tui.setFocus(editor);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
const transcriptRenders = transcript.renders;
|
|
|
|
term.sendInput("x");
|
|
await scheduler.drain(term);
|
|
|
|
expect(editor.getText()).toBe("x");
|
|
expect(transcript.renders).toBe(transcriptRenders);
|
|
expect(visible(term).some(row => row.includes("msg-0"))).toBe(true);
|
|
expect(visible(term).some(row => row.includes("x"))).toBe(true);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("keeps a correct viewport when a keystroke grows the editor by one wrapped row", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0", "msg-1"]);
|
|
const editor = new Editor(defaultEditorTheme);
|
|
tui.enableScopedInputRender(editor);
|
|
// 34 chars fills the first content row at width 40; the next char wraps.
|
|
editor.setText("x".repeat(34));
|
|
tui.addChild(transcript);
|
|
tui.addChild(editor);
|
|
tui.setFocus(editor);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
expect(visible(term)).toEqual([
|
|
"msg-0",
|
|
"msg-1",
|
|
"+--------------------------------------+",
|
|
"+- xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|-+",
|
|
]);
|
|
const transcriptRenders = transcript.renders;
|
|
|
|
term.sendInput("y");
|
|
await scheduler.drain(term);
|
|
|
|
expect(editor.getText()).toBe(`${"x".repeat(34)}y`);
|
|
expect(transcript.renders).toBe(transcriptRenders);
|
|
expect(visible(term)).toEqual([
|
|
"msg-0",
|
|
"msg-1",
|
|
"+--------------------------------------+",
|
|
"| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |",
|
|
"+- y| -+",
|
|
]);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("falls back to a full compose when handleInput moves focus", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0"]);
|
|
const nextFocus = new CountingLines(["selector"]);
|
|
const focusMover: Component & Focusable = {
|
|
focused: false,
|
|
invalidate() {},
|
|
render() {
|
|
return this.focused ? ["editor-focused"] : ["editor-idle"];
|
|
},
|
|
handleInput() {
|
|
tui.setFocus(nextFocus);
|
|
},
|
|
};
|
|
tui.enableScopedInputRender(focusMover);
|
|
|
|
tui.addChild(transcript);
|
|
tui.addChild(focusMover);
|
|
tui.addChild(nextFocus);
|
|
tui.setFocus(focusMover);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
expect(visible(term)).toEqual(["msg-0", "editor-focused", "selector"]);
|
|
const transcriptRenders = transcript.renders;
|
|
|
|
term.sendInput("x");
|
|
await scheduler.drain(term);
|
|
|
|
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
|
|
expect(visible(term)).toEqual(["msg-0", "editor-idle", "selector"]);
|
|
expect(tui.getFocused()).toBe(nextFocus);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("TUI.requestDirectWrite", () => {
|
|
it("directly rewrites a visible unchanged-size root segment without a full render", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new RenderCountingTUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0", "msg-1"]);
|
|
const spinner = new CountingLines(["spin-0"]);
|
|
const footer = new CountingLines(["footer"]);
|
|
tui.addChild(transcript);
|
|
tui.addChild(spinner);
|
|
tui.addChild(footer);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-0", "footer"]);
|
|
const tuiRenders = tui.renders;
|
|
const transcriptRenders = transcript.renders;
|
|
const footerRenders = footer.renders;
|
|
|
|
spinner.set(["spin-1"]);
|
|
tui.requestDirectWrite(spinner);
|
|
await scheduler.drain(term);
|
|
|
|
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-1", "footer"]);
|
|
expect(tui.renders).toBe(tuiRenders);
|
|
expect(transcript.renders).toBe(transcriptRenders);
|
|
expect(footer.renders).toBe(footerRenders);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("directly rewrites fully live anchored status segments", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new RenderCountingTUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0", "msg-1"]);
|
|
const status = new AnchoredStatusContainer();
|
|
const spinner = new CountingLines(["spin-0"]);
|
|
status.addChild(spinner);
|
|
tui.addChild(transcript);
|
|
tui.addChild(status);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-0"]);
|
|
const tuiRenders = tui.renders;
|
|
const transcriptRenders = transcript.renders;
|
|
|
|
spinner.set(["spin-1"]);
|
|
tui.requestDirectWrite(spinner);
|
|
await scheduler.drain(term);
|
|
|
|
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-1"]);
|
|
expect(tui.renders).toBe(tuiRenders);
|
|
expect(transcript.renders).toBe(transcriptRenders);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("falls back to a full render while a visible overlay is up", async () => {
|
|
const term = new VirtualTerminal(40, 8, 1_000);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new RenderCountingTUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new CountingLines(["msg-0"]);
|
|
const spinner = new CountingLines(["spin-0"]);
|
|
const footer = new CountingLines(["footer"]);
|
|
tui.addChild(transcript);
|
|
tui.addChild(spinner);
|
|
tui.addChild(footer);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
tui.showOverlay(new CountingLines(["modal"]), { width: 5, anchor: "top-left" });
|
|
await scheduler.drain(term);
|
|
expect(visible(term)).toEqual(["modal", "spin-0", "footer"]);
|
|
const tuiRenders = tui.renders;
|
|
const transcriptRenders = transcript.renders;
|
|
|
|
spinner.set(["spin-1"]);
|
|
tui.requestDirectWrite(spinner);
|
|
await scheduler.drain(term);
|
|
expect(visible(term)).toEqual(["modal", "spin-1", "footer"]);
|
|
expect(tui.renders).toBeGreaterThan(tuiRenders);
|
|
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
});
|