Migrate core render util to use xterm.js as part of the rendering loop. (#19044)

This commit is contained in:
Jacob Richman
2026-02-18 16:46:50 -08:00
committed by GitHub
parent 04c52513e7
commit 04f65f3d55
213 changed files with 7065 additions and 3852 deletions

View File

@@ -5,6 +5,7 @@
*/
import { describe, it, afterEach, expect } from 'vitest';
import { act } from 'react';
import { AppRig } from './AppRig.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -68,7 +69,11 @@ describe('AppRig', () => {
);
rig = new AppRig({ fakeResponsesPath });
await rig.initialize();
rig.render();
await act(async () => {
rig!.render();
// Allow async initializations (like banners) to settle within the act boundary
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Wait for initial render
await rig.waitForIdle();

View File

@@ -501,7 +501,7 @@ export class AppRig {
get lastFrame() {
if (!this.renderResult) return '';
return stripAnsi(this.renderResult.lastFrame() || '');
return stripAnsi(this.renderResult.lastFrame({ allowEmpty: true }) || '');
}
getStaticOutput() {

View File

@@ -13,14 +13,14 @@ import { vi } from 'vitest';
// The version of waitFor from vitest is still fine to use if you aren't waiting
// for React state updates.
export async function waitFor(
assertion: () => void,
assertion: () => void | Promise<void>,
{ timeout = 2000, interval = 50 } = {},
): Promise<void> {
const startTime = Date.now();
while (true) {
try {
assertion();
await assertion();
return;
} catch (error) {
if (Date.now() - startTime > timeout) {

View File

@@ -5,36 +5,48 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { useState, useEffect } from 'react';
import { useState, useEffect, act } from 'react';
import { Text } from 'ink';
import { renderHook, render } from './render.js';
import { waitFor } from './async.js';
describe('render', () => {
it('should render a component', () => {
const { lastFrame } = render(<Text>Hello World</Text>);
expect(lastFrame()).toBe('Hello World');
it('should render a component', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<Text>Hello World</Text>,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello World\n');
unmount();
});
it('should support rerender', () => {
const { lastFrame, rerender } = render(<Text>Hello</Text>);
expect(lastFrame()).toBe('Hello');
it('should support rerender', async () => {
const { lastFrame, rerender, waitUntilReady, unmount } = render(
<Text>Hello</Text>,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello\n');
rerender(<Text>World</Text>);
expect(lastFrame()).toBe('World');
await act(async () => {
rerender(<Text>World</Text>);
});
await waitUntilReady();
expect(lastFrame()).toBe('World\n');
unmount();
});
it('should support unmount', () => {
const cleanup = vi.fn();
it('should support unmount', async () => {
const cleanupMock = vi.fn();
function TestComponent() {
useEffect(() => cleanup, []);
useEffect(() => cleanupMock, []);
return <Text>Hello</Text>;
}
const { unmount } = render(<TestComponent />);
const { unmount, waitUntilReady } = render(<TestComponent />);
await waitUntilReady();
unmount();
expect(cleanup).toHaveBeenCalled();
expect(cleanupMock).toHaveBeenCalled();
});
});
@@ -48,49 +60,74 @@ describe('renderHook', () => {
return { count, value };
};
const { result, rerender } = renderHook(useTestHook, {
initialProps: { value: 1 },
});
const { result, rerender, waitUntilReady, unmount } = renderHook(
useTestHook,
{
initialProps: { value: 1 },
},
);
await waitUntilReady();
expect(result.current.value).toBe(1);
await waitFor(() => expect(result.current.count).toBe(1));
// Rerender with new props
rerender({ value: 2 });
await act(async () => {
rerender({ value: 2 });
});
await waitUntilReady();
expect(result.current.value).toBe(2);
await waitFor(() => expect(result.current.count).toBe(2));
// Rerender without arguments should use previous props (value: 2)
// This would previously crash or pass undefined if not fixed
rerender();
await act(async () => {
rerender();
});
await waitUntilReady();
expect(result.current.value).toBe(2);
// Count should not increase because value didn't change
await waitFor(() => expect(result.current.count).toBe(2));
unmount();
});
it('should handle initial render without props', () => {
it('should handle initial render without props', async () => {
const useTestHook = () => {
const [count, setCount] = useState(0);
return { count, increment: () => setCount((c) => c + 1) };
};
const { result, rerender } = renderHook(useTestHook);
const { result, rerender, waitUntilReady, unmount } =
renderHook(useTestHook);
await waitUntilReady();
expect(result.current.count).toBe(0);
rerender();
await act(async () => {
rerender();
});
await waitUntilReady();
expect(result.current.count).toBe(0);
unmount();
});
it('should update props if undefined is passed explicitly', () => {
it('should update props if undefined is passed explicitly', async () => {
const useTestHook = (val: string | undefined) => val;
const { result, rerender } = renderHook(useTestHook, {
initialProps: 'initial',
});
const { result, rerender, waitUntilReady, unmount } = renderHook(
useTestHook,
{
initialProps: 'initial' as string | undefined,
},
);
await waitUntilReady();
expect(result.current).toBe('initial');
rerender(undefined);
await act(async () => {
rerender(undefined);
});
await waitUntilReady();
expect(result.current).toBeUndefined();
unmount();
});
});

View File

@@ -4,10 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render as inkRender } from 'ink-testing-library';
import { render as inkRenderDirect, type Instance as InkInstance } from 'ink';
import { EventEmitter } from 'node:events';
import { Box } from 'ink';
import type React from 'react';
import { Terminal } from '@xterm/headless';
import { vi } from 'vitest';
import stripAnsi from 'strip-ansi';
import { act, useState } from 'react';
import os from 'node:os';
import { LoadedSettings } from '../config/settings.js';
@@ -40,6 +43,15 @@ import { pickDefaultThemeName } from '../ui/themes/theme.js';
export const persistentStateMock = new FakePersistentState();
if (process.env['NODE_ENV'] === 'test') {
// We mock NODE_ENV to development during tests that use render.tsx
// so that animations (which check process.env.NODE_ENV !== 'test')
// are actually tested. We mutate process.env directly here because
// vi.stubEnv() is cleared by vi.unstubAllEnvs() in test-setup.ts
// after each test.
process.env['NODE_ENV'] = 'development';
}
vi.mock('../utils/persistentState.js', () => ({
persistentState: persistentStateMock,
}));
@@ -50,51 +62,356 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({
isITerm2: vi.fn(() => false),
}));
// Wrapper around ink-testing-library's render that ensures act() is called
type TerminalState = {
terminal: Terminal;
cols: number;
rows: number;
};
class XtermStdout extends EventEmitter {
private state: TerminalState;
private pendingWrites = 0;
private renderCount = 0;
private queue: { promise: Promise<void> };
isTTY = true;
private lastRenderOutput: string | undefined = undefined;
private lastRenderStaticContent: string | undefined = undefined;
constructor(state: TerminalState, queue: { promise: Promise<void> }) {
super();
this.state = state;
this.queue = queue;
}
get columns() {
return this.state.terminal.cols;
}
get rows() {
return this.state.terminal.rows;
}
get frames(): string[] {
return [];
}
write = (data: string) => {
this.pendingWrites++;
this.queue.promise = this.queue.promise.then(async () => {
await new Promise<void>((resolve) =>
this.state.terminal.write(data, resolve),
);
this.pendingWrites--;
});
};
clear = () => {
this.state.terminal.reset();
this.lastRenderOutput = undefined;
this.lastRenderStaticContent = undefined;
};
dispose = () => {
this.state.terminal.dispose();
};
onRender = (staticContent: string, output: string) => {
this.renderCount++;
this.lastRenderStaticContent = staticContent;
this.lastRenderOutput = output;
this.emit('render');
};
lastFrame = (options: { allowEmpty?: boolean } = {}) => {
let result: string;
// On Windows, xterm.js headless can sometimes have timing or rendering issues
// that lead to duplicated content or incorrect buffer state in tests.
// As a fallback, we can trust the raw output Ink provided during onRender.
if (os.platform() === 'win32') {
result =
(this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? '');
} else {
const buffer = this.state.terminal.buffer.active;
const allLines: string[] = [];
for (let i = 0; i < buffer.length; i++) {
allLines.push(buffer.getLine(i)?.translateToString(true) ?? '');
}
const trimmed = [...allLines];
while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') {
trimmed.pop();
}
result = trimmed.join('\n');
}
// Normalize for cross-platform snapshot stability:
// Normalize any \r\n to \n
const normalized = result.replace(/\r\n/g, '\n');
if (normalized === '' && !options.allowEmpty) {
throw new Error(
'lastFrame() returned an empty string. If this is intentional, use lastFrame({ allowEmpty: true }). ' +
'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.',
);
}
return normalized === '' ? normalized : normalized + '\n';
};
async waitUntilReady() {
const startRenderCount = this.renderCount;
if (!vi.isFakeTimers()) {
// Give Ink a chance to start its rendering loop
await new Promise((resolve) => setImmediate(resolve));
}
await act(async () => {
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(50);
} else {
// Wait for at least one render to be called if we haven't rendered yet or since start of this call,
// but don't wait forever as some renders might be synchronous or skipped.
if (this.renderCount === startRenderCount) {
const renderPromise = new Promise((resolve) =>
this.once('render', resolve),
);
const timeoutPromise = new Promise((resolve) =>
setTimeout(resolve, 50),
);
await Promise.race([renderPromise, timeoutPromise]);
}
}
});
let attempts = 0;
const maxAttempts = 50;
let lastCurrent = '';
let lastExpected = '';
while (attempts < maxAttempts) {
// Ensure all pending writes to the terminal are processed.
await this.queue.promise;
const currentFrame = stripAnsi(
this.lastFrame({ allowEmpty: true }),
).trim();
const expectedFrame = stripAnsi(
(this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''),
)
.trim()
.replace(/\r\n/g, '\n');
lastCurrent = currentFrame;
lastExpected = expectedFrame;
const isMatch = () => {
if (expectedFrame === '...') {
return currentFrame !== '';
}
// If both are empty, it's a match.
// We consider undefined lastRenderOutput as effectively empty for this check
// to support hook testing where Ink may skip rendering completely.
if (
(this.lastRenderOutput === undefined || expectedFrame === '') &&
currentFrame === ''
) {
return true;
}
if (this.lastRenderOutput === undefined) {
return false;
}
// If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match.
if (expectedFrame === '' || currentFrame === '') {
return false;
}
// Check if the current frame contains the expected content.
// We use includes because xterm might have some formatting or
// extra whitespace that Ink doesn't account for in its raw output metrics.
return currentFrame.includes(expectedFrame);
};
if (this.pendingWrites === 0 && isMatch()) {
return;
}
attempts++;
await act(async () => {
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(10);
} else {
await new Promise((resolve) => setTimeout(resolve, 10));
}
});
}
throw new Error(
`waitUntilReady() timed out after ${maxAttempts} attempts.\n` +
`Expected content (stripped ANSI):\n"${lastExpected}"\n` +
`Actual content (stripped ANSI):\n"${lastCurrent}"\n` +
`Pending writes: ${this.pendingWrites}\n` +
`Render count: ${this.renderCount}`,
);
}
}
class XtermStderr extends EventEmitter {
private state: TerminalState;
private pendingWrites = 0;
private queue: { promise: Promise<void> };
isTTY = true;
constructor(state: TerminalState, queue: { promise: Promise<void> }) {
super();
this.state = state;
this.queue = queue;
}
write = (data: string) => {
this.pendingWrites++;
this.queue.promise = this.queue.promise.then(async () => {
await new Promise<void>((resolve) =>
this.state.terminal.write(data, resolve),
);
this.pendingWrites--;
});
};
dispose = () => {
this.state.terminal.dispose();
};
lastFrame = () => '';
}
class XtermStdin extends EventEmitter {
isTTY = true;
data: string | null = null;
constructor(options: { isTTY?: boolean } = {}) {
super();
this.isTTY = options.isTTY ?? true;
}
write = (data: string) => {
this.data = data;
this.emit('readable');
this.emit('data', data);
};
setEncoding() {}
setRawMode() {}
resume() {}
pause() {}
ref() {}
unref() {}
read = () => {
const { data } = this;
this.data = null;
return data;
};
}
export type RenderInstance = {
rerender: (tree: React.ReactElement) => void;
unmount: () => void;
cleanup: () => void;
stdout: XtermStdout;
stderr: XtermStderr;
stdin: XtermStdin;
frames: string[];
lastFrame: (options?: { allowEmpty?: boolean }) => string;
terminal: Terminal;
waitUntilReady: () => Promise<void>;
};
const instances: InkInstance[] = [];
// Wrapper around ink's render that ensures act() is called and uses Xterm for output
export const render = (
tree: React.ReactElement,
terminalWidth?: number,
): ReturnType<typeof inkRender> => {
let renderResult: ReturnType<typeof inkRender> =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
undefined as unknown as ReturnType<typeof inkRender>;
act(() => {
renderResult = inkRender(tree);
): RenderInstance => {
const cols = terminalWidth ?? 100;
const rows = 40;
const terminal = new Terminal({
cols,
rows,
allowProposedApi: true,
convertEol: true,
});
if (terminalWidth !== undefined && renderResult?.stdout) {
// Override the columns getter on the stdout instance provided by ink-testing-library
Object.defineProperty(renderResult.stdout, 'columns', {
get: () => terminalWidth,
configurable: true,
});
const state: TerminalState = {
terminal,
cols,
rows,
};
const writeQueue = { promise: Promise.resolve() };
const stdout = new XtermStdout(state, writeQueue);
const stderr = new XtermStderr(state, writeQueue);
const stdin = new XtermStdin();
// Trigger a rerender so Ink can pick up the new terminal width
act(() => {
renderResult.rerender(tree);
let instance!: InkInstance;
stdout.clear();
act(() => {
instance = inkRenderDirect(tree, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stdout: stdout as unknown as NodeJS.WriteStream,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stderr: stderr as unknown as NodeJS.WriteStream,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stdin: stdin as unknown as NodeJS.ReadStream,
debug: false,
exitOnCtrlC: false,
patchConsole: false,
onRender: (metrics: { output: string; staticOutput?: string }) => {
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
},
});
}
});
const originalUnmount = renderResult.unmount;
const originalRerender = renderResult.rerender;
instances.push(instance);
return {
...renderResult,
unmount: () => {
act(() => {
originalUnmount();
});
},
rerender: (newTree: React.ReactElement) => {
act(() => {
originalRerender(newTree);
stdout.clear();
instance.rerender(newTree);
});
},
unmount: () => {
act(() => {
instance.unmount();
});
stdout.dispose();
stderr.dispose();
},
cleanup: instance.cleanup,
stdout,
stderr,
stdin,
frames: stdout.frames,
lastFrame: stdout.lastFrame,
terminal: state.terminal,
waitUntilReady: () => stdout.waitUntilReady(),
};
};
export const cleanup = () => {
for (const instance of instances) {
act(() => {
instance.unmount();
});
instance.cleanup();
}
instances.length = 0;
};
export const simulateClick = async (
stdin: ReturnType<typeof inkRender>['stdin'],
stdin: XtermStdin,
col: number,
row: number,
button: 0 | 1 | 2 = 0, // 0 for left, 1 for middle, 2 for right
@@ -151,7 +468,7 @@ export const mockSettings = new LoadedSettings(
const baseMockUiState = {
renderMarkdown: true,
streamingState: StreamingState.Idle,
terminalWidth: 120,
terminalWidth: 100,
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: 'black',
@@ -258,7 +575,13 @@ export const renderWithProviders = (
};
appState?: AppState;
} = {},
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
): RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
} => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
@@ -377,7 +700,11 @@ export const renderWithProviders = (
terminalWidth,
);
return { ...renderResult, simulateClick };
return {
...renderResult,
simulateClick: (col: number, row: number, button?: 0 | 1 | 2) =>
simulateClick(renderResult.stdin, col, row, button),
};
};
export function renderHook<Result, Props>(
@@ -390,6 +717,7 @@ export function renderHook<Result, Props>(
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
@@ -411,6 +739,7 @@ export function renderHook<Result, Props>(
let inkRerender: (tree: React.ReactElement) => void = () => {};
let unmount: () => void = () => {};
let waitUntilReady: () => Promise<void> = async () => {};
act(() => {
const renderResult = render(
@@ -420,6 +749,7 @@ export function renderHook<Result, Props>(
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
});
function rerender(props?: Props) {
@@ -436,7 +766,7 @@ export function renderHook<Result, Props>(
});
}
return { result, rerender, unmount };
return { result, rerender, unmount, waitUntilReady };
}
export function renderHookWithProviders<Result, Props>(
@@ -457,6 +787,7 @@ export function renderHookWithProviders<Result, Props>(
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
@@ -506,5 +837,6 @@ export function renderHookWithProviders<Result, Props>(
renderResult.unmount();
});
},
waitUntilReady: () => renderResult.waitUntilReady(),
};
}