mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-18 10:01:29 -07:00
Migrate core render util to use xterm.js as part of the rendering loop. (#19044)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user