diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 49d39c60..ffe241e5 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 55cd2bca..e8f0d023 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -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"); diff --git a/packages/tui/test/overlay-non-capturing.test.ts b/packages/tui/test/overlay-non-capturing.test.ts index 628f3971..c388814c 100644 --- a/packages/tui/test/overlay-non-capturing.test.ts +++ b/packages/tui/test/overlay-non-capturing.test.ts @@ -41,7 +41,7 @@ class FocusableOverlay implements Component, Focusable { async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise { tui.requestRender(true); await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); + await terminal.waitForRender(); } describe("TUI overlay non-capturing", () => { diff --git a/packages/tui/test/overlay-options.test.ts b/packages/tui/test/overlay-options.test.ts index 2a83f4b3..45d909b6 100644 --- a/packages/tui/test/overlay-options.test.ts +++ b/packages/tui/test/overlay-options.test.ts @@ -29,7 +29,7 @@ class EmptyContent implements Component { async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise { tui.requestRender(true); await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); + await terminal.waitForRender(); } describe("TUI overlay options", () => { diff --git a/packages/tui/test/overlay-short-content.test.ts b/packages/tui/test/overlay-short-content.test.ts index 13c07fe3..98c37029 100644 --- a/packages/tui/test/overlay-short-content.test.ts +++ b/packages/tui/test/overlay-short-content.test.ts @@ -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")); diff --git a/packages/tui/test/tui-overlay-style-leak.test.ts b/packages/tui/test/tui-overlay-style-leak.test.ts index cf49f31b..59713371 100644 --- a/packages/tui/test/tui-overlay-style-leak.test.ts +++ b/packages/tui/test/tui-overlay-style-leak.test.ts @@ -37,7 +37,7 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise { tui.requestRender(true); await new Promise((resolve) => process.nextTick(resolve)); - await terminal.flush(); + await terminal.waitForRender(); } describe("TUI overlay compositing", () => { diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 6d0acd15..4357a367 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -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"); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 5630c0d7..60756b74 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -206,4 +206,11 @@ export class VirtualTerminal implements Terminal { y: buffer.cursorY, }; } + + /** Wait for TUI's throttled render pipeline to settle. */ + async waitForRender(): Promise { + await new Promise((resolve) => process.nextTick(resolve)); + await new Promise((resolve) => setTimeout(resolve, 20)); + await this.flush(); + } }