fix(tui): sync test awaits with throttled render pipeline (#3076)

Tests assumed renders complete synchronously after requestRender() +
nextTick + flush(), but commit 6f5f37f8 changed requestRender() to
schedule via setTimeout with a 16ms minimum interval. Tests reading
viewport state before doRender() runs saw stale data.

Add VirtualTerminal.waitForRender() helper that waits for the throttled
render to settle (nextTick + 20ms + xterm flush), and replace all
terminal.flush() calls after render-triggering actions with it.

Fixes 8 failing tests across overlay-short-content, tui-render, and
markdown test suites.
This commit is contained in:
Aliou Diallo
2026-04-13 10:57:48 +02:00
committed by GitHub
parent fb6ccbeaa1
commit 41377ee8ec
8 changed files with 50 additions and 43 deletions

View File

@@ -5,6 +5,7 @@
### Fixed
- Fixed `Container.render()` stack overflow on long sessions by replacing `Array.push(...spread)` with a loop-based push, preventing `RangeError: Maximum call stack size exceeded` when child output exceeds the V8 call stack argument limit ([#2651](https://github.com/badlogic/pi-mono/issues/2651))
- Fixed TUI test suite failures caused by render throttle scheduling: added `VirtualTerminal.waitForRender()` helper that waits for the 16ms throttled render pipeline to settle before asserting viewport state ([#3076](https://github.com/badlogic/pi-mono/pull/3076) by [@aliou](https://github.com/aliou))
## [0.66.1] - 2026-04-08

View File

@@ -592,7 +592,7 @@ describe("Markdown component", () => {
const component = new MarkdownWithInput(markdown);
tui.addChild(component);
tui.start();
await terminal.flush();
await terminal.waitForRender();
assert.ok(component.markdownLineCount > 0);
const inputRow = component.markdownLineCount;
@@ -1032,7 +1032,7 @@ bar`,
const tui = new TUI(terminal);
tui.addChild(markdown);
tui.start();
await terminal.flush();
await terminal.waitForRender();
const renderedLine = markdown.render(80)[0];
assert.ok(renderedLine, "Should render heading line");

View File

@@ -41,7 +41,7 @@ class FocusableOverlay implements Component, Focusable {
async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.flush();
await terminal.waitForRender();
}
describe("TUI overlay non-capturing", () => {

View File

@@ -29,7 +29,7 @@ class EmptyContent implements Component {
async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.flush();
await terminal.waitForRender();
}
describe("TUI overlay options", () => {

View File

@@ -33,8 +33,7 @@ describe("TUI overlay with short content", () => {
// Trigger render
tui.start();
await new Promise((r) => process.nextTick(r));
await terminal.flush();
await terminal.waitForRender();
const viewport = terminal.getViewport();
const hasOverlay = viewport.some((line) => line.includes("OVERLAY"));

View File

@@ -37,7 +37,7 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num
async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.flush();
await terminal.waitForRender();
}
describe("TUI overlay compositing", () => {

View File

@@ -73,13 +73,13 @@ describe("TUI resize handling", () => {
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
// Resize height
terminal.resize(40, 15);
await terminal.flush();
await terminal.waitForRender();
// Should have triggered a full redraw
assert.ok(tui.fullRedraws > initialRedraws, "Height change should trigger full redraw");
@@ -100,13 +100,13 @@ describe("TUI resize handling", () => {
component.lines = Array.from({ length: 20 }, (_, i) => `Line ${i}`);
tui.start();
await terminal.flush();
await terminal.waitForRender();
terminal.clearWrites();
const initialRedraws = tui.fullRedraws;
for (const height of [15, 8, 14, 11]) {
terminal.resize(40, height);
await terminal.flush();
await terminal.waitForRender();
}
assert.strictEqual(tui.fullRedraws, initialRedraws, "Height change should not trigger full redraw");
@@ -128,13 +128,13 @@ describe("TUI resize handling", () => {
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
// Resize width
terminal.resize(60, 10);
await terminal.flush();
await terminal.waitForRender();
// Should have triggered a full redraw
assert.ok(tui.fullRedraws > initialRedraws, "Width change should trigger full redraw");
@@ -154,14 +154,14 @@ describe("TUI content shrinkage", () => {
// Start with many lines
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4", "Line 5"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
// Shrink to fewer lines
component.lines = ["Line 0", "Line 1"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
// Should have triggered a full redraw to clear empty rows
assert.ok(tui.fullRedraws > initialRedraws, "Content shrinkage should trigger full redraw");
@@ -185,12 +185,12 @@ describe("TUI content shrinkage", () => {
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
// Shrink to single line
component.lines = ["Only line"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Only line"), "Single line rendered");
@@ -208,12 +208,12 @@ describe("TUI content shrinkage", () => {
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
// Shrink to empty
component.lines = [];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
const viewport = terminal.getViewport();
// All lines should be empty
@@ -234,18 +234,18 @@ describe("TUI differential rendering", () => {
// Initial render: 5 identical lines
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
// Shrink to 3 lines, all identical to before (no content changes in remaining lines)
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
// cursorRow should be 2 (last line of new content)
// Verify by doing another render with a change on line 1
component.lines = ["Line 0", "CHANGED", "Line 2"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
const viewport = terminal.getViewport();
// Line 1 should show "CHANGED", proving cursor tracking was correct
@@ -263,14 +263,14 @@ describe("TUI differential rendering", () => {
// Initial render
component.lines = ["Header", "Working...", "Footer"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
// Simulate spinner animation - only middle line changes
const spinnerFrames = ["|", "/", "-", "\\"];
for (const frame of spinnerFrames) {
component.lines = ["Header", `Working ${frame}`, "Footer"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Header"), `Header preserved: ${viewport[0]}`);
@@ -289,7 +289,7 @@ describe("TUI differential rendering", () => {
component.lines = ["\x1b[3mItalic", "Plain"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
@@ -303,12 +303,12 @@ describe("TUI differential rendering", () => {
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
// Change only first line
component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("CHANGED"), `First line changed: ${viewport[0]}`);
@@ -327,12 +327,12 @@ describe("TUI differential rendering", () => {
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
// Change only last line
component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`);
@@ -351,12 +351,12 @@ describe("TUI differential rendering", () => {
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
// Change lines 1 and 3, keep 0, 2, 4 the same
component.lines = ["Line 0", "CHANGED 1", "Line 2", "CHANGED 3", "Line 4"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`);
@@ -377,7 +377,7 @@ describe("TUI differential rendering", () => {
// Start with content
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
await terminal.waitForRender();
let viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered");
@@ -385,12 +385,12 @@ describe("TUI differential rendering", () => {
// Clear to empty
component.lines = [];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
// Add content back - this should work correctly even after empty state
component.lines = ["New Line 0", "New Line 1"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("New Line 0"), `New content rendered: ${viewport[0]}`);
@@ -407,13 +407,13 @@ describe("TUI differential rendering", () => {
component.lines = Array.from({ length: 12 }, (_, i) => `Line ${i}`);
tui.start();
await terminal.flush();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
component.lines = Array.from({ length: 7 }, (_, i) => `Line ${i}`);
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should trigger a full redraw");
assert.deepStrictEqual(terminal.getViewport(), ["Line 2", "Line 3", "Line 4", "Line 5", "Line 6"]);
@@ -429,20 +429,20 @@ describe("TUI differential rendering", () => {
component.lines = Array.from({ length: 8 }, (_, i) => `Line ${i}`);
tui.start();
await terminal.flush();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
component.lines = ["Line 0", "Line 1"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should reset the viewport with a full redraw");
const redrawsAfterShrink = tui.fullRedraws;
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
assert.strictEqual(tui.fullRedraws, redrawsAfterShrink, "Append should stay on the differential path");
assert.deepStrictEqual(terminal.getViewport(), ["Line 0", "Line 1", "Line 2", "", ""]);
@@ -466,20 +466,20 @@ describe("TUI differential rendering", () => {
chat.lines = longChat;
editor.lines = editorLines;
tui.start();
await terminal.flush();
await terminal.waitForRender();
editor.lines = selectorLines;
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
editor.lines = editorLines;
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
const redrawsBeforeSwitch = tui.fullRedraws;
chat.lines = shortChat;
tui.requestRender();
await terminal.flush();
await terminal.waitForRender();
assert.ok(tui.fullRedraws > redrawsBeforeSwitch, "Branch switch should trigger a full redraw");

View File

@@ -206,4 +206,11 @@ export class VirtualTerminal implements Terminal {
y: buffer.cursorY,
};
}
/** Wait for TUI's throttled render pipeline to settle. */
async waitForRender(): Promise<void> {
await new Promise<void>((resolve) => process.nextTick(resolve));
await new Promise<void>((resolve) => setTimeout(resolve, 20));
await this.flush();
}
}