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
+2 -2
View File
@@ -48,7 +48,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.10",
"ink": "npm:@jrichman/ink@6.4.11",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -80,7 +80,7 @@
"@types/shell-quote": "^1.7.5",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"ink-testing-library": "^4.0.0",
"@xterm/headless": "^5.5.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
@@ -269,7 +269,7 @@ describe('github.ts', () => {
it('should return NOT_UPDATABLE if local extension config cannot be loaded', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockImplementation(
() => {
async () => {
throw new Error('Config not found');
},
);
+20 -12
View File
@@ -491,25 +491,33 @@ describe('Trusted Folders', () => {
});
});
const itif = (condition: boolean) => (condition ? it : it.skip);
describe('Symlinks Support', () => {
const mockSettings: Settings = {
security: { folderTrust: { enabled: true } },
};
it('should trust a folder if the rule matches the realpath', () => {
// Create a real directory and a symlink
const realDir = path.join(tempDir, 'real');
const symlinkDir = path.join(tempDir, 'symlink');
fs.mkdirSync(realDir);
fs.symlinkSync(realDir, symlinkDir);
// TODO: issue 19387 - Enable symlink tests on Windows
itif(process.platform !== 'win32')(
'should trust a folder if the rule matches the realpath',
() => {
// Create a real directory and a symlink
const realDir = path.join(tempDir, 'real');
const symlinkDir = path.join(tempDir, 'symlink');
fs.mkdirSync(realDir);
fs.symlinkSync(realDir, symlinkDir);
// Rule uses realpath
const config = { [realDir]: TrustLevel.TRUST_FOLDER };
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
// Rule uses realpath
const config = { [realDir]: TrustLevel.TRUST_FOLDER };
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
// Check against symlink path
expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe(true);
});
// Check against symlink path
expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe(
true,
);
},
);
});
describe('Verification: Auth and Trust Interaction', () => {
+6 -1
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();
+1 -1
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() {
+2 -2
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) {
+64 -27
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();
});
});
+364 -32
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(),
};
}
+103 -48
View File
@@ -92,32 +92,42 @@ describe('App', () => {
backgroundShells: new Map(),
};
it('should render main content and composer when not quitting', () => {
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
useAlternateBuffer: false,
});
it('should render main content and composer when not quitting', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
useAlternateBuffer: false,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Composer');
unmount();
});
it('should render quitting display when quittingMessages is set', () => {
it('should render quitting display when quittingMessages is set', async () => {
const quittingUIState = {
...mockUIState,
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
} as UIState;
const { lastFrame } = renderWithProviders(<App />, {
uiState: quittingUIState,
useAlternateBuffer: false,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: quittingUIState,
useAlternateBuffer: false,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Quitting...');
unmount();
});
it('should render full history in alternate buffer mode when quittingMessages is set', () => {
it('should render full history in alternate buffer mode when quittingMessages is set', async () => {
const quittingUIState = {
...mockUIState,
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
@@ -125,28 +135,38 @@ describe('App', () => {
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
} as UIState;
const { lastFrame } = renderWithProviders(<App />, {
uiState: quittingUIState,
useAlternateBuffer: true,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: quittingUIState,
useAlternateBuffer: true,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('HistoryItemDisplay');
expect(lastFrame()).toContain('Quitting...');
unmount();
});
it('should render dialog manager when dialogs are visible', () => {
it('should render dialog manager when dialogs are visible', async () => {
const dialogUIState = {
...mockUIState,
dialogsVisible: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, {
uiState: dialogUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: dialogUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('DialogManager');
unmount();
});
it.each([
@@ -154,47 +174,62 @@ describe('App', () => {
{ key: 'D', stateKey: 'ctrlDPressedOnce' },
])(
'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true',
({ key, stateKey }) => {
async ({ key, stateKey }) => {
const uiState = {
...mockUIState,
dialogsVisible: true,
[stateKey]: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, {
uiState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
unmount();
},
);
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
it('should render ScreenReaderAppLayout when screen reader is enabled', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Footer');
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Composer');
unmount();
});
it('should render DefaultAppLayout when screen reader is not enabled', () => {
it('should render DefaultAppLayout when screen reader is not enabled', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Composer');
unmount();
});
it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', () => {
it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const toolCalls = [
@@ -234,44 +269,64 @@ describe('App', () => {
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
const { lastFrame } = renderWithProviders(<App />, {
uiState: stateWithConfirmingTool,
config: configWithExperiment,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: stateWithConfirmingTool,
config: configWithExperiment,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
expect(lastFrame()).toContain('Composer');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
describe('Snapshots', () => {
it('renders default layout correctly', () => {
it('renders default layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders screen reader layout correctly', () => {
it('renders screen reader layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with dialogs visible', () => {
it('renders with dialogs visible', async () => {
const dialogUIState = {
...mockUIState,
dialogsVisible: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, {
uiState: dialogUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: dialogUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
});
+1 -2
View File
@@ -14,9 +14,8 @@ import {
type Mock,
type MockedObject,
} from 'vitest';
import { render, persistentStateMock } from '../test-utils/render.js';
import { render, cleanup, persistentStateMock } from '../test-utils/render.js';
import { waitFor } from '../test-utils/async.js';
import { cleanup } from 'ink-testing-library';
import { act, useContext, type ReactElement } from 'react';
import { AppContainer } from './AppContainer.js';
import { SettingsContext } from './contexts/SettingsContext.js';
@@ -11,8 +11,6 @@ import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
import { debugLogger } from '@google/gemini-cli-core';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Mock debugLogger
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -56,128 +54,129 @@ describe('IdeIntegrationNudge', () => {
});
it('renders correctly with default options', async () => {
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
expect(frame).toContain('Yes');
expect(frame).toContain('No (esc)');
expect(frame).toContain("No, don't ask again");
unmount();
});
it('handles "Yes" selection', async () => {
const onComplete = vi.fn();
const { stdin } = render(
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
// "Yes" is the first option and selected by default usually.
await act(async () => {
stdin.write('\r');
await delay(100);
});
await waitUntilReady();
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'yes',
isExtensionPreInstalled: false,
});
unmount();
});
it('handles "No" selection', async () => {
const onComplete = vi.fn();
const { stdin } = render(
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
// Navigate down to "No (esc)"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
await delay(100);
});
await waitUntilReady();
await act(async () => {
stdin.write('\r'); // Enter
await delay(100);
});
await waitUntilReady();
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'no',
isExtensionPreInstalled: false,
});
unmount();
});
it('handles "Dismiss" selection', async () => {
const onComplete = vi.fn();
const { stdin } = render(
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
// Navigate down to "No, don't ask again"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
await delay(100);
});
await waitUntilReady();
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
await delay(100);
});
await waitUntilReady();
await act(async () => {
stdin.write('\r'); // Enter
await delay(100);
});
await waitUntilReady();
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'dismiss',
isExtensionPreInstalled: false,
});
unmount();
});
it('handles Escape key press', async () => {
const onComplete = vi.fn();
const { stdin } = render(
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
// Press Escape
await act(async () => {
stdin.write('\u001B');
await delay(100);
});
// Escape key has a timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'no',
isExtensionPreInstalled: false,
});
unmount();
});
it('displays correct text and handles selection when extension is pre-installed', async () => {
@@ -185,15 +184,13 @@ describe('IdeIntegrationNudge', () => {
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp');
const onComplete = vi.fn();
const { lastFrame, stdin } = render(
const { lastFrame, stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
const frame = lastFrame();
@@ -205,12 +202,13 @@ describe('IdeIntegrationNudge', () => {
// Select "Yes"
await act(async () => {
stdin.write('\r');
await delay(100);
});
await waitUntilReady();
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'yes',
isExtensionPreInstalled: true,
});
unmount();
});
});
@@ -61,7 +61,8 @@ Tips for getting started:
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
Composer"
Composer
"
`;
exports[`App > Snapshots > renders with dialogs visible 1`] = `
@@ -124,19 +125,19 @@ Tips for getting started:
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
HistoryItemDisplay
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ Action Required
│ ? ls list directory
│ ls
│ Allow execution of: 'ls'?
│ ● 1. Allow once
│ 2. Allow for this session
│ 3. No, suggest changes (esc)
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required │
│ │
│ ? ls list directory │
│ │
│ ls │
│ Allow execution of: 'ls'? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -65,21 +65,24 @@ describe('ApiAuthDialog', () => {
mockedUseTextBuffer.mockReturnValue(mockBuffer);
});
it('renders correctly', () => {
const { lastFrame } = render(
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with a defaultValue', () => {
render(
it('renders with a defaultValue', async () => {
const { waitUntilReady, unmount } = render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
defaultValue="test-key"
/>,
);
await waitUntilReady();
expect(mockedUseTextBuffer).toHaveBeenCalledWith(
expect.objectContaining({
initialText: 'test-key',
@@ -88,6 +91,7 @@ describe('ApiAuthDialog', () => {
}),
}),
);
unmount();
});
it.each([
@@ -100,9 +104,12 @@ describe('ApiAuthDialog', () => {
{ keyName: 'escape', sequence: '\u001b', expectedCall: onCancel, args: [] },
])(
'calls $expectedCall.name when $keyName is pressed',
({ keyName, sequence, expectedCall, args }) => {
async ({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
const { waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
// calls[1] is the TextInput's useKeypress (typing handler)
const keypressHandler = mockedUseKeypress.mock.calls[1][0];
@@ -117,23 +124,29 @@ describe('ApiAuthDialog', () => {
});
expect(expectedCall).toHaveBeenCalledWith(...args);
unmount();
},
);
it('displays an error message', () => {
const { lastFrame } = render(
it('displays an error message', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
error="Invalid API Key"
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Invalid API Key');
unmount();
});
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
const { waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// Call 0 is ApiAuthDialog (isActive: true)
// Call 1 is TextInput (isActive: true, priority: true)
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
@@ -149,5 +162,6 @@ describe('ApiAuthDialog', () => {
expect(clearApiKey).toHaveBeenCalled();
expect(mockBuffer.setText).toHaveBeenCalledWith('');
});
unmount();
});
});
+95 -27
View File
@@ -139,11 +139,14 @@ describe('AuthDialog', () => {
},
])(
'correctly shows/hides COMPUTE_ADC options $desc',
({ env, shouldContain, shouldNotContain }) => {
async ({ env, shouldContain, shouldNotContain }) => {
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value as string);
}
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
for (const item of shouldContain) {
expect(items).toContainEqual(item);
@@ -151,23 +154,32 @@ describe('AuthDialog', () => {
for (const item of shouldNotContain) {
expect(items).not.toContainEqual(item);
}
unmount();
},
);
});
it('filters auth types when enforcedType is set', () => {
it('filters auth types when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(1);
expect(items[0].value).toBe(AuthType.USE_GEMINI);
unmount();
});
it('sets initial index to 0 when enforcedType is set', () => {
it('sets initial index to 0 when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(initialIndex).toBe(0);
unmount();
});
describe('Initial Auth Type Selection', () => {
@@ -199,18 +211,25 @@ describe('AuthDialog', () => {
expected: AuthType.LOGIN_WITH_GOOGLE,
desc: 'defaults to Login with Google',
},
])('selects initial auth type $desc', ({ setup, expected }) => {
])('selects initial auth type $desc', async ({ setup, expected }) => {
setup();
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(expected);
unmount();
});
});
describe('handleAuthSelect', () => {
it('calls onAuthError if validation fails', () => {
it('calls onAuthError if validation fails', async () => {
mockedValidateAuthMethod.mockReturnValue('Invalid method');
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
handleAuthSelect(AuthType.USE_GEMINI);
@@ -221,11 +240,15 @@ describe('AuthDialog', () => {
);
expect(props.onAuthError).toHaveBeenCalledWith('Invalid method');
expect(props.settings.setValue).not.toHaveBeenCalled();
unmount();
});
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
@@ -233,16 +256,21 @@ describe('AuthDialog', () => {
expect(props.setAuthContext).toHaveBeenCalledWith({
requiresRestart: true,
});
unmount();
});
it('sets auth context with empty object for other auth types', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
expect(props.setAuthContext).toHaveBeenCalledWith({});
unmount();
});
it('skips API key dialog on initial setup if env var is present', async () => {
@@ -250,7 +278,10 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -258,6 +289,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
unmount();
});
it('skips API key dialog if env var is present but empty', async () => {
@@ -265,7 +297,10 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -273,6 +308,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
unmount();
});
it('shows API key dialog on initial setup if no env var is present', async () => {
@@ -280,7 +316,10 @@ describe('AuthDialog', () => {
// process.env['GEMINI_API_KEY'] is not set
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -288,6 +327,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.AwaitingApiKeyInput,
);
unmount();
});
it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => {
@@ -297,7 +337,10 @@ describe('AuthDialog', () => {
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -305,6 +348,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
unmount();
});
it('exits process for Login with Google when browser is suppressed', async () => {
@@ -316,7 +360,10 @@ describe('AuthDialog', () => {
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -330,13 +377,18 @@ describe('AuthDialog', () => {
exitSpy.mockRestore();
logSpy.mockRestore();
vi.useRealTimers();
unmount();
});
});
it('displays authError when provided', () => {
it('displays authError when provided', async () => {
props.authError = 'Something went wrong';
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Something went wrong');
unmount();
});
describe('useKeypress', () => {
@@ -375,31 +427,47 @@ describe('AuthDialog', () => {
expect(p.settings.setValue).not.toHaveBeenCalled();
},
},
])('$desc', ({ setup, expectations }) => {
])('$desc', async ({ setup, expectations }) => {
setup();
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expectations(props);
unmount();
});
});
describe('Snapshots', () => {
it('renders correctly with default props', () => {
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
it('renders correctly with default props', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with auth error', () => {
it('renders correctly with auth error', async () => {
props.authError = 'Something went wrong';
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with enforced auth type', () => {
it('renders correctly with enforced auth type', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
});
@@ -54,48 +54,80 @@ describe('AuthInProgress', () => {
vi.useRealTimers();
});
it('renders initial state with spinner', () => {
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
it('renders initial state with spinner', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('[Spinner] Waiting for auth...');
expect(lastFrame()).toContain('Press ESC or CTRL+C to cancel');
unmount();
});
it('calls onTimeout when ESC is pressed', () => {
render(<AuthInProgress onTimeout={onTimeout} />);
it('calls onTimeout when ESC is pressed', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
keypressHandler({ name: 'escape' } as unknown as Key);
await act(async () => {
keypressHandler({ name: 'escape' } as unknown as Key);
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
expect(onTimeout).toHaveBeenCalled();
unmount();
});
it('calls onTimeout when Ctrl+C is pressed', () => {
render(<AuthInProgress onTimeout={onTimeout} />);
it('calls onTimeout when Ctrl+C is pressed', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
await act(async () => {
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
});
await waitUntilReady();
expect(onTimeout).toHaveBeenCalled();
unmount();
});
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
vi.advanceTimersByTime(180000);
});
await waitUntilReady();
expect(onTimeout).toHaveBeenCalled();
await vi.waitUntil(
() => lastFrame()?.includes('Authentication timed out'),
{ timeout: 1000 },
);
expect(lastFrame()).toContain('Authentication timed out');
unmount();
});
it('clears timer on unmount', () => {
const { unmount } = render(<AuthInProgress onTimeout={onTimeout} />);
act(() => {
it('clears timer on unmount', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
unmount();
});
vi.advanceTimersByTime(180000);
await act(async () => {
vi.advanceTimersByTime(180000);
});
expect(onTimeout).not.toHaveBeenCalled();
});
});
@@ -40,23 +40,26 @@ describe('LoginWithGoogleRestartDialog', () => {
vi.useRealTimers();
});
it('renders correctly', () => {
const { lastFrame } = render(
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('calls onDismiss when escape is pressed', () => {
render(
it('calls onDismiss when escape is pressed', async () => {
const { waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
@@ -68,6 +71,7 @@ describe('LoginWithGoogleRestartDialog', () => {
});
expect(onDismiss).toHaveBeenCalledTimes(1);
unmount();
});
it.each(['r', 'R'])(
@@ -75,12 +79,13 @@ describe('LoginWithGoogleRestartDialog', () => {
async (keyName) => {
vi.useFakeTimers();
render(
const { waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
@@ -98,6 +103,7 @@ describe('LoginWithGoogleRestartDialog', () => {
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
vi.useRealTimers();
unmount();
},
);
});
@@ -14,5 +14,6 @@ exports[`ApiAuthDialog > renders correctly 1`] = `
│ │
│ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -1,57 +1,60 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ? Get started
│ How would you like to authenticate for this project?
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI
│ Something went wrong
│ (Use Enter to select)
│ Terms of Services and Privacy Notice for Gemini CLI
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
│ │
│ Something went wrong │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────
"
`;
exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ? Get started
│ How would you like to authenticate for this project?
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI
│ (Use Enter to select)
│ Terms of Services and Privacy Notice for Gemini CLI
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────
"
`;
exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ? Get started
│ How would you like to authenticate for this project?
│ (selected) Use Gemini API Key
│ (Use Enter to select)
│ Terms of Services and Privacy Notice for Gemini CLI
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Use Gemini API Key │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────
"
`;
@@ -4,5 +4,6 @@ exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ You have successfully logged in with Google. Gemini CLI needs to be restarted. Press 'r' to │
│ restart, or 'escape' to choose a different auth method. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -290,7 +290,7 @@ describe('extensionsCommand', () => {
it('should inform user if there are no extensions to update with --all', async () => {
mockDispatchExtensionState.mockImplementationOnce(
(action: ExtensionUpdateAction) => {
async (action: ExtensionUpdateAction) => {
if (action.type === 'SCHEDULE_UPDATE') {
action.payload.onComplete([]);
}
@@ -306,7 +306,7 @@ describe('extensionsCommand', () => {
it('should call setPendingItem and addItem in a finally block on success', async () => {
mockDispatchExtensionState.mockImplementationOnce(
(action: ExtensionUpdateAction) => {
async (action: ExtensionUpdateAction) => {
if (action.type === 'SCHEDULE_UPDATE') {
action.payload.onComplete([
{
@@ -357,7 +357,7 @@ describe('extensionsCommand', () => {
it('should update a single extension by name', async () => {
mockDispatchExtensionState.mockImplementationOnce(
(action: ExtensionUpdateAction) => {
async (action: ExtensionUpdateAction) => {
if (action.type === 'SCHEDULE_UPDATE') {
action.payload.onComplete([
{
@@ -382,7 +382,7 @@ describe('extensionsCommand', () => {
it('should update multiple extensions by name', async () => {
mockDispatchExtensionState.mockImplementationOnce(
(action: ExtensionUpdateAction) => {
async (action: ExtensionUpdateAction) => {
if (action.type === 'SCHEDULE_UPDATE') {
action.payload.onComplete([
{
@@ -24,8 +24,11 @@ describe('AboutBox', () => {
ideClient: '',
};
it('renders with required props', () => {
const { lastFrame } = renderWithProviders(<AboutBox {...defaultProps} />);
it('renders with required props', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AboutBox {...defaultProps} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('About Gemini CLI');
expect(output).toContain('1.0.0');
@@ -34,31 +37,44 @@ describe('AboutBox', () => {
expect(output).toContain('default');
expect(output).toContain('macOS');
expect(output).toContain('Logged in with Google');
unmount();
});
it.each([
['gcpProject', 'my-project', 'GCP Project'],
['ideClient', 'vscode', 'IDE Client'],
['tier', 'Enterprise', 'Tier'],
])('renders optional prop %s', (prop, value, label) => {
])('renders optional prop %s', async (prop, value, label) => {
const props = { ...defaultProps, [prop]: value };
const { lastFrame } = renderWithProviders(<AboutBox {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(label);
expect(output).toContain(value);
unmount();
});
it('renders Auth Method with email when userEmail is provided', () => {
it('renders Auth Method with email when userEmail is provided', async () => {
const props = { ...defaultProps, userEmail: 'test@example.com' };
const { lastFrame } = renderWithProviders(<AboutBox {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Logged in with Google (test@example.com)');
unmount();
});
it('renders Auth Method correctly when not oauth', () => {
it('renders Auth Method correctly when not oauth', async () => {
const props = { ...defaultProps, selectedAuthType: 'api-key' };
const { lastFrame } = renderWithProviders(<AboutBox {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('api-key');
unmount();
});
});
@@ -16,17 +16,24 @@ describe('AdminSettingsChangedDialog', () => {
vi.restoreAllMocks();
});
it('renders correctly', () => {
const { lastFrame } = renderWithProviders(<AdminSettingsChangedDialog />);
it('renders correctly', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<AdminSettingsChangedDialog />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('restarts on "r" key press', async () => {
const { stdin } = renderWithProviders(<AdminSettingsChangedDialog />, {
uiActions: {
handleRestart: handleRestartMock,
const { stdin, waitUntilReady } = renderWithProviders(
<AdminSettingsChangedDialog />,
{
uiActions: {
handleRestart: handleRestartMock,
},
},
});
);
await waitUntilReady();
act(() => {
stdin.write('r');
@@ -36,11 +43,15 @@ describe('AdminSettingsChangedDialog', () => {
});
it.each(['r', 'R'])('restarts on "%s" key press', async (key) => {
const { stdin } = renderWithProviders(<AdminSettingsChangedDialog />, {
uiActions: {
handleRestart: handleRestartMock,
const { stdin, waitUntilReady } = renderWithProviders(
<AdminSettingsChangedDialog />,
{
uiActions: {
handleRestart: handleRestartMock,
},
},
});
);
await waitUntilReady();
act(() => {
stdin.write(key);
@@ -118,11 +118,11 @@ describe('AgentConfigDialog', () => {
mockOnSave = vi.fn();
});
const renderDialog = (
const renderDialog = async (
settings: LoadedSettings,
definition: AgentDefinition = createMockAgentDefinition(),
) =>
render(
) => {
const result = render(
<KeypressProvider>
<AgentConfigDialog
agentName="test-agent"
@@ -134,18 +134,21 @@ describe('AgentConfigDialog', () => {
/>
</KeypressProvider>,
);
await result.waitUntilReady();
return result;
};
describe('rendering', () => {
it('should render the dialog with title', () => {
it('should render the dialog with title', async () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
const { lastFrame, unmount } = await renderDialog(settings);
expect(lastFrame()).toContain('Configure: Test Agent');
unmount();
});
it('should render all configuration fields', () => {
it('should render all configuration fields', async () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
const { lastFrame, unmount } = await renderDialog(settings);
const frame = lastFrame();
expect(frame).toContain('Enabled');
@@ -156,44 +159,53 @@ describe('AgentConfigDialog', () => {
expect(frame).toContain('Max Output Tokens');
expect(frame).toContain('Max Time (minutes)');
expect(frame).toContain('Max Turns');
unmount();
});
it('should render scope selector', () => {
it('should render scope selector', async () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
const { lastFrame, unmount } = await renderDialog(settings);
expect(lastFrame()).toContain('Apply To');
expect(lastFrame()).toContain('User Settings');
expect(lastFrame()).toContain('Workspace Settings');
unmount();
});
it('should render help text', () => {
it('should render help text', async () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
const { lastFrame, unmount } = await renderDialog(settings);
expect(lastFrame()).toContain('Use Enter to select');
expect(lastFrame()).toContain('Tab to change focus');
expect(lastFrame()).toContain('Esc to close');
unmount();
});
});
describe('keyboard navigation', () => {
it('should close dialog on Escape', async () => {
const settings = createMockSettings();
const { stdin } = renderDialog(settings);
const { stdin, waitUntilReady, unmount } = await renderDialog(settings);
await act(async () => {
stdin.write(TerminalKeys.ESCAPE);
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('should navigate down with arrow key', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = renderDialog(settings);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderDialog(settings);
// Initially first item (Enabled) should be active
expect(lastFrame()).toContain('●');
@@ -202,16 +214,19 @@ describe('AgentConfigDialog', () => {
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
await waitFor(() => {
// Model field should now be highlighted
expect(lastFrame()).toContain('Model');
});
unmount();
});
it('should switch focus with Tab', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = renderDialog(settings);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderDialog(settings);
// Initially settings section is focused
expect(lastFrame()).toContain('> Configure: Test Agent');
@@ -220,22 +235,25 @@ describe('AgentConfigDialog', () => {
await act(async () => {
stdin.write(TerminalKeys.TAB);
});
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('> Apply To');
});
unmount();
});
});
describe('boolean toggle', () => {
it('should toggle enabled field on Enter', async () => {
const settings = createMockSettings();
const { stdin } = renderDialog(settings);
const { stdin, waitUntilReady, unmount } = await renderDialog(settings);
// Press Enter to toggle the first field (Enabled)
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
await waitFor(() => {
expect(settings.setValue).toHaveBeenCalledWith(
@@ -245,11 +263,12 @@ describe('AgentConfigDialog', () => {
);
expect(mockOnSave).toHaveBeenCalled();
});
unmount();
});
});
describe('default values', () => {
it('should show values from agent definition as defaults', () => {
it('should show values from agent definition as defaults', async () => {
const definition = createMockAgentDefinition({
modelConfig: {
model: 'gemini-2.0-flash',
@@ -263,29 +282,31 @@ describe('AgentConfigDialog', () => {
},
});
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings, definition);
const { lastFrame, unmount } = await renderDialog(settings, definition);
const frame = lastFrame();
expect(frame).toContain('gemini-2.0-flash');
expect(frame).toContain('0.7');
expect(frame).toContain('10');
expect(frame).toContain('20');
unmount();
});
it('should show experimental agents as disabled by default', () => {
it('should show experimental agents as disabled by default', async () => {
const definition = createMockAgentDefinition({
experimental: true,
});
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings, definition);
const { lastFrame, unmount } = await renderDialog(settings, definition);
// Experimental agents default to disabled
expect(lastFrame()).toContain('false');
unmount();
});
});
describe('existing overrides', () => {
it('should show existing override values with * indicator', () => {
it('should show existing override values with * indicator', async () => {
const settings = createMockSettings({
agents: {
overrides: {
@@ -298,12 +319,13 @@ describe('AgentConfigDialog', () => {
},
},
});
const { lastFrame } = renderDialog(settings);
const { lastFrame, unmount } = await renderDialog(settings);
const frame = lastFrame();
// Should show the overridden values
expect(frame).toContain('custom-model');
expect(frame).toContain('false');
unmount();
});
});
});
@@ -106,9 +106,9 @@ describe('AlternateBufferQuittingDisplay', () => {
},
};
it('renders with active and pending tool messages', () => {
it('renders with active and pending tool messages', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -118,12 +118,14 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_history_and_pending');
unmount();
});
it('renders with empty history and no pending items', () => {
it('renders with empty history and no pending items', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -133,12 +135,14 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('empty');
unmount();
});
it('renders with history but no pending items', () => {
it('renders with history but no pending items', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -148,12 +152,14 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_history_no_pending');
unmount();
});
it('renders with pending items but no history', () => {
it('renders with pending items but no history', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -163,10 +169,12 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_pending_no_history');
unmount();
});
it('renders with a tool awaiting confirmation', () => {
it('renders with a tool awaiting confirmation', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const pendingHistoryItems: HistoryItemWithoutId[] = [
{
@@ -187,7 +195,7 @@ describe('AlternateBufferQuittingDisplay', () => {
],
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -197,20 +205,22 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Action Required (was prompted):');
expect(output).toContain('confirming_tool');
expect(output).toContain('Confirming tool description');
expect(output).toMatchSnapshot('with_confirming_tool');
unmount();
});
it('renders with user and gemini messages', () => {
it('renders with user and gemini messages', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const history: HistoryItem[] = [
{ id: 1, type: 'user', text: 'Hello Gemini' },
{ id: 2, type: 'gemini', text: 'Hello User!' },
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -220,6 +230,8 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');
unmount();
});
});
@@ -22,15 +22,19 @@ const createAnsiToken = (overrides: Partial<AnsiToken>): AnsiToken => ({
});
describe('<AnsiOutputText />', () => {
it('renders a simple AnsiOutput object correctly', () => {
it('renders a simple AnsiOutput object correctly', async () => {
const data: AnsiOutput = [
[
createAnsiToken({ text: 'Hello, ' }),
createAnsiToken({ text: 'world!' }),
],
];
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
expect(lastFrame()).toBe('Hello, world!');
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello, world!\n');
unmount();
});
// Note: ink-testing-library doesn't render styles, so we can only check the text.
@@ -41,73 +45,89 @@ describe('<AnsiOutputText />', () => {
{ style: { underline: true }, text: 'Underline' },
{ style: { dim: true }, text: 'Dim' },
{ style: { inverse: true }, text: 'Inverse' },
])('correctly applies style $text', ({ style, text }) => {
])('correctly applies style $text', async ({ style, text }) => {
const data: AnsiOutput = [[createAnsiToken({ text, ...style })]];
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
expect(lastFrame()).toBe(text);
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame()).toBe(text + '\n');
unmount();
});
it.each([
{ color: { fg: '#ff0000' }, text: 'Red FG' },
{ color: { bg: '#0000ff' }, text: 'Blue BG' },
{ color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' },
])('correctly applies color $text', ({ color, text }) => {
])('correctly applies color $text', async ({ color, text }) => {
const data: AnsiOutput = [[createAnsiToken({ text, ...color })]];
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
expect(lastFrame()).toBe(text);
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame()).toBe(text + '\n');
unmount();
});
it('handles empty lines and empty tokens', () => {
it('handles empty lines and empty tokens', async () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'First line' })],
[],
[createAnsiToken({ text: 'Third line' })],
[createAnsiToken({ text: '' })],
];
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
const lines = output!.split('\n');
const lines = output.split('\n');
expect(lines[0].trim()).toBe('First line');
expect(lines[1].trim()).toBe('');
expect(lines[2].trim()).toBe('Third line');
unmount();
});
it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
it('respects the availableTerminalHeight prop and slices the lines correctly', async () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} availableTerminalHeight={2} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
unmount();
});
it('respects the maxLines prop and slices the lines correctly', () => {
it('respects the maxLines prop and slices the lines correctly', async () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} maxLines={2} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
unmount();
});
it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', () => {
it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', async () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
@@ -115,7 +135,7 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 4' })],
];
// availableTerminalHeight=3, maxLines=2 => show 2 lines
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText
data={data}
availableTerminalHeight={3}
@@ -123,21 +143,25 @@ describe('<AnsiOutputText />', () => {
width={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
unmount();
});
it('renders a large AnsiOutput object without crashing', () => {
it('renders a large AnsiOutput object without crashing', async () => {
const largeData: AnsiOutput = [];
for (let i = 0; i < 1000; i++) {
largeData.push([createAnsiToken({ text: `Line ${i}` })]);
}
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={largeData} width={80} />,
);
await waitUntilReady();
// We are just checking that it renders something without crashing.
expect(lastFrame()).toBeDefined();
unmount();
});
});
@@ -18,7 +18,7 @@ vi.mock('../utils/terminalSetup.js', () => ({
}));
describe('<AppHeader />', () => {
it('should render the banner with default text', () => {
it('should render the banner with default text', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -29,20 +29,21 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render the banner with warning text', () => {
it('should render the banner with warning text', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -53,20 +54,21 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('There are capacity issues');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the banner when no flags are set', () => {
it('should not render the banner when no flags are set', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -76,20 +78,21 @@ describe('<AppHeader />', () => {
},
};
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the default banner if shown count is 5 or more', () => {
it('should not render the default banner if shown count is 5 or more', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -108,20 +111,21 @@ describe('<AppHeader />', () => {
},
});
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should increment the version count when default banner is displayed', () => {
it('should increment the version count when default banner is displayed', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -135,10 +139,14 @@ describe('<AppHeader />', () => {
// and interfering with the expected persistentState.set call.
persistentStateMock.setData({ tipsShown: 10 });
const { unmount } = renderWithProviders(<AppHeader version="1.0.0" />, {
config: mockConfig,
uiState,
});
const { waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(persistentStateMock.set).toHaveBeenCalledWith(
'defaultBannerShownCount',
@@ -152,7 +160,7 @@ describe('<AppHeader />', () => {
unmount();
});
it('should render banner text with unescaped newlines', () => {
it('should render banner text with unescaped newlines', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -163,19 +171,20 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('First line\\nSecond line');
unmount();
});
it('should render Tips when tipsShown is less than 10', () => {
it('should render Tips when tipsShown is less than 10', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -188,36 +197,38 @@ describe('<AppHeader />', () => {
persistentStateMock.setData({ tipsShown: 5 });
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips');
expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6);
unmount();
});
it('should NOT render Tips when tipsShown is 10 or more', () => {
it('should NOT render Tips when tipsShown is 10 or more', async () => {
const mockConfig = makeFakeConfig();
persistentStateMock.setData({ tipsShown: 10 });
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Tips');
unmount();
});
it('should show tips until they have been shown 10 times (persistence flow)', () => {
it('should show tips until they have been shown 10 times (persistence flow)', async () => {
persistentStateMock.setData({ tipsShown: 9 });
const mockConfig = makeFakeConfig();
@@ -235,6 +246,7 @@ describe('<AppHeader />', () => {
config: mockConfig,
uiState,
});
await session1.waitUntilReady();
expect(session1.lastFrame()).toContain('Tips');
expect(persistentStateMock.get('tipsShown')).toBe(10);
@@ -244,6 +256,7 @@ describe('<AppHeader />', () => {
const session2 = renderWithProviders(<AppHeader version="1.0.0" />, {
config: mockConfig,
});
await session2.waitUntilReady();
expect(session2.lastFrame()).not.toContain('Tips');
session2.unmount();
@@ -10,51 +10,57 @@ import { describe, it, expect } from 'vitest';
import { ApprovalMode } from '@google/gemini-cli-core';
describe('ApprovalModeIndicator', () => {
it('renders correctly for AUTO_EDIT mode', () => {
const { lastFrame } = render(
it('renders correctly for AUTO_EDIT mode', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for AUTO_EDIT mode with plan enabled', () => {
const { lastFrame } = render(
it('renders correctly for AUTO_EDIT mode with plan enabled', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.AUTO_EDIT}
allowPlanMode={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for PLAN mode', () => {
const { lastFrame } = render(
it('renders correctly for PLAN mode', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for YOLO mode', () => {
const { lastFrame } = render(
it('renders correctly for YOLO mode', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode', () => {
const { lastFrame } = render(
it('renders correctly for DEFAULT mode', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode with plan enabled', () => {
const { lastFrame } = render(
it('renders correctly for DEFAULT mode with plan enabled', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.DEFAULT}
allowPlanMode={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -46,8 +46,8 @@ describe('AskUserDialog', () => {
},
];
it('renders question and options', () => {
const { lastFrame } = renderWithProviders(
it('renders question and options', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -57,6 +57,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -125,7 +126,7 @@ describe('AskUserDialog', () => {
actions(stdin);
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith(expectedSubmit);
});
});
@@ -133,7 +134,7 @@ describe('AskUserDialog', () => {
it('handles custom option in single select with inline typing', async () => {
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={onSubmit}
@@ -147,7 +148,8 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\x1b[B');
writeKey(stdin, '\x1b[B');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Enter a custom value');
});
@@ -156,14 +158,15 @@ describe('AskUserDialog', () => {
writeKey(stdin, char);
}
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('API Key');
});
// Press Enter to submit the custom value
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' });
});
});
@@ -180,7 +183,7 @@ describe('AskUserDialog', () => {
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestionWithOther}
onSubmit={onSubmit}
@@ -207,15 +210,17 @@ describe('AskUserDialog', () => {
writeKey(stdin, char);
}
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Line 1');
await waitUntilReady();
expect(lastFrame()).toContain('Line 2');
});
// Press Enter to submit
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Line 1\nLine 2' });
});
});
@@ -240,7 +245,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -251,14 +256,19 @@ describe('AskUserDialog', () => {
{ useAlternateBuffer },
);
await waitFor(() => {
await waitFor(async () => {
if (expectedArrows) {
await waitUntilReady();
expect(lastFrame()).toContain('▲');
await waitUntilReady();
expect(lastFrame()).toContain('▼');
} else {
await waitUntilReady();
expect(lastFrame()).not.toContain('▲');
await waitUntilReady();
expect(lastFrame()).not.toContain('▼');
}
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -266,7 +276,7 @@ describe('AskUserDialog', () => {
);
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -279,10 +289,12 @@ describe('AskUserDialog', () => {
// Type a character without navigating down
writeKey(stdin, 'A');
await waitFor(() => {
await waitFor(async () => {
// Should show the custom input with 'A'
// Placeholder is hidden when text is present
await waitUntilReady();
expect(lastFrame()).toContain('A');
await waitUntilReady();
expect(lastFrame()).toContain('3. A');
});
@@ -290,12 +302,13 @@ describe('AskUserDialog', () => {
writeKey(stdin, 'P');
writeKey(stdin, 'I');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('API');
});
});
it('shows progress header for multiple questions', () => {
it('shows progress header for multiple questions', async () => {
const multiQuestions: Question[] = [
{
question: 'Which database should we use?',
@@ -319,7 +332,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -329,11 +342,12 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('hides progress header for single question', () => {
const { lastFrame } = renderWithProviders(
it('hides progress header for single question', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -343,11 +357,12 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('shows keyboard hints', () => {
const { lastFrame } = renderWithProviders(
it('shows keyboard hints', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -357,6 +372,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -380,7 +396,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -390,17 +406,20 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toContain('Which testing framework?');
writeKey(stdin, '\x1b[C'); // Right arrow
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which CI provider?');
});
writeKey(stdin, '\x1b[D'); // Left arrow
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which testing framework?');
});
});
@@ -424,7 +443,7 @@ describe('AskUserDialog', () => {
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={onSubmit}
@@ -437,40 +456,44 @@ describe('AskUserDialog', () => {
// Answer first question (should auto-advance)
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which bundler?');
});
// Navigate back
writeKey(stdin, '\x1b[D');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which package manager?');
});
// Navigate forward
writeKey(stdin, '\x1b[C');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which bundler?');
});
// Answer second question
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Review your answers:');
});
// Submit from Review
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' });
});
});
it('shows Review tab in progress header for multiple questions', () => {
it('shows Review tab in progress header for multiple questions', async () => {
const multiQuestions: Question[] = [
{
question: 'Which framework?',
@@ -494,7 +517,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -504,6 +527,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -525,7 +549,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -537,19 +561,22 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\x1b[C'); // Right arrow
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Add documentation?');
});
writeKey(stdin, '\x1b[C'); // Right arrow to Review
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
writeKey(stdin, '\x1b[D'); // Left arrow back
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Add documentation?');
});
});
@@ -572,7 +599,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -586,7 +613,8 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\x1b[C');
writeKey(stdin, '\x1b[C');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -627,13 +655,13 @@ describe('AskUserDialog', () => {
// Submit
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' });
});
});
describe('Text type questions', () => {
it('renders text input for type: "text"', () => {
it('renders text input for type: "text"', async () => {
const textQuestion: Question[] = [
{
question: 'What should we name this component?',
@@ -643,7 +671,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -653,10 +681,11 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('shows default placeholder when none provided', () => {
it('shows default placeholder when none provided', async () => {
const textQuestion: Question[] = [
{
question: 'Enter the database connection string:',
@@ -665,7 +694,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -675,6 +704,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -687,7 +717,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -701,19 +731,22 @@ describe('AskUserDialog', () => {
writeKey(stdin, char);
}
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('abc');
});
writeKey(stdin, '\x7f'); // Backspace
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('ab');
await waitUntilReady();
expect(lastFrame()).not.toContain('abc');
});
});
it('shows correct keyboard hints for text type', () => {
it('shows correct keyboard hints for text type', async () => {
const textQuestion: Question[] = [
{
question: 'Enter the variable name:',
@@ -722,7 +755,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -732,6 +765,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -754,7 +788,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={mixedQuestions}
onSubmit={vi.fn()}
@@ -770,13 +804,15 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\t'); // Use Tab instead of Right arrow when text input is active
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Should it be async?');
});
writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('useAuth');
});
});
@@ -802,7 +838,7 @@ describe('AskUserDialog', () => {
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={mixedQuestions}
onSubmit={onSubmit}
@@ -818,23 +854,29 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which styling approach?');
});
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Review your answers:');
await waitUntilReady();
expect(lastFrame()).toContain('Name');
await waitUntilReady();
expect(lastFrame()).toContain('DataTable');
await waitUntilReady();
expect(lastFrame()).toContain('Style');
await waitUntilReady();
expect(lastFrame()).toContain('CSS Modules');
});
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({
'0': 'DataTable',
'1': 'CSS Modules',
@@ -864,7 +906,7 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({});
});
});
@@ -879,7 +921,7 @@ describe('AskUserDialog', () => {
];
const onCancel = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -893,16 +935,19 @@ describe('AskUserDialog', () => {
writeKey(stdin, char);
}
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('SomeText');
});
// Send Ctrl+C
writeKey(stdin, '\x03'); // Ctrl+C
await waitFor(() => {
await waitFor(async () => {
// Text should be cleared
await waitUntilReady();
expect(lastFrame()).not.toContain('SomeText');
await waitUntilReady();
expect(lastFrame()).toContain('>');
});
@@ -926,7 +971,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -938,13 +983,15 @@ describe('AskUserDialog', () => {
// 1. Move to Text Q (Right arrow works for Choice Q)
writeKey(stdin, '\x1b[C');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Text Q?');
});
// 2. Type something in Text Q to make isEditingCustomOption true
writeKey(stdin, 'a');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('a');
});
@@ -952,18 +999,21 @@ describe('AskUserDialog', () => {
// When typing 'a', cursor is at index 1.
// We need to move cursor to index 0 first for Left arrow to work for navigation.
writeKey(stdin, '\x1b[D'); // Left arrow moves cursor to index 0
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Text Q?');
});
writeKey(stdin, '\x1b[D'); // Second Left arrow should now trigger navigation
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Choice Q?');
});
// 4. Immediately try Right arrow to go back to Text Q
writeKey(stdin, '\x1b[C');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Text Q?');
});
});
@@ -987,7 +1037,7 @@ describe('AskUserDialog', () => {
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={onSubmit}
@@ -1001,14 +1051,16 @@ describe('AskUserDialog', () => {
act(() => {
stdin.write('\r'); // Select A1 for Q1 -> triggers autoAdvance
});
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Question 2?');
});
act(() => {
stdin.write('\r'); // Select A2 for Q2 -> triggers autoAdvance to Review
});
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Review your answers:');
});
@@ -1016,7 +1068,7 @@ describe('AskUserDialog', () => {
stdin.write('\r'); // Submit from Review
});
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({
'0': 'A1',
'1': 'A2',
@@ -1037,7 +1089,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1048,7 +1100,8 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
// Plain text should be rendered as bold
expect(frame).toContain(chalk.bold('Which option do you prefer?'));
@@ -1066,7 +1119,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1077,7 +1130,8 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
// Should NOT have double-bold (the whole question bolded AND "this" bolded)
// "Is " should not be bold, only "this" should be bold
@@ -1098,7 +1152,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1109,7 +1163,8 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
// Check for chalk.bold('this') - asterisks should be gone, text should be bold
expect(frame).toContain(chalk.bold('this'));
@@ -1128,7 +1183,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1139,7 +1194,8 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
// Backticks should be removed
expect(frame).toContain('npm start');
@@ -1148,7 +1204,7 @@ describe('AskUserDialog', () => {
});
});
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => {
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', async () => {
const questions: Question[] = [
{
question: 'Choose an option',
@@ -1166,7 +1222,7 @@ describe('AskUserDialog', () => {
availableTerminalHeight: 5, // Small height to force scroll arrows
} as UIState;
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<AskUserDialog
questions={questions}
@@ -1179,11 +1235,13 @@ describe('AskUserDialog', () => {
);
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
await waitUntilReady();
expect(lastFrame()).toContain('▲');
await waitUntilReady();
expect(lastFrame()).toContain('▼');
});
it('does NOT truncate the question when in alternate buffer mode even with small height', () => {
it('does NOT truncate the question when in alternate buffer mode even with small height', async () => {
const longQuestion =
'This is a very long question ' + 'with many words '.repeat(10);
const questions: Question[] = [
@@ -1200,7 +1258,7 @@ describe('AskUserDialog', () => {
availableTerminalHeight: 5,
} as UIState;
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<AskUserDialog
questions={questions}
@@ -1213,8 +1271,10 @@ describe('AskUserDialog', () => {
);
// Should NOT contain the truncation message
await waitUntilReady();
expect(lastFrame()).not.toContain('hidden ...');
// Should contain the full long question (or at least its parts)
await waitUntilReady();
expect(lastFrame()).toContain('This is a very long question');
});
@@ -1234,7 +1294,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1248,7 +1308,8 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\x1b[B'); // Down
writeKey(stdin, '\x1b[B'); // Down to Other
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -1267,7 +1328,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1281,7 +1342,8 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\x1b[B'); // Down
writeKey(stdin, '\x1b[B'); // Down to Other
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -14,8 +14,6 @@ import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
import { ScrollProvider } from '../contexts/ScrollProvider.js';
import { Box } from 'ink';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Mock dependencies
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
@@ -142,81 +140,84 @@ describe('<BackgroundShellDisplay />', () => {
});
it('renders the output of the active shell', async () => {
const { lastFrame } = render(
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders tabs for multiple shells', async () => {
const { lastFrame } = render(
const width = 100;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
width={width}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('highlights the focused state', async () => {
const { lastFrame } = render(
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={true} // Focused
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('resizes the PTY on mount and when dimensions change', async () => {
const { rerender } = render(
const width = 80;
const { rerender, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
@@ -236,143 +237,152 @@ describe('<BackgroundShellDisplay />', () => {
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
96,
27,
);
unmount();
});
it('renders the process list when isListOpenProp is true', async () => {
const { lastFrame } = render(
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
render(
const width = 80;
const { waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
act(() => {
await act(async () => {
simulateKey({ name: 'down' });
});
await waitUntilReady();
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
act(() => {
await act(async () => {
simulateKey({ name: 'l', ctrl: true });
});
await waitUntilReady();
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
unmount();
});
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
render(
const width = 80;
const { waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
// Initial state: shell1 (active) is highlighted
// Move to shell2
act(() => {
await act(async () => {
simulateKey({ name: 'down' });
});
await waitUntilReady();
// Press Ctrl+K
act(() => {
await act(async () => {
simulateKey({ name: 'k', ctrl: true });
});
await waitUntilReady();
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
unmount();
});
it('kills the active process when Ctrl+K is pressed in output view', async () => {
render(
const width = 80;
const { waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
act(() => {
await act(async () => {
simulateKey({ name: 'k', ctrl: true });
});
await waitUntilReady();
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
unmount();
});
it('scrolls to active shell when list opens', async () => {
// shell2 is active
const { lastFrame } = render(
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell2.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('keeps exit code status color even when selected', async () => {
@@ -387,22 +397,23 @@ describe('<BackgroundShellDisplay />', () => {
};
mockShells.set(exitedShell.pid, exitedShell);
const { lastFrame } = render(
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={exitedShell.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -12,18 +12,22 @@ describe('Banner', () => {
it.each([
['warning mode', true, 'Warning Message'],
['info mode', false, 'Info Message'],
])('renders in %s', (_, isWarning, text) => {
const { lastFrame } = render(
])('renders in %s', async (_, isWarning, text) => {
const { lastFrame, waitUntilReady, unmount } = render(
<Banner bannerText={text} isWarning={isWarning} width={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('handles newlines in text', () => {
it('handles newlines in text', async () => {
const text = 'Line 1\\nLine 2';
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<Banner bannerText={text} isWarning={false} width={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -17,26 +17,28 @@ describe('<Checklist />', () => {
{ status: 'cancelled', label: 'Task 4' },
];
it('renders nothing when list is empty', () => {
const { lastFrame } = render(
it('renders nothing when list is empty', async () => {
const { lastFrame, waitUntilReady } = render(
<Checklist title="Test List" items={[]} isExpanded={true} />,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders nothing when collapsed and no active items', () => {
it('renders nothing when collapsed and no active items', async () => {
const inactiveItems: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'cancelled', label: 'Task 2' },
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders summary view correctly (collapsed)', () => {
const { lastFrame } = render(
it('renders summary view correctly (collapsed)', async () => {
const { lastFrame, waitUntilReady } = render(
<Checklist
title="Test List"
items={items}
@@ -44,11 +46,12 @@ describe('<Checklist />', () => {
toggleHint="toggle me"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders expanded view correctly', () => {
const { lastFrame } = render(
it('renders expanded view correctly', async () => {
const { lastFrame, waitUntilReady } = render(
<Checklist
title="Test List"
items={items}
@@ -56,17 +59,19 @@ describe('<Checklist />', () => {
toggleHint="toggle me"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders summary view without in-progress item if none exists', () => {
it('renders summary view without in-progress item if none exists', async () => {
const pendingItems: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'pending', label: 'Task 2' },
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -15,36 +15,39 @@ describe('<ChecklistItem />', () => {
{ status: 'in_progress', label: 'Doing this' },
{ status: 'completed', label: 'Done this' },
{ status: 'cancelled', label: 'Skipped this' },
] as ChecklistItemData[])('renders %s item correctly', (item) => {
const { lastFrame } = render(<ChecklistItem item={item} />);
] as ChecklistItemData[])('renders %s item correctly', async (item) => {
const { lastFrame, waitUntilReady } = render(<ChecklistItem item={item} />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('truncates long text when wrap="truncate"', () => {
it('truncates long text when wrap="truncate"', async () => {
const item: ChecklistItemData = {
status: 'in_progress',
label:
'This is a very long text that should be truncated because the wrap prop is set to truncate',
};
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<Box width={30}>
<ChecklistItem item={item} wrap="truncate" />
</Box>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('wraps long text by default', () => {
it('wraps long text by default', async () => {
const item: ChecklistItemData = {
status: 'in_progress',
label:
'This is a very long text that should wrap because the default behavior is wrapping',
};
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<Box width={30}>
<ChecklistItem item={item} />
</Box>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -15,17 +15,23 @@ describe('<CliSpinner />', () => {
debugState.debugNumAnimatedComponents = 0;
});
it('should increment debugNumAnimatedComponents on mount and decrement on unmount', () => {
it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => {
expect(debugState.debugNumAnimatedComponents).toBe(0);
const { unmount } = renderWithProviders(<CliSpinner />);
const { waitUntilReady, unmount } = renderWithProviders(<CliSpinner />);
await waitUntilReady();
expect(debugState.debugNumAnimatedComponents).toBe(1);
unmount();
expect(debugState.debugNumAnimatedComponents).toBe(0);
});
it('should not render when showSpinner is false', () => {
it('should not render when showSpinner is false', async () => {
const settings = createMockSettings({ ui: { showSpinner: false } });
const { lastFrame } = renderWithProviders(<CliSpinner />, { settings });
expect(lastFrame()).toBe('');
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<CliSpinner />,
{ settings },
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
+103 -100
View File
@@ -245,13 +245,13 @@ const createMockConfig = (overrides = {}): Config =>
...overrides,
}) as unknown as Config;
const renderComposer = (
const renderComposer = async (
uiState: UIState,
settings = createMockSettings(),
config = createMockConfig(),
uiActions = createMockUIActions(),
) =>
render(
) => {
const result = render(
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
@@ -262,6 +262,9 @@ const renderComposer = (
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
await result.waitUntilReady();
return result;
};
describe('Composer', () => {
beforeEach(() => {
@@ -274,20 +277,20 @@ describe('Composer', () => {
});
describe('Footer Display Settings', () => {
it('renders Footer by default when hideFooter is false', () => {
it('renders Footer by default when hideFooter is false', async () => {
const uiState = createMockUIState();
const settings = createMockSettings({ ui: { hideFooter: false } });
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).toContain('Footer');
});
it('does NOT render Footer when hideFooter is true', () => {
it('does NOT render Footer when hideFooter is true', async () => {
const uiState = createMockUIState();
const settings = createMockSettings({ ui: { hideFooter: true } });
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = await renderComposer(uiState, settings);
// Check for content that only appears IN the Footer component itself
expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
@@ -331,7 +334,7 @@ describe('Composer', () => {
setVimMode: vi.fn(),
} as unknown as ReturnType<typeof useVimMode>);
const { lastFrame } = renderComposer(uiState, settings, config);
const { lastFrame } = await renderComposer(uiState, settings, config);
expect(lastFrame()).toContain('Footer');
// Footer should be rendered with all the state passed through
@@ -339,7 +342,7 @@ describe('Composer', () => {
});
describe('Loading Indicator', () => {
it('renders LoadingIndicator with thought when streaming', () => {
it('renders LoadingIndicator with thought when streaming', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
@@ -350,13 +353,13 @@ describe('Composer', () => {
elapsedTime: 1500,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Processing');
});
it('renders generic thinking text in loading indicator when full inline thinking is enabled', () => {
it('renders generic thinking text in loading indicator when full inline thinking is enabled', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
@@ -368,27 +371,27 @@ describe('Composer', () => {
ui: { inlineThinkingMode: 'full' },
});
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Thinking ...');
});
it('hides shortcuts hint while loading', () => {
it('hides shortcuts hint while loading', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
elapsedTime: 1,
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('ShortcutsHint');
});
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
it('renders LoadingIndicator without thought when accessibility disables loading phrases', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: { subject: 'Hidden', description: 'Should not show' },
@@ -397,14 +400,14 @@ describe('Composer', () => {
getAccessibility: vi.fn(() => ({ enableLoadingPhrases: false })),
});
const { lastFrame } = renderComposer(uiState, undefined, config);
const { lastFrame } = await renderComposer(uiState, undefined, config);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show');
});
it('does not render LoadingIndicator when waiting for confirmation', () => {
it('does not render LoadingIndicator when waiting for confirmation', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.WaitingForConfirmation,
thought: {
@@ -413,13 +416,13 @@ describe('Composer', () => {
},
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
});
it('does not render LoadingIndicator when a tool confirmation is pending', () => {
it('does not render LoadingIndicator when a tool confirmation is pending', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
pendingHistoryItems: [
@@ -439,27 +442,27 @@ describe('Composer', () => {
],
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
expect(output).not.toContain('esc to cancel');
});
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
});
it('renders both LoadingIndicator and ApprovalModeIndicator when streaming in full UI mode', () => {
it('renders both LoadingIndicator and ApprovalModeIndicator when streaming in full UI mode', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
@@ -469,21 +472,21 @@ describe('Composer', () => {
showApprovalModeIndicator: ApprovalMode.PLAN,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Thinking');
expect(output).toContain('ApprovalModeIndicator');
});
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', () => {
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
@@ -491,7 +494,7 @@ describe('Composer', () => {
});
describe('Message Queue Display', () => {
it('displays queued messages when present', () => {
it('displays queued messages when present', async () => {
const uiState = createMockUIState({
messageQueue: [
'First queued message',
@@ -500,7 +503,7 @@ describe('Composer', () => {
],
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('First queued message');
@@ -508,12 +511,12 @@ describe('Composer', () => {
expect(output).toContain('Third queued message');
});
it('renders QueuedMessageDisplay with empty message queue', () => {
it('renders QueuedMessageDisplay with empty message queue', async () => {
const uiState = createMockUIState({
messageQueue: [],
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
// The component should render but return null for empty queue
// This test verifies that the component receives the correct prop
@@ -523,14 +526,14 @@ describe('Composer', () => {
});
describe('Context and Status Display', () => {
it('shows StatusDisplay and ApprovalModeIndicator in normal state', () => {
it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => {
const uiState = createMockUIState({
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('StatusDisplay');
@@ -538,12 +541,12 @@ describe('Composer', () => {
expect(output).not.toContain('ToastDisplay');
});
it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', () => {
it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
@@ -551,7 +554,7 @@ describe('Composer', () => {
expect(output).toContain('StatusDisplay');
});
it('shows ToastDisplay for other toast types', () => {
it('shows ToastDisplay for other toast types', async () => {
const uiState = createMockUIState({
transientMessage: {
text: 'Warning',
@@ -559,7 +562,7 @@ describe('Composer', () => {
},
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
@@ -568,12 +571,12 @@ describe('Composer', () => {
});
describe('Input and Indicators', () => {
it('hides non-essential UI details in clean mode', () => {
it('hides non-essential UI details in clean mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ShortcutsHint');
@@ -583,22 +586,22 @@ describe('Composer', () => {
expect(output).not.toContain('ContextSummaryDisplay');
});
it('renders InputPrompt when input is active', () => {
it('renders InputPrompt when input is active', async () => {
const uiState = createMockUIState({
isInputActive: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('InputPrompt');
});
it('does not render InputPrompt when input is inactive', () => {
it('does not render InputPrompt when input is inactive', async () => {
const uiState = createMockUIState({
isInputActive: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('InputPrompt');
});
@@ -610,44 +613,44 @@ describe('Composer', () => {
[ApprovalMode.YOLO],
])(
'shows ApprovalModeIndicator when approval mode is %s and shell mode is inactive',
(mode) => {
async (mode) => {
const uiState = createMockUIState({
showApprovalModeIndicator: mode,
shellModeActive: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/);
},
);
it('shows ShellModeIndicator when shell mode is active', () => {
it('shows ShellModeIndicator when shell mode is active', async () => {
const uiState = createMockUIState({
shellModeActive: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
});
it('shows RawMarkdownIndicator when renderMarkdown is false', () => {
it('shows RawMarkdownIndicator when renderMarkdown is false', async () => {
const uiState = createMockUIState({
renderMarkdown: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('raw markdown mode');
});
it('does not show RawMarkdownIndicator when renderMarkdown is true', () => {
it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => {
const uiState = createMockUIState({
renderMarkdown: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('raw markdown mode');
});
@@ -658,18 +661,18 @@ describe('Composer', () => {
[ApprovalMode.AUTO_EDIT, 'auto edit'],
])(
'shows minimal mode badge "%s" when clean UI details are hidden',
(mode, label) => {
async (mode, label) => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showApprovalModeIndicator: mode,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain(label);
},
);
it('hides minimal mode badge while loading in clean mode', () => {
it('hides minimal mode badge while loading in clean mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
@@ -677,14 +680,14 @@ describe('Composer', () => {
showApprovalModeIndicator: ApprovalMode.PLAN,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
});
it('hides minimal mode badge while action-required state is active', () => {
it('hides minimal mode badge while action-required state is active', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showApprovalModeIndicator: ApprovalMode.PLAN,
@@ -695,26 +698,26 @@ describe('Composer', () => {
),
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
});
it('shows Esc rewind prompt in minimal mode without showing full UI', () => {
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'msg' }],
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ContextSummaryDisplay');
});
it('shows context usage bleed-through when over 60%', () => {
it('shows context usage bleed-through when over 60%', async () => {
const model = 'gemini-2.5-pro';
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
@@ -734,13 +737,13 @@ describe('Composer', () => {
},
});
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).toContain('%');
});
});
describe('Error Details Display', () => {
it('shows DetailedMessagesDisplay when showErrorDetails is true', () => {
it('shows DetailedMessagesDisplay when showErrorDetails is true', async () => {
const uiState = createMockUIState({
showErrorDetails: true,
filteredConsoleMessages: [
@@ -752,18 +755,18 @@ describe('Composer', () => {
],
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('DetailedMessagesDisplay');
expect(lastFrame()).toContain('ShowMoreLines');
});
it('does not show error details when showErrorDetails is false', () => {
it('does not show error details when showErrorDetails is false', async () => {
const uiState = createMockUIState({
showErrorDetails: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('DetailedMessagesDisplay');
});
@@ -780,7 +783,7 @@ describe('Composer', () => {
setVimMode: vi.fn(),
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain(
"InputPrompt: Press 'Esc' for NORMAL mode.",
@@ -797,7 +800,7 @@ describe('Composer', () => {
setVimMode: vi.fn(),
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain(
"InputPrompt: Press 'i' for INSERT mode.",
@@ -806,7 +809,7 @@ describe('Composer', () => {
});
describe('Shortcuts Hint', () => {
it('hides shortcuts hint when showShortcutsHint setting is false', () => {
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
const uiState = createMockUIState();
const settings = createMockSettings({
ui: {
@@ -814,12 +817,12 @@ describe('Composer', () => {
},
});
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when a action is required (e.g. dialog is open)', () => {
it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => {
const uiState = createMockUIState({
customDialog: (
<Box>
@@ -829,55 +832,55 @@ describe('Composer', () => {
),
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('keeps shortcuts hint visible when no action is required', () => {
it('keeps shortcuts hint visible when no action is required', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
});
it('shows shortcuts hint when full UI details are visible', () => {
it('shows shortcuts hint when full UI details are visible', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
});
it('hides shortcuts hint while loading in minimal mode', () => {
it('hides shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
elapsedTime: 1,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('shows shortcuts help in minimal mode when toggled on', () => {
it('shows shortcuts help in minimal mode when toggled on', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
shortcutsHelpVisible: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHelp');
});
it('hides shortcuts hint when suggestions are visible above input in alternate buffer', () => {
it('hides shortcuts hint when suggestions are visible above input in alternate buffer', async () => {
composerTestControls.isAlternateBuffer = true;
composerTestControls.suggestionsVisible = true;
@@ -886,13 +889,13 @@ describe('Composer', () => {
showApprovalModeIndicator: ApprovalMode.PLAN,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('plan');
});
it('hides approval mode indicator when suggestions are visible above input in alternate buffer', () => {
it('hides approval mode indicator when suggestions are visible above input in alternate buffer', async () => {
composerTestControls.isAlternateBuffer = true;
composerTestControls.suggestionsVisible = true;
@@ -901,12 +904,12 @@ describe('Composer', () => {
showApprovalModeIndicator: ApprovalMode.YOLO,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ApprovalModeIndicator');
});
it('keeps shortcuts hint when suggestions are visible below input in regular buffer', () => {
it('keeps shortcuts hint when suggestions are visible below input in regular buffer', async () => {
composerTestControls.isAlternateBuffer = false;
composerTestControls.suggestionsVisible = true;
@@ -914,36 +917,36 @@ describe('Composer', () => {
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
});
});
describe('Shortcuts Help', () => {
it('shows shortcuts help in passive state', () => {
it('shows shortcuts help in passive state', async () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
streamingState: StreamingState.Idle,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHelp');
});
it('hides shortcuts help while streaming', () => {
it('hides shortcuts help while streaming', async () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
streamingState: StreamingState.Responding,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
});
it('hides shortcuts help when action is required', () => {
it('hides shortcuts help when action is required', async () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
customDialog: (
@@ -953,20 +956,20 @@ describe('Composer', () => {
),
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
});
});
describe('Snapshots', () => {
it('matches snapshot in idle state', () => {
it('matches snapshot in idle state', async () => {
const uiState = createMockUIState();
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatchSnapshot();
});
it('matches snapshot while streaming', () => {
it('matches snapshot while streaming', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
@@ -974,33 +977,33 @@ describe('Composer', () => {
description: 'Thinking about the meaning of life...',
},
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatchSnapshot();
});
it('matches snapshot in narrow view', () => {
it('matches snapshot in narrow view', async () => {
const uiState = createMockUIState({
terminalWidth: 40,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatchSnapshot();
});
it('matches snapshot in minimal UI mode', () => {
it('matches snapshot in minimal UI mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatchSnapshot();
});
it('matches snapshot in minimal UI mode while loading', () => {
it('matches snapshot in minimal UI mode while loading', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
elapsedTime: 1000,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -42,8 +42,9 @@ describe('ConfigInitDisplay', () => {
vi.restoreAllMocks();
});
it('renders initial state', () => {
const { lastFrame } = render(<ConfigInitDisplay />);
it('renders initial state', async () => {
const { lastFrame, waitUntilReady } = render(<ConfigInitDisplay />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -31,15 +31,16 @@ describe('ConsentPrompt', () => {
vi.clearAllMocks();
});
it('renders a string prompt with MarkdownDisplay', () => {
it('renders a string prompt with MarkdownDisplay', async () => {
const prompt = 'Are you sure?';
const { unmount } = render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
{
@@ -52,15 +53,16 @@ describe('ConsentPrompt', () => {
unmount();
});
it('renders a ReactNode prompt directly', () => {
it('renders a ReactNode prompt directly', async () => {
const prompt = <Text>Are you sure?</Text>;
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedMarkdownDisplay).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Are you sure?');
@@ -69,18 +71,20 @@ describe('ConsentPrompt', () => {
it('calls onConfirm with true when "Yes" is selected', async () => {
const prompt = 'Are you sure?';
const { unmount } = render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
onSelect(true);
});
await waitUntilReady();
expect(onConfirm).toHaveBeenCalledWith(true);
unmount();
@@ -88,32 +92,35 @@ describe('ConsentPrompt', () => {
it('calls onConfirm with false when "No" is selected', async () => {
const prompt = 'Are you sure?';
const { unmount } = render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
onSelect(false);
});
await waitUntilReady();
expect(onConfirm).toHaveBeenCalledWith(false);
unmount();
});
it('passes correct items to RadioButtonSelect', () => {
it('passes correct items to RadioButtonSelect', async () => {
const prompt = 'Are you sure?';
const { unmount } = render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedRadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
@@ -9,19 +9,27 @@ import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import { describe, it, expect } from 'vitest';
describe('ConsoleSummaryDisplay', () => {
it('renders nothing when errorCount is 0', () => {
const { lastFrame } = render(<ConsoleSummaryDisplay errorCount={0} />);
expect(lastFrame()).toBe('');
it('renders nothing when errorCount is 0', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ConsoleSummaryDisplay errorCount={0} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it.each([
[1, '1 error'],
[5, '5 errors'],
])('renders correct message for %i errors', (count, expectedText) => {
const { lastFrame } = render(<ConsoleSummaryDisplay errorCount={count} />);
])('renders correct message for %i errors', async (count, expectedText) => {
const { lastFrame, waitUntilReady, unmount } = render(
<ConsoleSummaryDisplay errorCount={count} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(expectedText);
expect(output).toContain('✖');
expect(output).toContain('(F12 for details)');
unmount();
});
});
@@ -21,12 +21,14 @@ afterEach(() => {
vi.useRealTimers();
});
const renderWithWidth = (
const renderWithWidth = async (
width: number,
props: React.ComponentProps<typeof ContextSummaryDisplay>,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<ContextSummaryDisplay {...props} />);
const result = render(<ContextSummaryDisplay {...props} />);
await result.waitUntilReady();
return result;
};
describe('<ContextSummaryDisplay />', () => {
@@ -42,7 +44,7 @@ describe('<ContextSummaryDisplay />', () => {
skillCount: 1,
};
it('should render on a single line on a wide screen', () => {
it('should render on a single line on a wide screen', async () => {
const props = {
...baseProps,
geminiMdFileCount: 1,
@@ -54,12 +56,12 @@ describe('<ContextSummaryDisplay />', () => {
},
},
};
const { lastFrame, unmount } = renderWithWidth(120, props);
const { lastFrame, unmount } = await renderWithWidth(120, props);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render on multiple lines on a narrow screen', () => {
it('should render on multiple lines on a narrow screen', async () => {
const props = {
...baseProps,
geminiMdFileCount: 1,
@@ -71,12 +73,12 @@ describe('<ContextSummaryDisplay />', () => {
},
},
};
const { lastFrame, unmount } = renderWithWidth(60, props);
const { lastFrame, unmount } = await renderWithWidth(60, props);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should switch layout at the 80-column breakpoint', () => {
it('should switch layout at the 80-column breakpoint', async () => {
const props = {
...baseProps,
geminiMdFileCount: 1,
@@ -90,23 +92,19 @@ describe('<ContextSummaryDisplay />', () => {
};
// At 80 columns, should be on one line
const { lastFrame: wideFrame, unmount: unmountWide } = renderWithWidth(
80,
props,
);
expect(wideFrame()!.includes('\n')).toBe(false);
const { lastFrame: wideFrame, unmount: unmountWide } =
await renderWithWidth(80, props);
expect(wideFrame().trim().includes('\n')).toBe(false);
unmountWide();
// At 79 columns, should be on multiple lines
const { lastFrame: narrowFrame, unmount: unmountNarrow } = renderWithWidth(
79,
props,
);
expect(narrowFrame()!.includes('\n')).toBe(true);
expect(narrowFrame()!.split('\n').length).toBe(4);
const { lastFrame: narrowFrame, unmount: unmountNarrow } =
await renderWithWidth(79, props);
expect(narrowFrame().trim().includes('\n')).toBe(true);
expect(narrowFrame().trim().split('\n').length).toBe(4);
unmountNarrow();
});
it('should not render empty parts', () => {
it('should not render empty parts', async () => {
const props = {
...baseProps,
geminiMdFileCount: 0,
@@ -119,7 +117,7 @@ describe('<ContextSummaryDisplay />', () => {
},
},
};
const { lastFrame, unmount } = renderWithWidth(60, props);
const { lastFrame, unmount } = await renderWithWidth(60, props);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -27,40 +27,46 @@ vi.mock('../../config/settings.js', () => ({
}));
describe('ContextUsageDisplay', () => {
it('renders correct percentage left', () => {
const { lastFrame } = render(
it('renders correct percentage left', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ContextUsageDisplay
promptTokenCount={5000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('50% context left');
unmount();
});
it('renders short label when terminal width is small', () => {
const { lastFrame } = render(
it('renders short label when terminal width is small', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ContextUsageDisplay
promptTokenCount={2000}
model="gemini-pro"
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80%');
expect(output).not.toContain('context left');
unmount();
});
it('renders 0% when full', () => {
const { lastFrame } = render(
it('renders 0% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ContextUsageDisplay
promptTokenCount={10000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% context left');
unmount();
});
});
@@ -18,20 +18,24 @@ describe('CopyModeWarning', () => {
vi.clearAllMocks();
});
it('renders nothing when copy mode is disabled', () => {
it('renders nothing when copy mode is disabled', async () => {
mockUseUIState.mockReturnValue({
copyModeEnabled: false,
} as unknown as UIState);
const { lastFrame } = render(<CopyModeWarning />);
expect(lastFrame()).toBe('');
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders warning when copy mode is enabled', () => {
it('renders warning when copy mode is enabled', async () => {
mockUseUIState.mockReturnValue({
copyModeEnabled: true,
} as unknown as UIState);
const { lastFrame } = render(<CopyModeWarning />);
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
await waitUntilReady();
expect(lastFrame()).toContain('In Copy Mode');
expect(lastFrame()).toContain('Press any key to exit');
unmount();
});
});
@@ -231,28 +231,24 @@ describe('DebugProfiler Component', () => {
showDebugProfiler: false,
constrainHeight: false,
} as unknown as UIState);
// Mock process.stdin and stdout
// We need to be careful not to break the test runner's own output
// So we might want to skip mocking them if they are not strictly needed for the simple render test
// or mock them safely.
// For now, let's assume the component uses them in useEffect.
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return null when showDebugProfiler is false', () => {
it('should return null when showDebugProfiler is false', async () => {
vi.mocked(useUIState).mockReturnValue({
showDebugProfiler: false,
constrainHeight: false,
} as unknown as UIState);
const { lastFrame } = render(<DebugProfiler />);
expect(lastFrame()).toBe('');
const { lastFrame, waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should render stats when showDebugProfiler is true', () => {
it('should render stats when showDebugProfiler is true', async () => {
vi.mocked(useUIState).mockReturnValue({
showDebugProfiler: true,
constrainHeight: false,
@@ -261,12 +257,14 @@ describe('DebugProfiler Component', () => {
profiler.totalIdleFrames = 5;
profiler.totalFlickerFrames = 2;
const { lastFrame } = render(<DebugProfiler />);
const { lastFrame, waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Renders: 10 (total)');
expect(output).toContain('5 (idle)');
expect(output).toContain('2 (flicker)');
unmount();
});
it('should report an action when a CoreEvent is emitted', async () => {
@@ -277,11 +275,13 @@ describe('DebugProfiler Component', () => {
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { unmount } = render(<DebugProfiler />);
const { waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
act(() => {
await act(async () => {
coreEvents.emitModelChanged('new-model');
});
await waitUntilReady();
expect(reportActionSpy).toHaveBeenCalled();
unmount();
@@ -295,11 +295,13 @@ describe('DebugProfiler Component', () => {
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { unmount } = render(<DebugProfiler />);
const { waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
act(() => {
await act(async () => {
appEvents.emit(AppEvent.SelectionWarning);
});
await waitUntilReady();
expect(reportActionSpy).toHaveBeenCalled();
unmount();
@@ -28,8 +28,8 @@ vi.mock('./shared/ScrollableList.js', () => ({
}));
describe('DetailedMessagesDisplay', () => {
it('renders nothing when messages are empty', () => {
const { lastFrame } = render(
it('renders nothing when messages are empty', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<DetailedMessagesDisplay
messages={[]}
maxHeight={10}
@@ -37,10 +37,12 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={false}
/>,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders messages correctly', () => {
it('renders messages correctly', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Log message', count: 1 },
{ type: 'warn', content: 'Warning message', count: 1 },
@@ -48,7 +50,7 @@ describe('DetailedMessagesDisplay', () => {
{ type: 'debug', content: 'Debug message', count: 1 },
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<DetailedMessagesDisplay
messages={messages}
maxHeight={20}
@@ -56,17 +58,19 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={true}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
unmount();
});
it('renders message counts', () => {
it('renders message counts', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Repeated message', count: 5 },
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<DetailedMessagesDisplay
messages={messages}
maxHeight={10}
@@ -74,8 +78,10 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={false}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
unmount();
});
});
@@ -101,12 +101,14 @@ describe('DialogManager', () => {
selectedAgentDefinition: undefined,
};
it('renders nothing by default', () => {
const { lastFrame } = renderWithProviders(
it('renders nothing by default', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DialogManager {...defaultProps} />,
{ uiState: baseUiState as Partial<UIState> as UIState },
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
const testCases: Array<[Partial<UIState>, string]> = [
@@ -190,8 +192,8 @@ describe('DialogManager', () => {
it.each(testCases)(
'renders %s when state is %o',
(uiStateOverride, expectedComponent) => {
const { lastFrame } = renderWithProviders(
async (uiStateOverride, expectedComponent) => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DialogManager {...defaultProps} />,
{
uiState: {
@@ -200,7 +202,9 @@ describe('DialogManager', () => {
} as Partial<UIState> as UIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain(expectedComponent);
unmount();
},
);
});
@@ -56,38 +56,41 @@ describe('EditorSettingsDialog', () => {
const renderWithProvider = (ui: React.ReactNode) =>
render(<KeypressProvider>{ui}</KeypressProvider>);
it('renders correctly', () => {
const { lastFrame } = renderWithProvider(
it('renders correctly', async () => {
const { lastFrame, waitUntilReady } = renderWithProvider(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={mockSettings}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('calls onSelect when an editor is selected', () => {
it('calls onSelect when an editor is selected', async () => {
const onSelect = vi.fn();
const { lastFrame } = renderWithProvider(
const { lastFrame, waitUntilReady } = renderWithProvider(
<EditorSettingsDialog
onSelect={onSelect}
settings={mockSettings}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('VS Code');
});
it('switches focus between editor and scope sections on Tab', async () => {
const { lastFrame, stdin } = renderWithProvider(
const { lastFrame, stdin, waitUntilReady } = renderWithProvider(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={mockSettings}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
// Initial focus on editor
expect(lastFrame()).toContain('> Select Editor');
@@ -97,6 +100,7 @@ describe('EditorSettingsDialog', () => {
await act(async () => {
stdin.write('\t');
});
await waitUntilReady();
// Focus should be on scope
await waitFor(() => {
@@ -115,6 +119,7 @@ describe('EditorSettingsDialog', () => {
await act(async () => {
stdin.write('\t');
});
await waitUntilReady();
// Focus should be back on editor
await waitFor(() => {
@@ -124,24 +129,26 @@ describe('EditorSettingsDialog', () => {
it('calls onExit when Escape is pressed', async () => {
const onExit = vi.fn();
const { stdin } = renderWithProvider(
const { stdin, waitUntilReady } = renderWithProvider(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={mockSettings}
onExit={onExit}
/>,
);
await waitUntilReady();
await act(async () => {
stdin.write('\u001B'); // Escape
});
await waitUntilReady();
await waitFor(() => {
expect(onExit).toHaveBeenCalled();
});
});
it('shows modified message when setting exists in other scope', () => {
it('shows modified message when setting exists in other scope', async () => {
const settingsWithOtherScope = {
forScope: (_scope: string) => ({
settings: {
@@ -157,13 +164,14 @@ describe('EditorSettingsDialog', () => {
},
} as unknown as LoadedSettings;
const { lastFrame } = renderWithProvider(
const { lastFrame, waitUntilReady } = renderWithProvider(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={settingsWithOtherScope}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
const frame = lastFrame() || '';
if (!frame.includes('(Also modified')) {
@@ -18,43 +18,51 @@ describe('ExitWarning', () => {
vi.clearAllMocks();
});
it('renders nothing by default', () => {
it('renders nothing by default', async () => {
mockUseUIState.mockReturnValue({
dialogsVisible: false,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
} as unknown as UIState);
const { lastFrame } = render(<ExitWarning />);
expect(lastFrame()).toBe('');
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders Ctrl+C warning when pressed once and dialogs visible', () => {
it('renders Ctrl+C warning when pressed once and dialogs visible', async () => {
mockUseUIState.mockReturnValue({
dialogsVisible: true,
ctrlCPressedOnce: true,
ctrlDPressedOnce: false,
} as unknown as UIState);
const { lastFrame } = render(<ExitWarning />);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
unmount();
});
it('renders Ctrl+D warning when pressed once and dialogs visible', () => {
it('renders Ctrl+D warning when pressed once and dialogs visible', async () => {
mockUseUIState.mockReturnValue({
dialogsVisible: true,
ctrlCPressedOnce: false,
ctrlDPressedOnce: true,
} as unknown as UIState);
const { lastFrame } = render(<ExitWarning />);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
unmount();
});
it('renders nothing if dialogs are not visible', () => {
it('renders nothing if dialogs are not visible', async () => {
mockUseUIState.mockReturnValue({
dialogsVisible: false,
ctrlCPressedOnce: true,
ctrlDPressedOnce: true,
} as unknown as UIState);
const { lastFrame } = render(<ExitWarning />);
expect(lastFrame()).toBe('');
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
@@ -36,10 +36,11 @@ describe('FolderTrustDialog', () => {
mockedCwd.mockReturnValue('/home/user/project');
});
it('should render the dialog with title and description', () => {
const { lastFrame, unmount } = renderWithProviders(
it('should render the dialog with title and description', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Do you trust this folder?');
expect(lastFrame()).toContain(
@@ -50,13 +51,18 @@ describe('FolderTrustDialog', () => {
it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => {
const onSelect = vi.fn();
const { lastFrame, stdin, unmount } = renderWithProviders(
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
);
await waitUntilReady();
act(() => {
await act(async () => {
stdin.write('\u001b[27u'); // Press kitty escape key
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(lastFrame()).toContain(
@@ -72,10 +78,11 @@ describe('FolderTrustDialog', () => {
unmount();
});
it('should display restart message when isRestarting is true', () => {
const { lastFrame, unmount } = renderWithProviders(
it('should display restart message when isRestarting is true', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Gemini CLI is restarting');
unmount();
@@ -84,9 +91,10 @@ describe('FolderTrustDialog', () => {
it('should call relaunchApp when isRestarting is true', async () => {
vi.useFakeTimers();
const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
const { unmount } = renderWithProviders(
const { waitUntilReady, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await waitUntilReady();
await vi.advanceTimersByTimeAsync(250);
expect(relaunchApp).toHaveBeenCalled();
unmount();
@@ -96,9 +104,10 @@ describe('FolderTrustDialog', () => {
it('should not call relaunchApp if unmounted before timeout', async () => {
vi.useFakeTimers();
const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
const { unmount } = renderWithProviders(
const { waitUntilReady, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await waitUntilReady();
// Unmount immediately (before 250ms)
unmount();
@@ -109,13 +118,15 @@ describe('FolderTrustDialog', () => {
});
it('should not call process.exit when "r" is pressed and isRestarting is false', async () => {
const { stdin, unmount } = renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
);
await waitUntilReady();
act(() => {
await act(async () => {
stdin.write('r');
});
await waitUntilReady();
await waitFor(() => {
expect(mockedExit).not.toHaveBeenCalled();
@@ -124,29 +135,32 @@ describe('FolderTrustDialog', () => {
});
describe('directory display', () => {
it('should correctly display the folder name for a nested directory', () => {
it('should correctly display the folder name for a nested directory', async () => {
mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Trust folder (project)');
unmount();
});
it('should correctly display the parent folder name for a nested directory', () => {
it('should correctly display the parent folder name for a nested directory', async () => {
mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Trust parent folder (user)');
unmount();
});
it('should correctly display an empty parent folder name for a directory directly under root', () => {
it('should correctly display an empty parent folder name for a directory directly under root', async () => {
mockedCwd.mockReturnValue('/project');
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Trust parent folder ()');
unmount();
});
+346 -223
View File
@@ -60,206 +60,284 @@ const mockSessionStats: SessionStatsState = {
};
describe('<Footer />', () => {
it('renders the component', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
it('renders the component', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
},
});
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
describe('path display', () => {
it('should display a shortened path on a narrow terminal', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 79,
uiState: { sessionStats: mockSessionStats },
});
it('should display a shortened path on a narrow terminal', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 79,
uiState: { sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const tildePath = tildeifyPath(defaultProps.targetDir);
const pathLength = Math.max(20, Math.floor(79 * 0.25));
const expectedPath =
'...' + tildePath.slice(tildePath.length - pathLength + 3);
expect(lastFrame()).toContain(expectedPath);
unmount();
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 80,
uiState: { sessionStats: mockSessionStats },
});
it('should use wide layout at 80 columns', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 80,
uiState: { sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath =
'...' + tildePath.slice(tildePath.length - 80 * 0.25 + 3);
expect(lastFrame()).toContain(expectedPath);
unmount();
});
});
it('displays the branch name when provided', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
});
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
});
it('does not display the branch name when not provided', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { branchName: undefined, sessionStats: mockSessionStats },
});
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
});
it('displays the model name and context percentage', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
it('displays the branch name when provided', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
}),
});
},
);
await waitUntilReady();
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
unmount();
});
it('does not display the branch name when not provided', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { branchName: undefined, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
unmount();
});
it('displays the model name and context percentage', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context left/);
unmount();
});
it('displays the usage indicator when usage is low', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
it('displays the usage indicator when usage is low', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
},
proQuotaRequest: null,
validationRequest: null,
},
},
});
);
await waitUntilReady();
expect(lastFrame()).toContain('15%');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides the usage indicator when usage is not near limit', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
it('hides the usage indicator when usage is not near limit', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
},
proQuotaRequest: null,
validationRequest: null,
},
},
});
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Usage remaining');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('displays "Limit reached" message when remaining is 0', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
it('displays "Limit reached" message when remaining is 0', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
},
proQuotaRequest: null,
validationRequest: null,
},
},
});
);
await waitUntilReady();
expect(lastFrame()).toContain('Limit reached');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('displays the model name and abbreviated context percentage', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 99,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
it('displays the model name and abbreviated context percentage', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 99,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+%/);
unmount();
});
describe('sandbox and trust info', () => {
it('should display untrusted when isTrustedFolder is false', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
});
it('should display untrusted when isTrustedFolder is false', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).toContain('untrusted');
unmount();
});
it('should display custom sandbox info when SANDBOX env is set', () => {
it('should display custom sandbox info when SANDBOX env is set', async () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: undefined, sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
isTrustedFolder: undefined,
sessionStats: mockSessionStats,
},
},
);
await waitUntilReady();
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
unmount();
});
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
unmount();
});
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', async () => {
// Clear any SANDBOX env var that might be set.
vi.stubEnv('SANDBOX', '');
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).toContain('no sandbox');
vi.unstubAllEnvs();
unmount();
});
it('should prioritize untrusted message over sandbox info', () => {
it('should prioritize untrusted message over sandbox info', async () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).toContain('untrusted');
expect(lastFrame()).not.toMatch(/test-sandbox/s);
vi.unstubAllEnvs();
unmount();
});
});
@@ -273,143 +351,188 @@ describe('<Footer />', () => {
vi.unstubAllEnvs();
});
it('renders complete footer with all sections visible (baseline)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
it('renders complete footer with all sections visible (baseline)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
unmount();
});
it('renders footer with all optional sections hidden (minimal footer)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
it('renders footer with all optional sections hidden (minimal footer)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
},
},
},
}),
});
expect(lastFrame()).toMatchSnapshot('footer-minimal');
}),
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toMatchSnapshot('footer-minimal');
unmount();
});
it('renders footer with only model info hidden (partial filtering)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
it('renders footer with only model info hidden (partial filtering)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('footer-no-model');
unmount();
});
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
unmount();
});
it('hides the context percentage when hideContextPercentage is true', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: true,
it('hides the context percentage when hideContextPercentage is true', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: true,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% context left/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
it('shows the context percentage when hideContextPercentage is false', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context left/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 79,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 79,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
unmount();
});
});
});
describe('fallback mode display', () => {
it('should display Flash model when in fallback mode, not the configured Pro model', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
},
},
});
);
await waitUntilReady();
// Footer should show the effective model (Flash), not the config model (Pro)
expect(lastFrame()).toContain('gemini-2.5-flash');
expect(lastFrame()).not.toContain('gemini-2.5-pro');
unmount();
});
it('should display Pro model when NOT in fallback mode', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
it('should display Pro model when NOT in fallback mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
},
},
});
);
await waitUntilReady();
expect(lastFrame()).toContain('gemini-2.5-pro');
unmount();
});
});
@@ -16,8 +16,8 @@ import {
} from '../textConstants.js';
vi.mock('../contexts/StreamingContext.js');
vi.mock('ink', async () => {
const actual = await vi.importActual('ink');
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
useIsScreenReaderEnabled: vi.fn(),
@@ -42,40 +42,56 @@ describe('GeminiRespondingSpinner', () => {
vi.useRealTimers();
});
it('renders spinner when responding', () => {
it('renders spinner when responding', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
const { lastFrame } = render(<GeminiRespondingSpinner />);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
// Spinner output varies, but it shouldn't be empty
expect(lastFrame()).not.toBe('');
unmount();
});
it('renders screen reader text when responding and screen reader enabled', () => {
it('renders screen reader text when responding and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame } = render(<GeminiRespondingSpinner />);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
unmount();
});
it('renders nothing when not responding and no non-responding display', () => {
it('renders nothing when not responding and no non-responding display', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame } = render(<GeminiRespondingSpinner />);
expect(lastFrame()).toBe('');
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders non-responding display when provided', () => {
it('renders non-responding display when provided', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Waiting...');
unmount();
});
it('renders screen reader loading text when non-responding display provided and screen reader enabled', () => {
it('renders screen reader loading text when non-responding display provided and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
await waitUntilReady();
expect(lastFrame()).toContain(SCREEN_READER_LOADING);
unmount();
});
});
@@ -66,52 +66,63 @@ useSessionStatsMock.mockReturnValue({
});
describe('Gradient Crash Regression Tests', () => {
it('<Header /> should not crash when theme.ui.gradient is empty', () => {
const { lastFrame } = renderWithProviders(
it('<Header /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Header version="1.0.0" nightly={false} />,
{
width: 120,
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
it('<ModelDialog /> should not crash when theme.ui.gradient is empty', () => {
const { lastFrame } = renderWithProviders(
<ModelDialog onClose={() => {}} />,
it('<ModelDialog /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ModelDialog onClose={async () => {}} />,
{
width: 120,
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
it('<Banner /> should not crash when theme.ui.gradient is empty', () => {
const { lastFrame } = renderWithProviders(
it('<Banner /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Banner bannerText="Test Banner" isWarning={false} width={80} />,
{
width: 120,
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
it('<Footer /> should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
nightly: true, // Enable nightly to trigger Gradient usage logic
sessionStats: mockSessionStats,
it('<Footer /> should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
nightly: true, // Enable nightly to trigger Gradient usage logic
sessionStats: mockSessionStats,
},
},
});
);
await waitUntilReady();
// If it crashes, this line won't be reached or lastFrame() will throw
expect(lastFrame()).toBeDefined();
// It should fall back to rendering text without gradient
expect(lastFrame()).not.toContain('Gradient');
unmount();
});
it('<StatsDisplay /> should not crash when theme.ui.gradient is empty', () => {
const { lastFrame } = renderWithProviders(
it('<StatsDisplay /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<StatsDisplay duration="1s" title="My Stats" />,
{
width: 120,
@@ -120,8 +131,10 @@ describe('Gradient Crash Regression Tests', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
// Ensure title is rendered
expect(lastFrame()).toContain('My Stats');
unmount();
});
});
+15 -6
View File
@@ -43,8 +43,11 @@ const mockCommands: readonly SlashCommand[] = [
];
describe('Help Component', () => {
it('should not render hidden commands', () => {
const { lastFrame, unmount } = render(<Help commands={mockCommands} />);
it('should not render hidden commands', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<Help commands={mockCommands} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('/test');
@@ -52,8 +55,11 @@ describe('Help Component', () => {
unmount();
});
it('should not render hidden subcommands', () => {
const { lastFrame, unmount } = render(<Help commands={mockCommands} />);
it('should not render hidden subcommands', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<Help commands={mockCommands} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('visible-child');
@@ -61,8 +67,11 @@ describe('Help Component', () => {
unmount();
});
it('should render keyboard shortcuts', () => {
const { lastFrame, unmount } = render(<Help commands={mockCommands} />);
it('should render keyboard shortcuts', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<Help commands={mockCommands} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Keyboard Shortcuts:');
@@ -33,59 +33,67 @@ describe('<HistoryItemDisplay />', () => {
config: mockConfig,
};
it('renders UserMessage for "user" type', () => {
it('renders UserMessage for "user" type', async () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.USER,
text: 'Hello',
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Hello');
unmount();
});
it('renders HintMessage for "hint" type', () => {
it('renders HintMessage for "hint" type', async () => {
const item: HistoryItem = {
...baseItem,
type: 'hint',
text: 'Try using ripgrep first',
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Try using ripgrep first');
unmount();
});
it('renders UserMessage for "user" type with slash command', () => {
it('renders UserMessage for "user" type with slash command', async () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.USER,
text: '/theme',
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('/theme');
unmount();
});
it.each([true, false])(
'renders InfoMessage for "info" type with multi-line text (alternateBuffer=%s)',
(useAlternateBuffer) => {
async (useAlternateBuffer) => {
const item: HistoryItem = {
...baseItem,
type: MessageType.INFO,
text: '⚡ Line 1\n⚡ Line 2\n⚡ Line 3',
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{ useAlternateBuffer },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
},
);
it('renders AgentsStatus for "agents_list" type', () => {
it('renders AgentsStatus for "agents_list" type', async () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.AGENTS_LIST,
@@ -103,27 +111,31 @@ describe('<HistoryItemDisplay />', () => {
},
],
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders StatsDisplay for "stats" type', () => {
it('renders StatsDisplay for "stats" type', async () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.STATS,
duration: '1s',
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Stats');
unmount();
});
it('renders AboutBox for "about" type', () => {
it('renders AboutBox for "about" type', async () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.ABOUT,
@@ -135,78 +147,88 @@ describe('<HistoryItemDisplay />', () => {
gcpProject: 'test-project',
ideClient: 'test-ide',
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('About Gemini CLI');
unmount();
});
it('renders ModelStatsDisplay for "model_stats" type', () => {
it('renders ModelStatsDisplay for "model_stats" type', async () => {
const item: HistoryItem = {
...baseItem,
type: 'model_stats',
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'No API calls have been made in this session.',
);
unmount();
});
it('renders ToolStatsDisplay for "tool_stats" type', () => {
it('renders ToolStatsDisplay for "tool_stats" type', async () => {
const item: HistoryItem = {
...baseItem,
type: 'tool_stats',
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'No tool calls have been made in this session.',
);
unmount();
});
it('renders SessionSummaryDisplay for "quit" type', () => {
it('renders SessionSummaryDisplay for "quit" type', async () => {
const item: HistoryItem = {
...baseItem,
type: 'quit',
duration: '1s',
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Agent powering down. Goodbye!');
unmount();
});
it('should escape ANSI codes in text content', () => {
it('should escape ANSI codes in text content', async () => {
const historyItem: HistoryItem = {
id: 1,
type: 'user',
text: 'Hello, \u001b[31mred\u001b[0m world!',
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay
item={historyItem}
terminalWidth={80}
isPending={false}
/>,
);
await waitUntilReady();
// The ANSI codes should be escaped for display.
expect(lastFrame()).toContain('Hello, \\u001b[31mred\\u001b[0m world!');
// The raw ANSI codes should not be present.
expect(lastFrame()).not.toContain('Hello, \u001b[31mred\u001b[0m world!');
unmount();
});
it('should escape ANSI codes in tool confirmation details', () => {
it('should escape ANSI codes in tool confirmation details', async () => {
const historyItem: HistoryItem = {
id: 1,
type: 'tool_group',
@@ -228,13 +250,14 @@ describe('<HistoryItemDisplay />', () => {
],
};
renderWithProviders(
const { waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay
item={historyItem}
terminalWidth={80}
isPending={false}
/>,
);
await waitUntilReady();
const passedProps = vi.mocked(ToolGroupMessage).mock.calls[0][0];
const confirmationDetails = passedProps.toolCalls[0]
@@ -243,16 +266,17 @@ describe('<HistoryItemDisplay />', () => {
expect(confirmationDetails.command).toBe(
'echo "\\u001b[31mhello\\u001b[0m"',
);
unmount();
});
describe('thinking items', () => {
it('renders thinking item when enabled', () => {
it('renders thinking item when enabled', async () => {
const item: HistoryItem = {
...baseItem,
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
settings: createMockSettings({
@@ -260,17 +284,19 @@ describe('<HistoryItemDisplay />', () => {
}),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('does not render thinking item when disabled', () => {
it('does not render thinking item when disabled', async () => {
const item: HistoryItem = {
...baseItem,
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
settings: createMockSettings({
@@ -278,8 +304,10 @@ describe('<HistoryItemDisplay />', () => {
}),
},
);
await waitUntilReady();
expect(lastFrame()).toBe('');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
@@ -292,13 +320,13 @@ describe('<HistoryItemDisplay />', () => {
Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join('\n') +
'\n```';
it('should render a truncated gemini item', () => {
it('should render a truncated gemini item', async () => {
const item: HistoryItem = {
id: 1,
type: 'gemini',
text: longCode,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
@@ -307,17 +335,19 @@ describe('<HistoryItemDisplay />', () => {
/>,
{ useAlternateBuffer },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render a full gemini item when using availableTerminalHeightGemini', () => {
it('should render a full gemini item when using availableTerminalHeightGemini', async () => {
const item: HistoryItem = {
id: 1,
type: 'gemini',
text: longCode,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
@@ -327,17 +357,19 @@ describe('<HistoryItemDisplay />', () => {
/>,
{ useAlternateBuffer },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render a truncated gemini_content item', () => {
it('should render a truncated gemini_content item', async () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_content',
text: longCode,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
@@ -346,17 +378,19 @@ describe('<HistoryItemDisplay />', () => {
/>,
{ useAlternateBuffer },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render a full gemini_content item when using availableTerminalHeightGemini', () => {
it('should render a full gemini_content item when using availableTerminalHeightGemini', async () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_content',
text: longCode,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
@@ -366,8 +400,10 @@ describe('<HistoryItemDisplay />', () => {
/>,
{ useAlternateBuffer },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
},
);
@@ -14,42 +14,54 @@ afterEach(() => {
});
describe('<HookStatusDisplay />', () => {
it('should render a single executing hook', () => {
it('should render a single executing hook', async () => {
const props = {
activeHooks: [{ name: 'test-hook', eventName: 'BeforeAgent' }],
};
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
const { lastFrame, waitUntilReady, unmount } = render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render multiple executing hooks', () => {
it('should render multiple executing hooks', async () => {
const props = {
activeHooks: [
{ name: 'h1', eventName: 'BeforeAgent' },
{ name: 'h2', eventName: 'BeforeAgent' },
],
};
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
const { lastFrame, waitUntilReady, unmount } = render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render sequential hook progress', () => {
it('should render sequential hook progress', async () => {
const props = {
activeHooks: [
{ name: 'step', eventName: 'BeforeAgent', index: 1, total: 3 },
],
};
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
const { lastFrame, waitUntilReady, unmount } = render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should return empty string if no active hooks', () => {
it('should return empty string if no active hooks', async () => {
const props = { activeHooks: [] };
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
expect(lastFrame()).toBe('');
const { lastFrame, waitUntilReady, unmount } = render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
@@ -5,6 +5,7 @@
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { act } from 'react';
import * as processUtils from '../../utils/processUtils.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
@@ -15,78 +16,96 @@ describe('IdeTrustChangeDialog', () => {
vi.clearAllMocks();
});
it('renders the correct message for CONNECTION_CHANGE', () => {
const { lastFrame } = renderWithProviders(
it('renders the correct message for CONNECTION_CHANGE', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
);
await waitUntilReady();
const frameText = lastFrame();
expect(frameText).toContain(
'Workspace trust has changed due to a change in the IDE connection.',
);
expect(frameText).toContain("Press 'r' to restart Gemini");
unmount();
});
it('renders the correct message for TRUST_CHANGE', () => {
const { lastFrame } = renderWithProviders(
it('renders the correct message for TRUST_CHANGE', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<IdeTrustChangeDialog reason="TRUST_CHANGE" />,
);
await waitUntilReady();
const frameText = lastFrame();
expect(frameText).toContain(
'Workspace trust has changed due to a change in the IDE trust.',
);
expect(frameText).toContain("Press 'r' to restart Gemini");
unmount();
});
it('renders a generic message and logs an error for NONE reason', () => {
it('renders a generic message and logs an error for NONE reason', async () => {
const debugLoggerWarnSpy = vi
.spyOn(debugLogger, 'warn')
.mockImplementation(() => {});
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<IdeTrustChangeDialog reason="NONE" />,
);
await waitUntilReady();
const frameText = lastFrame();
expect(frameText).toContain('Workspace trust has changed.');
expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
'IdeTrustChangeDialog rendered with unexpected reason "NONE"',
);
unmount();
});
it('calls relaunchApp when "r" is pressed', () => {
it('calls relaunchApp when "r" is pressed', async () => {
const relaunchAppSpy = vi.spyOn(processUtils, 'relaunchApp');
const { stdin } = renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeTrustChangeDialog reason="NONE" />,
);
await waitUntilReady();
stdin.write('r');
await act(async () => {
stdin.write('r');
});
await waitUntilReady();
expect(relaunchAppSpy).toHaveBeenCalledTimes(1);
unmount();
});
it('calls relaunchApp when "R" is pressed', () => {
it('calls relaunchApp when "R" is pressed', async () => {
const relaunchAppSpy = vi.spyOn(processUtils, 'relaunchApp');
const { stdin } = renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
);
await waitUntilReady();
stdin.write('R');
await act(async () => {
stdin.write('R');
});
await waitUntilReady();
expect(relaunchAppSpy).toHaveBeenCalledTimes(1);
unmount();
});
it('does not call relaunchApp when another key is pressed', async () => {
const relaunchAppSpy = vi.spyOn(processUtils, 'relaunchApp');
const { stdin } = renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
);
await waitUntilReady();
stdin.write('a');
// Give it a moment to ensure no async actions are triggered
await new Promise((resolve) => setTimeout(resolve, 50));
await act(async () => {
stdin.write('a');
});
await waitUntilReady();
expect(relaunchAppSpy).not.toHaveBeenCalled();
unmount();
});
});
@@ -2064,7 +2064,7 @@ describe('InputPrompt', () => {
);
await waitFor(() => {
const frame = stdout.lastFrame();
const lines = frame!.split('\n');
const lines = frame.split('\n');
// The line with the cursor should just be an inverted space inside the box border
expect(
lines.find((l) => l.includes(chalk.inverse(' '))),
@@ -2101,7 +2101,7 @@ describe('InputPrompt', () => {
expect(frame).toContain('hello');
expect(frame).toContain(`world${chalk.inverse(' ')}`);
const outputLines = frame!.split('\n');
const outputLines = frame.trim().split('\n');
// The number of lines should be 2 for the border plus 3 for the content.
expect(outputLines.length).toBe(5);
});
@@ -3349,7 +3349,7 @@ describe('InputPrompt', () => {
return <InputPrompt {...baseProps} buffer={buffer as TextBuffer} />;
};
const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
const { stdout, unmount, simulateClick } = renderWithProviders(
<TestWrapper />,
{
mouseEventsEnabled: true,
@@ -3364,8 +3364,8 @@ describe('InputPrompt', () => {
});
// Simulate double-click to expand
await simulateClick(stdin, 5, 2);
await simulateClick(stdin, 5, 2);
await simulateClick(5, 2);
await simulateClick(5, 2);
// 2. Verify expanded content is visible
await waitFor(() => {
@@ -3373,8 +3373,8 @@ describe('InputPrompt', () => {
});
// Simulate double-click to collapse
await simulateClick(stdin, 5, 2);
await simulateClick(stdin, 5, 2);
await simulateClick(5, 2);
await simulateClick(5, 2);
// 3. Verify placeholder is restored
await waitFor(() => {
@@ -3440,7 +3440,7 @@ describe('InputPrompt', () => {
return <InputPrompt {...baseProps} buffer={buffer as TextBuffer} />;
};
const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
const { stdout, unmount, simulateClick } = renderWithProviders(
<TestWrapper />,
{
mouseEventsEnabled: true,
@@ -3455,8 +3455,8 @@ describe('InputPrompt', () => {
});
// Simulate double-click WAY to the right on the first line
await simulateClick(stdin, 100, 2);
await simulateClick(stdin, 100, 2);
await simulateClick(90, 2);
await simulateClick(90, 2);
// Verify it is NOW collapsed
await waitFor(() => {
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from '../../test-utils/render.js';
import React, { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { Text } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js';
@@ -42,13 +42,10 @@ const renderWithContext = (
width = 120,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
const contextValue: StreamingState = streamingStateValue;
return render(
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>,
return renderWithProviders(ui, {
uiState: { streamingState: streamingStateValue },
width,
);
});
};
describe('<LoadingIndicator />', () => {
@@ -57,34 +54,37 @@ describe('<LoadingIndicator />', () => {
elapsedTime: 5,
};
it('should render blank when streamingState is Idle and no loading phrase or thought', () => {
const { lastFrame } = renderWithContext(
it('should render blank when streamingState is Idle and no loading phrase or thought', async () => {
const { lastFrame, waitUntilReady } = renderWithContext(
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
expect(lastFrame()?.trim()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
});
it('should render spinner, phrase, and time when streamingState is Responding', () => {
const { lastFrame } = renderWithContext(
it('should render spinner, phrase, and time when streamingState is Responding', async () => {
const { lastFrame, waitUntilReady } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => {
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', async () => {
const props = {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
};
const { lastFrame } = renderWithContext(
const { lastFrame, waitUntilReady } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
@@ -92,85 +92,121 @@ describe('<LoadingIndicator />', () => {
expect(output).not.toContain(', 10s');
});
it('should display the currentLoadingPhrase correctly', () => {
it('should display the currentLoadingPhrase correctly', async () => {
const props = {
currentLoadingPhrase: 'Processing data...',
elapsedTime: 3,
};
const { lastFrame, unmount } = renderWithContext(
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Processing data...');
unmount();
});
it('should display the elapsedTime correctly when Responding', () => {
it('should display the elapsedTime correctly when Responding', async () => {
const props = {
currentLoadingPhrase: 'Working...',
elapsedTime: 60,
};
const { lastFrame, unmount } = renderWithContext(
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 1m)');
unmount();
});
it('should display the elapsedTime correctly in human-readable format', () => {
it('should display the elapsedTime correctly in human-readable format', async () => {
const props = {
currentLoadingPhrase: 'Working...',
elapsedTime: 125,
};
const { lastFrame, unmount } = renderWithContext(
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
unmount();
});
it('should render rightContent when provided', () => {
it('should render rightContent when provided', async () => {
const rightContent = <Text>Extra Info</Text>;
const { lastFrame, unmount } = renderWithContext(
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Extra Info');
unmount();
});
it('should transition correctly between states using rerender', () => {
const { lastFrame, rerender, unmount } = renderWithContext(
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
it('should transition correctly between states', async () => {
let setStateExternally:
| React.Dispatch<
React.SetStateAction<{
state: StreamingState;
phrase?: string;
elapsedTime: number;
}>
>
| undefined;
const TestWrapper = () => {
const [config, setConfig] = React.useState<{
state: StreamingState;
phrase?: string;
elapsedTime: number;
}>({
state: StreamingState.Idle,
phrase: undefined,
elapsedTime: 5,
});
setStateExternally = setConfig;
return (
<StreamingContext.Provider value={config.state}>
<LoadingIndicator
currentLoadingPhrase={config.phrase}
elapsedTime={config.elapsedTime}
/>
</StreamingContext.Provider>
);
};
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<TestWrapper />,
);
expect(lastFrame()?.trim()).toBe(''); // Initial: Idle (no loading phrase)
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding
rerender(
<StreamingContext.Provider value={StreamingState.Responding}>
<LoadingIndicator
currentLoadingPhrase="Now Responding"
elapsedTime={2}
/>
</StreamingContext.Provider>,
);
await act(async () => {
setStateExternally?.({
state: StreamingState.Responding,
phrase: 'Now Responding',
elapsedTime: 2,
});
});
await waitUntilReady();
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
expect(output).toContain('(esc to cancel, 2s)');
// Transition to WaitingForConfirmation
rerender(
<StreamingContext.Provider value={StreamingState.WaitingForConfirmation}>
<LoadingIndicator
currentLoadingPhrase="Please Confirm"
elapsedTime={15}
/>
</StreamingContext.Provider>,
);
await act(async () => {
setStateExternally?.({
state: StreamingState.WaitingForConfirmation,
phrase: 'Please Confirm',
elapsedTime: 15,
});
});
await waitUntilReady();
output = lastFrame();
expect(output).toContain('⠏');
expect(output).toContain('Please Confirm');
@@ -178,31 +214,35 @@ describe('<LoadingIndicator />', () => {
expect(output).not.toContain(', 15s');
// Transition back to Idle
rerender(
<StreamingContext.Provider value={StreamingState.Idle}>
<LoadingIndicator elapsedTime={5} />
</StreamingContext.Provider>,
);
expect(lastFrame()?.trim()).toBe(''); // Idle with no loading phrase and no spinner
await act(async () => {
setStateExternally?.({
state: StreamingState.Idle,
phrase: undefined,
elapsedTime: 5,
});
});
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Idle with no loading phrase and no spinner
unmount();
});
it('should display fallback phrase if thought is empty', () => {
it('should display fallback phrase if thought is empty', async () => {
const props = {
thought: null,
currentLoadingPhrase: 'Loading...',
elapsedTime: 5,
};
const { lastFrame, unmount } = renderWithContext(
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Loading...');
unmount();
});
it('should display the subject of a thought', () => {
it('should display the subject of a thought', async () => {
const props = {
thought: {
subject: 'Thinking about something...',
@@ -210,10 +250,11 @@ describe('<LoadingIndicator />', () => {
},
elapsedTime: 5,
};
const { lastFrame, unmount } = renderWithContext(
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
@@ -224,7 +265,7 @@ describe('<LoadingIndicator />', () => {
unmount();
});
it('should prioritize thought.subject over currentLoadingPhrase', () => {
it('should prioritize thought.subject over currentLoadingPhrase', async () => {
const props = {
thought: {
subject: 'This should be displayed',
@@ -233,10 +274,11 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'This should not be displayed',
elapsedTime: 5,
};
const { lastFrame, unmount } = renderWithContext(
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('💬');
expect(output).toContain('This should be displayed');
@@ -244,20 +286,21 @@ describe('<LoadingIndicator />', () => {
unmount();
});
it('should not display thought icon for non-thought loading phrases', () => {
const { lastFrame, unmount } = renderWithContext(
it('should not display thought icon for non-thought loading phrases', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator
currentLoadingPhrase="some random tip..."
elapsedTime={3}
/>,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('💬');
unmount();
});
it('should truncate long primary text instead of wrapping', () => {
const { lastFrame, unmount } = renderWithContext(
it('should truncate long primary text instead of wrapping', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator
{...defaultProps}
currentLoadingPhrase={
@@ -267,14 +310,15 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', () => {
const { lastFrame, unmount } = renderWithContext(
it('should render on a single line on a wide terminal', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
@@ -282,17 +326,18 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
120,
);
await waitUntilReady();
const output = lastFrame();
// Check for single line output
expect(output?.includes('\n')).toBe(false);
expect(output?.trim().includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('Right');
unmount();
});
it('should render on multiple lines on a narrow terminal', () => {
const { lastFrame, unmount } = renderWithContext(
it('should render on multiple lines on a narrow terminal', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
@@ -300,8 +345,9 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
79,
);
await waitUntilReady();
const output = lastFrame();
const lines = output?.split('\n');
const lines = output?.trim().split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
@@ -316,22 +362,24 @@ describe('<LoadingIndicator />', () => {
unmount();
});
it('should use wide layout at 80 columns', () => {
const { lastFrame, unmount } = renderWithContext(
it('should use wide layout at 80 columns', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
80,
);
expect(lastFrame()?.includes('\n')).toBe(false);
await waitUntilReady();
expect(lastFrame()?.trim().includes('\n')).toBe(false);
unmount();
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame, unmount } = renderWithContext(
it('should use narrow layout at 79 columns', async () => {
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
await waitUntilReady();
expect(lastFrame()?.includes('\n')).toBe(true);
unmount();
});
@@ -22,20 +22,25 @@ describe('LogoutConfirmationDialog', () => {
vi.clearAllMocks();
});
it('should render the dialog with title, description, and hint', () => {
const { lastFrame } = renderWithProviders(
it('should render the dialog with title, description, and hint', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<LogoutConfirmationDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('You are now logged out.');
expect(lastFrame()).toContain(
'Login again to continue using Gemini CLI, or exit the application.',
);
expect(lastFrame()).toContain('(Use Enter to select, Esc to close)');
unmount();
});
it('should render RadioButtonSelect with Login and Exit options', () => {
renderWithProviders(<LogoutConfirmationDialog onSelect={vi.fn()} />);
it('should render RadioButtonSelect with Login and Exit options', async () => {
const { waitUntilReady, unmount } = renderWithProviders(
<LogoutConfirmationDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(RadioButtonSelect).toHaveBeenCalled();
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
@@ -44,43 +49,60 @@ describe('LogoutConfirmationDialog', () => {
{ label: 'Exit', value: LogoutChoice.EXIT, key: 'exit' },
]);
expect(mockCall.isFocused).toBe(true);
unmount();
});
it('should call onSelect with LOGIN when Login is selected', async () => {
const onSelect = vi.fn();
renderWithProviders(<LogoutConfirmationDialog onSelect={onSelect} />);
const { waitUntilReady, unmount } = renderWithProviders(
<LogoutConfirmationDialog onSelect={onSelect} />,
);
await waitUntilReady();
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
await act(async () => {
mockCall.onSelect(LogoutChoice.LOGIN);
});
await waitUntilReady();
expect(onSelect).toHaveBeenCalledWith(LogoutChoice.LOGIN);
unmount();
});
it('should call onSelect with EXIT when Exit is selected', async () => {
const onSelect = vi.fn();
renderWithProviders(<LogoutConfirmationDialog onSelect={onSelect} />);
const { waitUntilReady, unmount } = renderWithProviders(
<LogoutConfirmationDialog onSelect={onSelect} />,
);
await waitUntilReady();
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
await act(async () => {
mockCall.onSelect(LogoutChoice.EXIT);
});
await waitUntilReady();
expect(onSelect).toHaveBeenCalledWith(LogoutChoice.EXIT);
unmount();
});
it('should call onSelect with EXIT when escape key is pressed', () => {
it('should call onSelect with EXIT when escape key is pressed', async () => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<LogoutConfirmationDialog onSelect={onSelect} />,
);
await waitUntilReady();
act(() => {
await act(async () => {
// Send kitty escape key sequence
stdin.write('\u001b[27u');
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
expect(onSelect).toHaveBeenCalledWith(LogoutChoice.EXIT);
unmount();
});
});
@@ -11,20 +11,23 @@ import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
describe('LoopDetectionConfirmation', () => {
const onComplete = vi.fn();
it('renders correctly', () => {
const { lastFrame } = renderWithProviders(
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<LoopDetectionConfirmation onComplete={onComplete} />,
{ width: 101 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('contains the expected options', () => {
const { lastFrame } = renderWithProviders(
it('contains the expected options', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<LoopDetectionConfirmation onComplete={onComplete} />,
{ width: 100 },
);
const output = lastFrame()!.toString();
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('A potential loop was detected');
expect(output).toContain('Keep loop detection enabled (esc)');
@@ -32,5 +35,6 @@ describe('LoopDetectionConfirmation', () => {
expect(output).toContain(
'This can happen due to repetitive tool calls or other model behavior',
);
unmount();
});
});
@@ -310,33 +310,39 @@ describe('MainContent', () => {
});
it('renders in normal buffer mode', async () => {
const { lastFrame } = renderWithProviders(<MainContent />, {
const { lastFrame, unmount } = renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('AppHeader(full)'));
const output = lastFrame();
expect(output).toContain('AppHeader');
expect(output).toContain('Hello');
expect(output).toContain('Hi there');
unmount();
});
it('renders in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('ScrollableList'));
const output = lastFrame();
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<MainContent />,
{
uiState: defaultMockUiState as Partial<UIState>,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('AppHeader(full)');
expect(output).toContain('Hello');
expect(output).toContain('Hi there');
unmount();
});
it('renders minimal header in minimal mode (alternate buffer)', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<MainContent />, {
const { lastFrame, unmount } = renderWithProviders(<MainContent />, {
uiState: {
...defaultMockUiState,
cleanUiDetailsVisible: false,
@@ -348,6 +354,7 @@ describe('MainContent', () => {
expect(output).toContain('AppHeader(minimal)');
expect(output).not.toContain('AppHeader(full)');
expect(output).toContain('Hello');
unmount();
});
it('restores full header details after toggle in alternate buffer mode', async () => {
@@ -405,15 +412,19 @@ describe('MainContent', () => {
it('does not constrain height in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('Hello'));
const output = lastFrame();
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<MainContent />,
{
uiState: defaultMockUiState as Partial<UIState>,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('AppHeader(full)');
expect(output).toContain('Hello');
expect(output).toContain('Hi there');
unmount();
});
describe('MainContent Tool Output Height Logic', () => {
@@ -502,10 +513,14 @@ describe('MainContent', () => {
bannerVisible: false,
};
const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>,
useAlternateBuffer: isAlternateBuffer,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<MainContent />,
{
uiState: uiState as Partial<UIState>,
useAlternateBuffer: isAlternateBuffer,
},
);
await waitUntilReady();
const output = lastFrame();
@@ -522,6 +537,7 @@ describe('MainContent', () => {
// Snapshots for visual verification
expect(output).toMatchSnapshot();
unmount();
},
);
});
@@ -29,13 +29,20 @@ describe('MemoryUsageDisplay', () => {
vi.restoreAllMocks();
});
it('renders memory usage', () => {
const { lastFrame } = render(<MemoryUsageDisplay />);
it('renders memory usage', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<MemoryUsageDisplay />,
);
await waitUntilReady();
expect(lastFrame()).toContain('50.0 MB');
unmount();
});
it('updates memory usage over time', async () => {
const { lastFrame } = render(<MemoryUsageDisplay />);
const { lastFrame, waitUntilReady, unmount } = render(
<MemoryUsageDisplay />,
);
await waitUntilReady();
expect(lastFrame()).toContain('50.0 MB');
vi.mocked(process.memoryUsage).mockReturnValue({
@@ -49,7 +56,9 @@ describe('MemoryUsageDisplay', () => {
await act(async () => {
vi.advanceTimersByTime(2000);
});
await waitUntilReady();
expect(lastFrame()).toContain('100.0 MB');
unmount();
});
});
@@ -70,48 +70,58 @@ describe('<ModelDialog />', () => {
});
});
const renderComponent = (configValue = mockConfig as Config) =>
renderWithProviders(<ModelDialog onClose={mockOnClose} />, {
const renderComponent = async (configValue = mockConfig as Config) => {
const result = renderWithProviders(<ModelDialog onClose={mockOnClose} />, {
config: configValue,
});
await result.waitUntilReady();
return result;
};
it('renders the initial "main" view correctly', () => {
const { lastFrame } = renderComponent();
it('renders the initial "main" view correctly', async () => {
const { lastFrame, unmount } = await renderComponent();
expect(lastFrame()).toContain('Select Model');
expect(lastFrame()).toContain('Remember model for future sessions: false');
expect(lastFrame()).toContain('Auto');
expect(lastFrame()).toContain('Manual');
unmount();
});
it('switches to "manual" view when "Manual" is selected', async () => {
const { lastFrame, stdin } = renderComponent();
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Select "Manual" (index 1)
// Press down arrow to move to "Manual"
await act(async () => {
stdin.write('\u001B[B'); // Arrow Down
});
await waitUntilReady();
// Press enter to select
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
// Should now show manual options
await waitFor(() => {
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_MODEL);
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
const output = lastFrame();
expect(output).toContain(DEFAULT_GEMINI_MODEL);
expect(output).toContain(DEFAULT_GEMINI_FLASH_MODEL);
expect(output).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
unmount();
});
it('sets model and closes when a model is selected in "main" view', async () => {
const { stdin } = renderComponent();
const { stdin, waitUntilReady, unmount } = await renderComponent();
// Select "Auto" (index 0)
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
await waitFor(() => {
expect(mockSetModel).toHaveBeenCalledWith(
@@ -120,32 +130,38 @@ describe('<ModelDialog />', () => {
);
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('sets model and closes when a model is selected in "manual" view', async () => {
const { stdin } = renderComponent();
const { stdin, waitUntilReady, unmount } = await renderComponent();
// Navigate to Manual (index 1) and select
await act(async () => {
stdin.write('\u001B[B');
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
// Now in manual view. Default selection is first item (DEFAULT_GEMINI_MODEL)
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
await waitFor(() => {
expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL, true);
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('toggles persist mode with Tab key', async () => {
const { lastFrame, stdin } = renderComponent();
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
expect(lastFrame()).toContain('Remember model for future sessions: false');
@@ -153,6 +169,7 @@ describe('<ModelDialog />', () => {
await act(async () => {
stdin.write('\t');
});
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('Remember model for future sessions: true');
@@ -162,6 +179,7 @@ describe('<ModelDialog />', () => {
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
await waitFor(() => {
expect(mockSetModel).toHaveBeenCalledWith(
@@ -170,30 +188,39 @@ describe('<ModelDialog />', () => {
);
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('closes dialog on escape in "main" view', async () => {
const { stdin } = renderComponent();
const { stdin, waitUntilReady, unmount } = await renderComponent();
await act(async () => {
stdin.write('\u001B'); // Escape
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('goes back to "main" view on escape in "manual" view', async () => {
const { lastFrame, stdin } = renderComponent();
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B');
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
@@ -203,11 +230,15 @@ describe('<ModelDialog />', () => {
await act(async () => {
stdin.write('\u001B');
});
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnClose).not.toHaveBeenCalled();
// Should be back to main view (Manual option visible)
expect(lastFrame()).toContain('Manual');
});
unmount();
});
});
@@ -33,7 +33,7 @@ vi.mock('../contexts/SettingsContext.js', async (importOriginal) => {
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const useSettingsMock = vi.mocked(SettingsContext.useSettings);
const renderWithMockedStats = (
const renderWithMockedStats = async (
metrics: SessionMetrics,
width?: number,
currentModel: string = 'gemini-2.5-pro',
@@ -59,7 +59,12 @@ const renderWithMockedStats = (
},
} as unknown as LoadedSettings);
return render(<ModelStatsDisplay currentModel={currentModel} />, width);
const result = render(
<ModelStatsDisplay currentModel={currentModel} />,
width,
);
await result.waitUntilReady();
return result;
};
describe('<ModelStatsDisplay />', () => {
@@ -76,8 +81,8 @@ describe('<ModelStatsDisplay />', () => {
vi.restoreAllMocks();
});
it('should render "no API calls" message when there are no active models', () => {
const { lastFrame } = renderWithMockedStats({
it('should render "no API calls" message when there are no active models', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {},
tools: {
totalCalls: 0,
@@ -102,10 +107,11 @@ describe('<ModelStatsDisplay />', () => {
'No API calls have been made in this session.',
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not display conditional rows if no model has data for them', () => {
const { lastFrame } = renderWithMockedStats({
it('should not display conditional rows if no model has data for them', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
@@ -145,10 +151,11 @@ describe('<ModelStatsDisplay />', () => {
expect(output).not.toContain('Thoughts');
expect(output).not.toContain('Tool');
expect(output).toMatchSnapshot();
unmount();
});
it('should display conditional rows if at least one model has data', () => {
const { lastFrame } = renderWithMockedStats({
it('should display conditional rows if at least one model has data', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
@@ -201,10 +208,11 @@ describe('<ModelStatsDisplay />', () => {
expect(output).toContain('Thoughts');
expect(output).toContain('Tool');
expect(output).toMatchSnapshot();
unmount();
});
it('should display stats for multiple models correctly', () => {
const { lastFrame } = renderWithMockedStats({
it('should display stats for multiple models correctly', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 1000 },
@@ -256,10 +264,11 @@ describe('<ModelStatsDisplay />', () => {
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('gemini-2.5-flash');
expect(output).toMatchSnapshot();
unmount();
});
it('should handle large values without wrapping or overlapping', () => {
const { lastFrame } = renderWithMockedStats({
it('should handle large values without wrapping or overlapping', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: {
@@ -299,10 +308,11 @@ describe('<ModelStatsDisplay />', () => {
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should display a single model correctly', () => {
const { lastFrame } = renderWithMockedStats({
it('should display a single model correctly', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
@@ -341,10 +351,11 @@ describe('<ModelStatsDisplay />', () => {
expect(output).toContain('gemini-2.5-pro');
expect(output).not.toContain('gemini-2.5-flash');
expect(output).toMatchSnapshot();
unmount();
});
it('should handle models with long names (gemini-3-*-preview) without layout breaking', () => {
const { lastFrame } = renderWithMockedStats(
it('should handle models with long names (gemini-3-*-preview) without layout breaking', async () => {
const { lastFrame, unmount } = await renderWithMockedStats(
{
models: {
'gemini-3-pro-preview': {
@@ -399,10 +410,11 @@ describe('<ModelStatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain('gemini-3-pro-');
expect(output).toContain('gemini-3-flash-');
unmount();
});
it('should display role breakdown correctly', () => {
const { lastFrame } = renderWithMockedStats({
it('should display role breakdown correctly', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 200 },
@@ -458,9 +470,10 @@ describe('<ModelStatsDisplay />', () => {
expect(output).toContain('Output');
expect(output).toContain('Cache Reads');
expect(output).toMatchSnapshot();
unmount();
});
it('should render user identity information when provided', () => {
it('should render user identity information when provided', async () => {
useSettingsMock.mockReturnValue({
merged: {
ui: {
@@ -469,14 +482,6 @@ describe('<ModelStatsDisplay />', () => {
},
} as unknown as LoadedSettings);
const { lastFrame } = render(
<ModelStatsDisplay
selectedAuthType="oauth"
userEmail="test@example.com"
tier="Pro"
/>,
);
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session',
@@ -523,19 +528,29 @@ describe('<ModelStatsDisplay />', () => {
startNewPrompt: vi.fn(),
});
const { lastFrame, waitUntilReady, unmount } = render(
<ModelStatsDisplay
selectedAuthType="oauth"
userEmail="test@example.com"
tier="Pro"
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Auth Method:');
expect(output).toContain('Logged in with Google');
expect(output).toContain('(test@example.com)');
expect(output).toContain('Tier:');
expect(output).toContain('Pro');
unmount();
});
it('should handle long role name layout', () => {
it('should handle long role name layout', async () => {
// Use the longest valid role name to test layout
const longRoleName = LlmRole.UTILITY_LOOP_DETECTOR;
const { lastFrame } = renderWithMockedStats({
const { lastFrame, unmount } = await renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
@@ -588,12 +603,13 @@ describe('<ModelStatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain(longRoleName);
expect(output).toMatchSnapshot();
unmount();
});
it('should filter out invalid role names', () => {
it('should filter out invalid role names', async () => {
const invalidRoleName =
'this_is_a_very_long_role_name_that_should_be_wrapped' as LlmRole;
const { lastFrame } = renderWithMockedStats({
const { lastFrame, unmount } = await renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
@@ -646,5 +662,6 @@ describe('<ModelStatsDisplay />', () => {
const output = lastFrame();
expect(output).not.toContain(invalidRoleName);
expect(output).toMatchSnapshot();
unmount();
});
});
@@ -71,22 +71,27 @@ describe('MultiFolderTrustDialog', () => {
));
});
it('renders the dialog with the list of folders', () => {
it('renders the dialog with the list of folders', async () => {
const folders = ['/path/to/folder1', '/path/to/folder2'];
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'Do you trust the following folders being added to this workspace?',
);
expect(lastFrame()).toContain('- /path/to/folder1');
expect(lastFrame()).toContain('- /path/to/folder2');
unmount();
});
it('calls onComplete and finishAddingDirectories with an error on escape', async () => {
const folders = ['/path/to/folder1'];
render(<MultiFolderTrustDialog {...defaultProps} folders={folders} />);
const { waitUntilReady, unmount } = render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
await waitUntilReady();
const keypressCallback = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
@@ -100,6 +105,7 @@ describe('MultiFolderTrustDialog', () => {
insertable: false,
});
});
await waitUntilReady();
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
mockConfig,
@@ -110,16 +116,21 @@ describe('MultiFolderTrustDialog', () => {
],
);
expect(mockOnComplete).toHaveBeenCalled();
unmount();
});
it('calls finishAddingDirectories with an error and does not add directories when "No" is chosen', async () => {
const folders = ['/path/to/folder1'];
render(<MultiFolderTrustDialog {...defaultProps} folders={folders} />);
const { waitUntilReady, unmount } = render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
onSelect(MultiFolderTrustChoice.NO);
});
await waitUntilReady();
expect(mockFinishAddingDirectories).toHaveBeenCalledWith(
mockConfig,
@@ -132,22 +143,25 @@ describe('MultiFolderTrustDialog', () => {
expect(mockOnComplete).toHaveBeenCalled();
expect(mockAddDirectory).not.toHaveBeenCalled();
expect(mockSetValue).not.toHaveBeenCalled();
unmount();
});
it('adds directories to workspace context when "Yes" is chosen', async () => {
const folders = ['/path/to/folder1', '/path/to/folder2'];
render(
const { waitUntilReady, unmount } = render(
<MultiFolderTrustDialog
{...defaultProps}
folders={folders}
trustedDirs={['/already/trusted']}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
onSelect(MultiFolderTrustChoice.YES);
});
await waitUntilReady();
expect(mockAddDirectory).toHaveBeenCalledWith(
path.resolve('/path/to/folder1'),
@@ -163,16 +177,21 @@ describe('MultiFolderTrustDialog', () => {
[],
);
expect(mockOnComplete).toHaveBeenCalled();
unmount();
});
it('adds directories to workspace context and remembers them as trusted when "Yes, and remember" is chosen', async () => {
const folders = ['/path/to/folder1'];
render(<MultiFolderTrustDialog {...defaultProps} folders={folders} />);
const { waitUntilReady, unmount } = render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
onSelect(MultiFolderTrustChoice.YES_AND_REMEMBER);
});
await waitUntilReady();
expect(mockAddDirectory).toHaveBeenCalledWith(
path.resolve('/path/to/folder1'),
@@ -188,37 +207,43 @@ describe('MultiFolderTrustDialog', () => {
[],
);
expect(mockOnComplete).toHaveBeenCalled();
unmount();
});
it('shows submitting message after a choice is made', async () => {
const folders = ['/path/to/folder1'];
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
onSelect(MultiFolderTrustChoice.NO);
});
await waitUntilReady();
expect(lastFrame()).toContain('Applying trust settings...');
unmount();
});
it('shows an error message and completes when config is missing', async () => {
const folders = ['/path/to/folder1'];
render(
const { waitUntilReady, unmount } = render(
<MultiFolderTrustDialog
{...defaultProps}
folders={folders}
config={null as unknown as Config}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
onSelect(MultiFolderTrustChoice.YES);
});
await waitUntilReady();
expect(mockAddItem).toHaveBeenCalledWith({
type: MessageType.ERROR,
@@ -226,6 +251,7 @@ describe('MultiFolderTrustDialog', () => {
});
expect(mockOnComplete).toHaveBeenCalled();
expect(mockFinishAddingDirectories).not.toHaveBeenCalled();
unmount();
});
it('collects and reports errors when some directories fail to be added', async () => {
@@ -237,18 +263,20 @@ describe('MultiFolderTrustDialog', () => {
});
const folders = ['/path/to/good', '/path/to/error'];
render(
const { waitUntilReady, unmount } = render(
<MultiFolderTrustDialog
{...defaultProps}
folders={folders}
errors={['initial error']}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
onSelect(MultiFolderTrustChoice.YES);
});
await waitUntilReady();
expect(mockAddDirectory).toHaveBeenCalledWith(
path.resolve('/path/to/good'),
@@ -263,5 +291,6 @@ describe('MultiFolderTrustDialog', () => {
['initial error', "Error adding '/path/to/error': Test error"],
);
expect(mockOnComplete).toHaveBeenCalled();
unmount();
});
});
@@ -29,17 +29,18 @@ describe('NewAgentsNotification', () => {
];
const onSelect = vi.fn();
it('renders agent list', () => {
const { lastFrame, unmount } = render(
it('renders agent list', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<NewAgentsNotification agents={mockAgents} onSelect={onSelect} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toMatchSnapshot();
unmount();
});
it('truncates list if more than 5 agents', () => {
it('truncates list if more than 5 agents', async () => {
const manyAgents = Array.from({ length: 7 }, (_, i) => ({
name: `Agent ${i}`,
description: `Description ${i}`,
@@ -48,9 +49,10 @@ describe('NewAgentsNotification', () => {
inputConfig: { inputSchema: {} },
}));
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<NewAgentsNotification agents={manyAgents} onSelect={onSelect} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toMatchSnapshot();
@@ -99,54 +99,64 @@ describe('Notifications', () => {
mockUseIsScreenReaderEnabled.mockReturnValue(false);
});
it('renders nothing when no notifications', () => {
const { lastFrame } = render(<Notifications />);
expect(lastFrame()).toBe('');
it('renders nothing when no notifications', async () => {
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it.each([[['Warning 1']], [['Warning 1', 'Warning 2']]])(
'renders startup warnings: %s',
(warnings) => {
async (warnings) => {
mockUseAppContext.mockReturnValue({
startupWarnings: warnings,
version: '1.0.0',
} as AppState);
const { lastFrame } = render(<Notifications />);
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
await waitUntilReady();
const output = lastFrame();
warnings.forEach((warning) => {
expect(output).toContain(warning);
});
unmount();
},
);
it('renders init error', () => {
it('renders init error', async () => {
mockUseUIState.mockReturnValue({
initError: 'Something went wrong',
streamingState: 'idle',
updateInfo: null,
} as unknown as UIState);
const { lastFrame } = render(<Notifications />);
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('does not render init error when streaming', () => {
it('does not render init error when streaming', async () => {
mockUseUIState.mockReturnValue({
initError: 'Something went wrong',
streamingState: 'responding',
updateInfo: null,
} as unknown as UIState);
const { lastFrame } = render(<Notifications />);
expect(lastFrame()).toBe('');
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders update notification', () => {
it('renders update notification', async () => {
mockUseUIState.mockReturnValue({
initError: null,
streamingState: 'idle',
updateInfo: { message: 'Update available' },
} as unknown as UIState);
const { lastFrame } = render(<Notifications />);
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders screen reader nudge when enabled and not seen (no legacy file)', async () => {
@@ -154,7 +164,8 @@ describe('Notifications', () => {
persistentStateMock.setData({ hasSeenScreenReaderNudge: false });
mockFsAccess.mockRejectedValue(new Error('No legacy file'));
const { lastFrame } = render(<Notifications />);
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
await waitUntilReady();
expect(lastFrame()).toContain('screen reader-friendly view');
expect(persistentStateMock.set).toHaveBeenCalledWith(
@@ -163,6 +174,7 @@ describe('Notifications', () => {
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('migrates legacy screen reader nudge file', async () => {
@@ -170,9 +182,10 @@ describe('Notifications', () => {
persistentStateMock.setData({ hasSeenScreenReaderNudge: undefined });
mockFsAccess.mockResolvedValue(undefined);
render(<Notifications />);
const { waitUntilReady, unmount } = render(<Notifications />);
await act(async () => {
await waitUntilReady();
await waitFor(() => {
expect(persistentStateMock.set).toHaveBeenCalledWith(
'hasSeenScreenReaderNudge',
@@ -181,15 +194,18 @@ describe('Notifications', () => {
expect(mockFsUnlink).toHaveBeenCalled();
});
});
unmount();
});
it('does not render screen reader nudge when already seen in persistent state', async () => {
mockUseIsScreenReaderEnabled.mockReturnValue(true);
persistentStateMock.setData({ hasSeenScreenReaderNudge: true });
const { lastFrame } = render(<Notifications />);
const { lastFrame, waitUntilReady, unmount } = render(<Notifications />);
await waitUntilReady();
expect(lastFrame()).toBe('');
expect(lastFrame({ allowEmpty: true })).toBe('');
expect(persistentStateMock.set).not.toHaveBeenCalled();
unmount();
});
});
@@ -65,15 +65,17 @@ describe('PermissionsModifyTrustDialog', () => {
});
it('should render the main dialog with current trust level', async () => {
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('Modify Trust Level');
expect(lastFrame()).toContain('Folder: /test/dir');
expect(lastFrame()).toContain('Current Level: DO_NOT_TRUST');
});
unmount();
});
it('should display the inherited trust note from parent', async () => {
@@ -87,15 +89,17 @@ describe('PermissionsModifyTrustDialog', () => {
commitTrustLevelChange: mockCommitTrustLevelChange,
isFolderTrustEnabled: true,
});
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain(
'Note: This folder behaves as a trusted folder because one of the parent folders is trusted.',
);
});
unmount();
});
it('should display the inherited trust note from IDE', async () => {
@@ -109,43 +113,53 @@ describe('PermissionsModifyTrustDialog', () => {
commitTrustLevelChange: mockCommitTrustLevelChange,
isFolderTrustEnabled: true,
});
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain(
'Note: This folder behaves as a trusted folder because the connected IDE workspace is trusted.',
);
});
unmount();
});
it('should render the labels with folder names', async () => {
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('Trust this folder (dir)');
expect(lastFrame()).toContain('Trust parent folder (test)');
});
unmount();
});
it('should call onExit when escape is pressed', async () => {
const onExit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady, unmount } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
act(() => {
await act(async () => {
stdin.write('\u001b[27u'); // Kitty escape key
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(onExit).toHaveBeenCalled();
});
unmount();
});
it('should commit and restart `r` keypress', async () => {
@@ -165,13 +179,17 @@ describe('PermissionsModifyTrustDialog', () => {
});
const onExit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady, unmount } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
act(() => stdin.write('r')); // Press 'r' to restart
await act(async () => {
stdin.write('r'); // Press 'r' to restart
});
await waitUntilReady();
await waitFor(() => {
expect(mockCommitTrustLevelChange).toHaveBeenCalled();
@@ -179,6 +197,7 @@ describe('PermissionsModifyTrustDialog', () => {
});
mockRelaunchApp.mockRestore();
unmount();
});
it('should not commit when escape is pressed during restart prompt', async () => {
@@ -194,17 +213,24 @@ describe('PermissionsModifyTrustDialog', () => {
});
const onExit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady, unmount } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
act(() => stdin.write('\u001b[27u')); // Press kitty escape key
await act(async () => {
stdin.write('\u001b[27u'); // Press kitty escape key
});
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockCommitTrustLevelChange).not.toHaveBeenCalled();
expect(onExit).toHaveBeenCalled();
});
unmount();
});
});
@@ -9,19 +9,21 @@ import { render } from '../../test-utils/render.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
describe('QueuedMessageDisplay', () => {
it('renders nothing when message queue is empty', () => {
const { lastFrame, unmount } = render(
it('renders nothing when message queue is empty', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QueuedMessageDisplay messageQueue={[]} />,
);
await waitUntilReady();
expect(lastFrame()).toBe('');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('displays single queued message', () => {
const { lastFrame, unmount } = render(
it('displays single queued message', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QueuedMessageDisplay messageQueue={['First message']} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Queued (press ↑ to edit):');
@@ -29,16 +31,17 @@ describe('QueuedMessageDisplay', () => {
unmount();
});
it('displays multiple queued messages', () => {
it('displays multiple queued messages', async () => {
const messageQueue = [
'First queued message',
'Second queued message',
'Third queued message',
];
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Queued (press ↑ to edit):');
@@ -48,7 +51,7 @@ describe('QueuedMessageDisplay', () => {
unmount();
});
it('shows overflow indicator when more than 3 messages are queued', () => {
it('shows overflow indicator when more than 3 messages are queued', async () => {
const messageQueue = [
'Message 1',
'Message 2',
@@ -57,9 +60,10 @@ describe('QueuedMessageDisplay', () => {
'Message 5',
];
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Queued (press ↑ to edit):');
@@ -72,12 +76,13 @@ describe('QueuedMessageDisplay', () => {
unmount();
});
it('normalizes whitespace in messages', () => {
it('normalizes whitespace in messages', async () => {
const messageQueue = ['Message with\tmultiple\n whitespace'];
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Queued (press ↑ to edit):');
@@ -39,15 +39,17 @@ describe('QuittingDisplay', () => {
mockUseTerminalSize.mockReturnValue({ rows: 20, columns: 80 });
});
it('renders nothing when no quitting messages', () => {
it('renders nothing when no quitting messages', async () => {
mockUseUIState.mockReturnValue({
quittingMessages: null,
} as unknown as UIState);
const { lastFrame } = render(<QuittingDisplay />);
expect(lastFrame()).toBe('');
const { lastFrame, waitUntilReady, unmount } = render(<QuittingDisplay />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders quitting messages', () => {
it('renders quitting messages', async () => {
const mockMessages = [
{ id: '1', type: 'user', content: 'Goodbye' },
{ id: '2', type: 'model', content: 'See you later' },
@@ -56,8 +58,10 @@ describe('QuittingDisplay', () => {
quittingMessages: mockMessages,
constrainHeight: false,
} as unknown as UIState);
const { lastFrame } = render(<QuittingDisplay />);
const { lastFrame, waitUntilReady, unmount } = render(<QuittingDisplay />);
await waitUntilReady();
expect(lastFrame()).toContain('Goodbye');
expect(lastFrame()).toContain('See you later');
unmount();
});
});
@@ -9,51 +9,73 @@ import { describe, it, expect } from 'vitest';
import { QuotaDisplay } from './QuotaDisplay.js';
describe('QuotaDisplay', () => {
it('should not render when remaining is undefined', () => {
const { lastFrame } = render(
it('should not render when remaining is undefined', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={undefined} limit={100} />,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should not render when limit is undefined', () => {
const { lastFrame } = render(
it('should not render when limit is undefined', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={100} limit={undefined} />,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should not render when limit is 0', () => {
const { lastFrame } = render(<QuotaDisplay remaining={100} limit={0} />);
expect(lastFrame()).toBe('');
it('should not render when limit is 0', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={100} limit={0} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should not render when usage > 20%', () => {
const { lastFrame } = render(<QuotaDisplay remaining={85} limit={100} />);
expect(lastFrame()).toBe('');
it('should not render when usage > 20%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={85} limit={100} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should render yellow when usage < 20%', () => {
const { lastFrame } = render(<QuotaDisplay remaining={15} limit={100} />);
it('should render yellow when usage < 20%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={15} limit={100} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render red when usage < 5%', () => {
const { lastFrame } = render(<QuotaDisplay remaining={4} limit={100} />);
it('should render red when usage < 5%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={4} limit={100} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render with reset time when provided', () => {
it('should render with reset time when provided', async () => {
const resetTime = new Date(Date.now() + 3600000).toISOString(); // 1 hour from now
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={15} limit={100} resetTime={resetTime} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should NOT render reset time when terse is true', () => {
it('should NOT render reset time when terse is true', async () => {
const resetTime = new Date(Date.now() + 3600000).toISOString();
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay
remaining={15}
limit={100}
@@ -61,13 +83,17 @@ describe('QuotaDisplay', () => {
terse={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render terse limit reached message', () => {
const { lastFrame } = render(
it('should render terse limit reached message', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={0} limit={100} terse={true} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -17,21 +17,29 @@ describe('RawMarkdownIndicator', () => {
});
});
it('renders correct key binding for darwin', () => {
it('renders correct key binding for darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
const { lastFrame } = render(<RawMarkdownIndicator />);
const { lastFrame, waitUntilReady, unmount } = render(
<RawMarkdownIndicator />,
);
await waitUntilReady();
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('option+m to toggle');
unmount();
});
it('renders correct key binding for other platforms', () => {
it('renders correct key binding for other platforms', async () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
const { lastFrame } = render(<RawMarkdownIndicator />);
const { lastFrame, waitUntilReady, unmount } = render(
<RawMarkdownIndicator />,
);
await waitUntilReady();
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('alt+m to toggle');
unmount();
});
});
@@ -15,7 +15,7 @@ describe('RewindConfirmation', () => {
vi.restoreAllMocks();
});
it('renders correctly with stats', () => {
it('renders correctly with stats', async () => {
const stats = {
addedLines: 10,
removedLines: 5,
@@ -23,7 +23,7 @@ describe('RewindConfirmation', () => {
details: [{ fileName: 'test.ts', diff: '' }],
};
const onConfirm = vi.fn();
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<RewindConfirmation
stats={stats}
onConfirm={onConfirm}
@@ -31,14 +31,16 @@ describe('RewindConfirmation', () => {
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain('Revert code changes');
unmount();
});
it('renders correctly without stats', () => {
it('renders correctly without stats', async () => {
const onConfirm = vi.fn();
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<RewindConfirmation
stats={null}
onConfirm={onConfirm}
@@ -46,15 +48,17 @@ describe('RewindConfirmation', () => {
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).not.toContain('Revert code changes');
expect(lastFrame()).toContain('Rewind conversation');
unmount();
});
it('calls onConfirm with Cancel on Escape', async () => {
const onConfirm = vi.fn();
const { stdin } = renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<RewindConfirmation
stats={null}
onConfirm={onConfirm}
@@ -62,6 +66,7 @@ describe('RewindConfirmation', () => {
/>,
{ width: 80 },
);
await waitUntilReady();
await act(async () => {
stdin.write('\x1b');
@@ -70,12 +75,13 @@ describe('RewindConfirmation', () => {
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(RewindOutcome.Cancel);
});
unmount();
});
it('renders timestamp when provided', () => {
it('renders timestamp when provided', async () => {
const onConfirm = vi.fn();
const timestamp = new Date().toISOString();
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<RewindConfirmation
stats={null}
onConfirm={onConfirm}
@@ -84,8 +90,10 @@ describe('RewindConfirmation', () => {
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).not.toContain('Revert code changes');
unmount();
});
});
@@ -62,7 +62,12 @@ const createConversation = (messages: MessageRecord[]): ConversationRecord => ({
});
describe('RewindViewer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -87,18 +92,20 @@ describe('RewindViewer', () => {
},
],
},
])('renders $name', ({ messages }) => {
])('renders $name', async ({ messages }) => {
const conversation = createConversation(messages as MessageRecord[]);
const onExit = vi.fn();
const onRewind = vi.fn();
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -113,13 +120,14 @@ describe('RewindViewer', () => {
]);
const onExit = vi.fn();
const onRewind = vi.fn();
const { lastFrame, stdin } = renderWithProviders(
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
await waitUntilReady();
// Initial state
expect(lastFrame()).toMatchSnapshot('initial-state');
@@ -128,10 +136,12 @@ describe('RewindViewer', () => {
act(() => {
stdin.write('\x1b[B');
});
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot('after-down');
});
unmount();
});
describe('Navigation', () => {
@@ -144,20 +154,30 @@ describe('RewindViewer', () => {
{ type: 'user', content: 'Q2', id: '2', timestamp: '1' },
{ type: 'user', content: 'Q3', id: '3', timestamp: '1' },
]);
const { lastFrame, stdin } = renderWithProviders(
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={vi.fn()}
onRewind={vi.fn()}
/>,
);
await waitUntilReady();
act(() => {
stdin.write(sequence);
});
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot(expectedSnapshot);
const frame = lastFrame();
expect(frame).toMatchSnapshot(expectedSnapshot);
if (expectedSnapshot === 'after-up') {
const headerLines = frame
?.split('\n')
.filter((line) => line.includes('╭───'));
expect(headerLines).toHaveLength(1);
}
});
unmount();
});
it('handles cyclic navigation', async () => {
@@ -166,18 +186,20 @@ describe('RewindViewer', () => {
{ type: 'user', content: 'Q2', id: '2', timestamp: '1' },
{ type: 'user', content: 'Q3', id: '3', timestamp: '1' },
]);
const { lastFrame, stdin } = renderWithProviders(
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={vi.fn()}
onRewind={vi.fn()}
/>,
);
await waitUntilReady();
// Up from first -> Last
act(() => {
stdin.write('\x1b[A');
});
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot('cyclic-up');
});
@@ -186,9 +208,11 @@ describe('RewindViewer', () => {
act(() => {
stdin.write('\x1b[B');
});
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot('cyclic-down');
});
unmount();
});
});
@@ -199,14 +223,16 @@ describe('RewindViewer', () => {
actionStep: async (
stdin: { write: (data: string) => void },
lastFrame: () => string | undefined,
waitUntilReady: () => Promise<void>,
) => {
// Wait for confirmation dialog to be rendered and interactive
await waitFor(() => {
expect(lastFrame()).toContain('Confirm Rewind');
});
act(() => {
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
},
},
{
@@ -214,14 +240,18 @@ describe('RewindViewer', () => {
actionStep: async (
stdin: { write: (data: string) => void },
lastFrame: () => string | undefined,
waitUntilReady: () => Promise<void>,
) => {
// Wait for confirmation dialog
await waitFor(() => {
expect(lastFrame()).toContain('Confirm Rewind');
});
act(() => {
await act(async () => {
stdin.write('\x1b');
});
await act(async () => {
await waitUntilReady();
});
// Wait for return to main view
await waitFor(() => {
expect(lastFrame()).toContain('> Rewind');
@@ -233,23 +263,26 @@ describe('RewindViewer', () => {
{ type: 'user', content: 'Original Prompt', id: '1', timestamp: '1' },
]);
const onRewind = vi.fn();
const { lastFrame, stdin } = renderWithProviders(
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={vi.fn()}
onRewind={onRewind}
/>,
);
await waitUntilReady();
// Select
act(() => {
await act(async () => {
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
stdin.write('\r');
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('confirmation-dialog');
// Act
await actionStep(stdin, lastFrame);
await actionStep(stdin, lastFrame, waitUntilReady);
unmount();
});
});
@@ -287,13 +320,14 @@ describe('RewindViewer', () => {
},
]);
const onRewind = vi.fn();
const { lastFrame, stdin } = renderWithProviders(
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={vi.fn()}
onRewind={onRewind}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -302,6 +336,7 @@ describe('RewindViewer', () => {
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
stdin.write('\r'); // Select
});
await waitUntilReady();
// Wait for confirmation dialog
await waitFor(() => {
@@ -312,14 +347,16 @@ describe('RewindViewer', () => {
act(() => {
stdin.write('\r');
});
await waitUntilReady();
await waitFor(() => {
expect(onRewind).toHaveBeenCalledWith('1', expected, expect.anything());
});
unmount();
});
});
it('updates content when conversation changes (background update)', () => {
it('updates content when conversation changes (background update)', async () => {
const messages: MessageRecord[] = [
{ type: 'user', content: 'Message 1', id: '1', timestamp: '1' },
];
@@ -327,13 +364,14 @@ describe('RewindViewer', () => {
const onExit = vi.fn();
const onRewind = vi.fn();
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('initial');
@@ -345,14 +383,20 @@ describe('RewindViewer', () => {
];
conversation = createConversation(newMessages);
const { lastFrame: lastFrame2 } = renderWithProviders(
const {
lastFrame: lastFrame2,
waitUntilReady: waitUntilReady2,
unmount: unmount2,
} = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
await waitUntilReady2();
expect(lastFrame2()).toMatchSnapshot('after-update');
unmount2();
});
});
@@ -162,18 +162,20 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
stats={confirmationStats}
terminalWidth={terminalWidth}
timestamp={selectedMessage?.timestamp}
onConfirm={async (outcome) => {
onConfirm={(outcome) => {
if (outcome === RewindOutcome.Cancel) {
clearSelection();
} else {
const userPrompt = interactions.find(
(m) => m.id === selectedMessageId,
);
if (userPrompt) {
const cleanedText = getCleanedRewindText(userPrompt);
setIsRewinding(true);
await onRewind(selectedMessageId, cleanedText, outcome);
}
void (async () => {
const userPrompt = interactions.find(
(m) => m.id === selectedMessageId,
);
if (userPrompt) {
const cleanedText = getCleanedRewindText(userPrompt);
setIsRewinding(true);
await onRewind(selectedMessageId, cleanedText, outcome);
}
})();
}
}}
/>
@@ -149,13 +149,13 @@ describe('SessionBrowser component', () => {
vi.restoreAllMocks();
});
it('shows empty state when no sessions exist', () => {
it('shows empty state when no sessions exist', async () => {
const config = createMockConfig();
const onResumeSession = vi.fn();
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
@@ -164,11 +164,12 @@ describe('SessionBrowser component', () => {
testSessions={[]}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders a list of sessions and marks current session as disabled', () => {
it('renders a list of sessions and marks current session as disabled', async () => {
const session1 = createSession({
id: 'abc123',
file: 'abc123',
@@ -192,7 +193,7 @@ describe('SessionBrowser component', () => {
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
@@ -201,11 +202,13 @@ describe('SessionBrowser component', () => {
testSessions={[session1, session2]}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('enters search mode, filters sessions, and renders match snippets', async () => {
// ... same searchSession setup ...
const searchSession = createSession({
id: 'search1',
file: 'search1',
@@ -243,7 +246,7 @@ describe('SessionBrowser component', () => {
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
@@ -252,11 +255,13 @@ describe('SessionBrowser component', () => {
testSessions={[searchSession, otherSession]}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Chat Sessions (2 total');
// Enter search mode.
triggerKey({ sequence: '/', name: '/' });
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('Search:');
@@ -272,6 +277,7 @@ describe('SessionBrowser component', () => {
cmd: false,
});
}
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('Chat Sessions (1 total, filtered');
@@ -279,7 +285,7 @@ describe('SessionBrowser component', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('handles keyboard navigation and resumes the selected session', () => {
it('handles keyboard navigation and resumes the selected session', async () => {
const session1 = createSession({
id: 'one',
file: 'one',
@@ -300,7 +306,7 @@ describe('SessionBrowser component', () => {
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
@@ -309,21 +315,24 @@ describe('SessionBrowser component', () => {
testSessions={[session1, session2]}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Chat Sessions (2 total');
// Move selection down.
triggerKey({ name: 'down', sequence: '[B' });
await waitUntilReady();
// Press Enter.
triggerKey({ name: 'return', sequence: '\r' });
await waitUntilReady();
expect(onResumeSession).toHaveBeenCalledTimes(1);
const [resumedSession] = onResumeSession.mock.calls[0];
expect(resumedSession).toEqual(session2);
});
it('does not allow resuming or deleting the current session', () => {
it('does not allow resuming or deleting the current session', async () => {
const currentSession = createSession({
id: 'current',
file: 'current',
@@ -346,7 +355,7 @@ describe('SessionBrowser component', () => {
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
render(
const { waitUntilReady } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
@@ -355,23 +364,26 @@ describe('SessionBrowser component', () => {
testSessions={[currentSession, otherSession]}
/>,
);
await waitUntilReady();
// Active selection is at 0 (current session).
triggerKey({ name: 'return', sequence: '\r' });
await waitUntilReady();
expect(onResumeSession).not.toHaveBeenCalled();
// Attempt delete.
triggerKey({ sequence: 'x', name: 'x' });
await waitUntilReady();
expect(onDeleteSession).not.toHaveBeenCalled();
});
it('shows an error state when loading sessions fails', () => {
it('shows an error state when loading sessions fails', async () => {
const config = createMockConfig();
const onResumeSession = vi.fn();
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
const onExit = vi.fn();
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
@@ -380,6 +392,7 @@ describe('SessionBrowser component', () => {
testError="storage failure"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -24,14 +24,15 @@ describe('SessionRetentionWarningDialog', () => {
vi.restoreAllMocks();
});
it('renders correctly with warning message and session count', () => {
const { lastFrame } = renderWithProviders(
it('renders correctly with warning message and session count', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<SessionRetentionWarningDialog
onKeep120Days={vi.fn()}
onKeep30Days={vi.fn()}
sessionsToDeleteCount={42}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Keep chat history');
expect(lastFrame()).toContain(
@@ -43,14 +44,15 @@ describe('SessionRetentionWarningDialog', () => {
expect(lastFrame()).toContain('No sessions will be deleted at this time');
});
it('handles pluralization correctly for 1 session', () => {
const { lastFrame } = renderWithProviders(
it('handles pluralization correctly for 1 session', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<SessionRetentionWarningDialog
onKeep120Days={vi.fn()}
onKeep30Days={vi.fn()}
sessionsToDeleteCount={1}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('1 session will be deleted');
});
@@ -59,13 +61,14 @@ describe('SessionRetentionWarningDialog', () => {
const onKeep120Days = vi.fn();
const onKeep30Days = vi.fn();
const { stdin } = renderWithProviders(
const { stdin, waitUntilReady } = renderWithProviders(
<SessionRetentionWarningDialog
onKeep120Days={onKeep120Days}
onKeep30Days={onKeep30Days}
sessionsToDeleteCount={10}
/>,
);
await waitUntilReady();
// Initial selection should be "Keep for 120 days" (index 1) because count > 0
// Pressing Enter immediately should select it.
@@ -81,13 +84,14 @@ describe('SessionRetentionWarningDialog', () => {
const onKeep120Days = vi.fn();
const onKeep30Days = vi.fn();
const { stdin } = renderWithProviders(
const { stdin, waitUntilReady } = renderWithProviders(
<SessionRetentionWarningDialog
onKeep120Days={onKeep120Days}
onKeep30Days={onKeep30Days}
sessionsToDeleteCount={10}
/>,
);
await waitUntilReady();
// Default is index 1 (120 days). Move UP to index 0 (30 days).
writeKey(stdin, '\x1b[A'); // Up arrow
@@ -100,13 +104,14 @@ describe('SessionRetentionWarningDialog', () => {
});
it('should match snapshot', async () => {
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<SessionRetentionWarningDialog
onKeep120Days={vi.fn()}
onKeep30Days={vi.fn()}
sessionsToDeleteCount={123}
/>,
);
await waitUntilReady();
// Initial render
expect(lastFrame()).toMatchSnapshot();
@@ -21,7 +21,7 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
const renderWithMockedStats = async (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session',
@@ -35,13 +35,18 @@ const renderWithMockedStats = (metrics: SessionMetrics) => {
startNewPrompt: vi.fn(),
});
return renderWithProviders(<SessionSummaryDisplay duration="1h 23m 45s" />, {
width: 100,
});
const result = renderWithProviders(
<SessionSummaryDisplay duration="1h 23m 45s" />,
{
width: 100,
},
);
await result.waitUntilReady();
return result;
};
describe('<SessionSummaryDisplay />', () => {
it('renders the summary display with a title', () => {
it('renders the summary display with a title', async () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
@@ -77,10 +82,11 @@ describe('<SessionSummaryDisplay />', () => {
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame, unmount } = await renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).toMatchSnapshot();
unmount();
});
});
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@
import { render } from '../../test-utils/render.js';
import { ShellInputPrompt } from './ShellInputPrompt.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { useUIActions, type UIActions } from '../contexts/UIActionsContext.js';
@@ -46,63 +47,86 @@ describe('ShellInputPrompt', () => {
} as Partial<UIActions> as UIActions);
});
it('renders nothing', () => {
const { lastFrame } = render(
it('renders nothing', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ShellInputPrompt activeShellPtyId={1} focus={true} />,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('sends tab to pty', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
it('sends tab to pty', async () => {
const { waitUntilReady, unmount } = render(
<ShellInputPrompt activeShellPtyId={1} focus={true} />,
);
await waitUntilReady();
const handler = mockUseKeypress.mock.calls[0][0];
handler({
name: 'tab',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '\t',
await act(async () => {
handler({
name: 'tab',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '\t',
});
});
await waitUntilReady();
expect(mockWriteToPty).toHaveBeenCalledWith(1, '\t');
unmount();
});
it.each([
['a', 'a'],
['b', 'b'],
])('handles keypress input: %s', (name, sequence) => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
])('handles keypress input: %s', async (name, sequence) => {
const { waitUntilReady, unmount } = render(
<ShellInputPrompt activeShellPtyId={1} focus={true} />,
);
await waitUntilReady();
// Get the registered handler
const handler = mockUseKeypress.mock.calls[0][0];
// Simulate keypress
handler({
name,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence,
await act(async () => {
handler({
name,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence,
});
});
await waitUntilReady();
expect(mockWriteToPty).toHaveBeenCalledWith(1, sequence);
unmount();
});
it.each([
['up', -1],
['down', 1],
])('handles scroll %s (Command.SCROLL_%s)', (key, direction) => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
])('handles scroll %s (Command.SCROLL_%s)', async (key, direction) => {
const { waitUntilReady, unmount } = render(
<ShellInputPrompt activeShellPtyId={1} focus={true} />,
);
await waitUntilReady();
const handler = mockUseKeypress.mock.calls[0][0];
handler({ name: key, shift: true, alt: false, ctrl: false, cmd: false });
await act(async () => {
handler({ name: key, shift: true, alt: false, ctrl: false, cmd: false });
});
await waitUntilReady();
expect(mockScrollPty).toHaveBeenCalledWith(1, direction);
unmount();
});
it.each([
@@ -110,97 +134,140 @@ describe('ShellInputPrompt', () => {
['pagedown', 15],
])(
'handles page scroll %s (Command.PAGE_%s) with default size',
(key, expectedScroll) => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
async (key, expectedScroll) => {
const { waitUntilReady, unmount } = render(
<ShellInputPrompt activeShellPtyId={1} focus={true} />,
);
await waitUntilReady();
const handler = mockUseKeypress.mock.calls[0][0];
handler({ name: key, shift: false, alt: false, ctrl: false, cmd: false });
await act(async () => {
handler({
name: key,
shift: false,
alt: false,
ctrl: false,
cmd: false,
});
});
await waitUntilReady();
expect(mockScrollPty).toHaveBeenCalledWith(1, expectedScroll);
unmount();
},
);
it('respects scrollPageSize prop', () => {
render(
it('respects scrollPageSize prop', async () => {
const { waitUntilReady, unmount } = render(
<ShellInputPrompt
activeShellPtyId={1}
focus={true}
scrollPageSize={10}
/>,
);
await waitUntilReady();
const handler = mockUseKeypress.mock.calls[0][0];
// PageDown
handler({
name: 'pagedown',
shift: false,
alt: false,
ctrl: false,
cmd: false,
await act(async () => {
handler({
name: 'pagedown',
shift: false,
alt: false,
ctrl: false,
cmd: false,
});
});
await waitUntilReady();
expect(mockScrollPty).toHaveBeenCalledWith(1, 10);
// PageUp
handler({
name: 'pageup',
shift: false,
alt: false,
ctrl: false,
cmd: false,
await act(async () => {
handler({
name: 'pageup',
shift: false,
alt: false,
ctrl: false,
cmd: false,
});
});
await waitUntilReady();
expect(mockScrollPty).toHaveBeenCalledWith(1, -10);
unmount();
});
it('does not handle input when not focused', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={false} />);
it('does not handle input when not focused', async () => {
const { waitUntilReady, unmount } = render(
<ShellInputPrompt activeShellPtyId={1} focus={false} />,
);
await waitUntilReady();
const handler = mockUseKeypress.mock.calls[0][0];
handler({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
await act(async () => {
handler({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
});
await waitUntilReady();
expect(mockWriteToPty).not.toHaveBeenCalled();
unmount();
});
it('does not handle input when no active shell', () => {
render(<ShellInputPrompt activeShellPtyId={null} focus={true} />);
it('does not handle input when no active shell', async () => {
const { waitUntilReady, unmount } = render(
<ShellInputPrompt activeShellPtyId={null} focus={true} />,
);
await waitUntilReady();
const handler = mockUseKeypress.mock.calls[0][0];
handler({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
await act(async () => {
handler({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
});
await waitUntilReady();
expect(mockWriteToPty).not.toHaveBeenCalled();
unmount();
});
it('ignores Command.UNFOCUS_SHELL (Shift+Tab) to allow focus navigation', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
it('ignores Command.UNFOCUS_SHELL (Shift+Tab) to allow focus navigation', async () => {
const { waitUntilReady, unmount } = render(
<ShellInputPrompt activeShellPtyId={1} focus={true} />,
);
await waitUntilReady();
const handler = mockUseKeypress.mock.calls[0][0];
const result = handler({
name: 'tab',
shift: true,
alt: false,
ctrl: false,
cmd: false,
let result: boolean | undefined;
await act(async () => {
result = handler({
name: 'tab',
shift: true,
alt: false,
ctrl: false,
cmd: false,
});
});
await waitUntilReady();
expect(result).toBe(false);
expect(mockWriteToPty).not.toHaveBeenCalled();
unmount();
});
});
@@ -9,9 +9,13 @@ import { ShellModeIndicator } from './ShellModeIndicator.js';
import { describe, it, expect } from 'vitest';
describe('ShellModeIndicator', () => {
it('renders correctly', () => {
const { lastFrame } = render(<ShellModeIndicator />);
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ShellModeIndicator />,
);
await waitUntilReady();
expect(lastFrame()).toContain('shell mode enabled');
expect(lastFrame()).toContain('esc to disable');
unmount();
});
});
@@ -34,21 +34,27 @@ describe('ShortcutsHelp', () => {
),
)(
'renders correctly in $name mode on $platform.name',
({ width, platform }) => {
async ({ width, platform }) => {
Object.defineProperty(process, 'platform', {
value: platform.value,
});
const { lastFrame } = renderWithProviders(<ShortcutsHelp />, {
width,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ShortcutsHelp />,
{
width,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('shell mode');
expect(lastFrame()).toMatchSnapshot();
unmount();
},
);
it('always shows Tab Tab focus UI shortcut', () => {
it('always shows Tab Tab focus UI shortcut', async () => {
const rendered = renderWithProviders(<ShortcutsHelp />);
await rendered.waitUntilReady();
expect(rendered.lastFrame()).toContain('Tab Tab');
rendered.unmount();
});
@@ -32,27 +32,33 @@ describe('ShowMoreLines', () => {
[new Set(['1']), StreamingState.Responding, true], // Streaming
])(
'renders nothing when: overflow=%s, streaming=%s, constrain=%s',
(overflowingIds, streamingState, constrainHeight) => {
async (overflowingIds, streamingState, constrainHeight) => {
mockUseOverflowState.mockReturnValue({ overflowingIds } as NonNullable<
ReturnType<typeof useOverflowState>
>);
mockUseStreamingContext.mockReturnValue(streamingState);
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ShowMoreLines constrainHeight={constrainHeight} />,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
},
);
it.each([[StreamingState.Idle], [StreamingState.WaitingForConfirmation]])(
'renders message when overflowing and state is %s',
(streamingState) => {
async (streamingState) => {
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(streamingState);
const { lastFrame } = render(<ShowMoreLines constrainHeight={true} />);
const { lastFrame, waitUntilReady, unmount } = render(
<ShowMoreLines constrainHeight={true} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
unmount();
},
);
});
@@ -68,10 +68,11 @@ const createTestMetrics = (
});
describe('<StatsDisplay />', () => {
it('renders only the Performance section in its zero state', () => {
it('renders only the Performance section in its zero state', async () => {
const zeroMetrics = createTestMetrics();
const { lastFrame } = renderWithMockedStats(zeroMetrics);
const { lastFrame, waitUntilReady } = renderWithMockedStats(zeroMetrics);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Performance');
@@ -79,7 +80,7 @@ describe('<StatsDisplay />', () => {
expect(output).toMatchSnapshot();
});
it('renders a table with two models correctly', () => {
it('renders a table with two models correctly', async () => {
const metrics = createTestMetrics({
models: {
'gemini-2.5-pro': {
@@ -111,7 +112,8 @@ describe('<StatsDisplay />', () => {
},
});
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame, waitUntilReady } = renderWithMockedStats(metrics);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('gemini-2.5-pro');
@@ -121,7 +123,7 @@ describe('<StatsDisplay />', () => {
expect(output).toMatchSnapshot();
});
it('renders all sections when all data is present', () => {
it('renders all sections when all data is present', async () => {
const metrics = createTestMetrics({
models: {
'gemini-2.5-pro': {
@@ -166,7 +168,8 @@ describe('<StatsDisplay />', () => {
},
});
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame, waitUntilReady } = renderWithMockedStats(metrics);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Performance');
@@ -177,7 +180,7 @@ describe('<StatsDisplay />', () => {
});
describe('Conditional Rendering Tests', () => {
it('hides User Agreement when no decisions are made', () => {
it('hides User Agreement when no decisions are made', async () => {
const metrics = createTestMetrics({
tools: {
totalCalls: 2,
@@ -207,7 +210,8 @@ describe('<StatsDisplay />', () => {
},
});
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame, waitUntilReady } = renderWithMockedStats(metrics);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Interaction Summary');
@@ -216,7 +220,7 @@ describe('<StatsDisplay />', () => {
expect(output).toMatchSnapshot();
});
it('hides Efficiency section when cache is not used', () => {
it('hides Efficiency section when cache is not used', async () => {
const metrics = createTestMetrics({
models: {
'gemini-2.5-pro': {
@@ -235,7 +239,8 @@ describe('<StatsDisplay />', () => {
},
});
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame, waitUntilReady } = renderWithMockedStats(metrics);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
@@ -243,7 +248,7 @@ describe('<StatsDisplay />', () => {
});
describe('Conditional Color Tests', () => {
it('renders success rate in green for high values', () => {
it('renders success rate in green for high values', async () => {
const metrics = createTestMetrics({
tools: {
totalCalls: 10,
@@ -259,11 +264,12 @@ describe('<StatsDisplay />', () => {
byName: {},
},
});
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame, waitUntilReady } = renderWithMockedStats(metrics);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders success rate in yellow for medium values', () => {
it('renders success rate in yellow for medium values', async () => {
const metrics = createTestMetrics({
tools: {
totalCalls: 10,
@@ -279,11 +285,12 @@ describe('<StatsDisplay />', () => {
byName: {},
},
});
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame, waitUntilReady } = renderWithMockedStats(metrics);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders success rate in red for low values', () => {
it('renders success rate in red for low values', async () => {
const metrics = createTestMetrics({
tools: {
totalCalls: 10,
@@ -299,13 +306,14 @@ describe('<StatsDisplay />', () => {
byName: {},
},
});
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame, waitUntilReady } = renderWithMockedStats(metrics);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Code Changes Display', () => {
it('displays Code Changes when line counts are present', () => {
it('displays Code Changes when line counts are present', async () => {
const metrics = createTestMetrics({
tools: {
totalCalls: 1,
@@ -326,7 +334,8 @@ describe('<StatsDisplay />', () => {
},
});
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame, waitUntilReady } = renderWithMockedStats(metrics);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Code Changes:');
@@ -335,7 +344,7 @@ describe('<StatsDisplay />', () => {
expect(output).toMatchSnapshot();
});
it('hides Code Changes when no lines are added or removed', () => {
it('hides Code Changes when no lines are added or removed', async () => {
const metrics = createTestMetrics({
tools: {
totalCalls: 1,
@@ -352,7 +361,8 @@ describe('<StatsDisplay />', () => {
},
});
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame, waitUntilReady } = renderWithMockedStats(metrics);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Code Changes:');
@@ -363,15 +373,16 @@ describe('<StatsDisplay />', () => {
describe('Title Rendering', () => {
const zeroMetrics = createTestMetrics();
it('renders the default title when no title prop is provided', () => {
const { lastFrame } = renderWithMockedStats(zeroMetrics);
it('renders the default title when no title prop is provided', async () => {
const { lastFrame, waitUntilReady } = renderWithMockedStats(zeroMetrics);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Session Stats');
expect(output).not.toContain('Agent powering down');
expect(output).toMatchSnapshot();
});
it('renders the custom title when a title prop is provided', () => {
it('renders the custom title when a title prop is provided', async () => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session-id',
@@ -385,10 +396,11 @@ describe('<StatsDisplay />', () => {
startNewPrompt: vi.fn(),
});
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<StatsDisplay duration="1s" title="Agent powering down. Goodbye!" />,
{ width: 100 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('Session Stats');
@@ -397,7 +409,7 @@ describe('<StatsDisplay />', () => {
});
describe('Quota Display', () => {
it('renders quota information when quotas are provided', () => {
it('renders quota information when quotas are provided', async () => {
const now = new Date('2025-01-01T12:00:00Z');
vi.useFakeTimers();
vi.setSystemTime(now);
@@ -446,10 +458,11 @@ describe('<StatsDisplay />', () => {
startNewPrompt: vi.fn(),
});
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<StatsDisplay duration="1s" quotas={quotas} />,
{ width: 100 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Usage remaining');
@@ -460,7 +473,7 @@ describe('<StatsDisplay />', () => {
vi.useRealTimers();
});
it('renders pooled quota information for auto mode', () => {
it('renders pooled quota information for auto mode', async () => {
const now = new Date('2025-01-01T12:00:00Z');
vi.useFakeTimers();
vi.setSystemTime(now);
@@ -493,7 +506,7 @@ describe('<StatsDisplay />', () => {
startNewPrompt: vi.fn(),
});
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<StatsDisplay
duration="1s"
quotas={quotas}
@@ -505,6 +518,7 @@ describe('<StatsDisplay />', () => {
/>,
{ width: 100 },
);
await waitUntilReady();
const output = lastFrame();
// (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5%
@@ -515,7 +529,7 @@ describe('<StatsDisplay />', () => {
vi.useRealTimers();
});
it('renders quota information for unused models', () => {
it('renders quota information for unused models', async () => {
const now = new Date('2025-01-01T12:00:00Z');
vi.useFakeTimers();
vi.setSystemTime(now);
@@ -548,10 +562,11 @@ describe('<StatsDisplay />', () => {
startNewPrompt: vi.fn(),
});
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<StatsDisplay duration="1s" quotas={quotas} />,
{ width: 100 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('gemini-2.5-flash');
@@ -565,7 +580,7 @@ describe('<StatsDisplay />', () => {
});
describe('User Identity Display', () => {
it('renders User row with Auth Method and Tier', () => {
it('renders User row with Auth Method and Tier', async () => {
const metrics = createTestMetrics();
useSessionStatsMock.mockReturnValue({
@@ -580,7 +595,7 @@ describe('<StatsDisplay />', () => {
startNewPrompt: vi.fn(),
});
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<StatsDisplay
duration="1s"
selectedAuthType="oauth"
@@ -589,6 +604,7 @@ describe('<StatsDisplay />', () => {
/>,
{ width: 100 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Auth Method:');
@@ -597,7 +613,7 @@ describe('<StatsDisplay />', () => {
expect(output).toContain('Pro');
});
it('renders User row with API Key and no Tier', () => {
it('renders User row with API Key and no Tier', async () => {
const metrics = createTestMetrics();
useSessionStatsMock.mockReturnValue({
@@ -612,10 +628,11 @@ describe('<StatsDisplay />', () => {
startNewPrompt: vi.fn(),
});
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<StatsDisplay duration="1s" selectedAuthType="Google API Key" />,
{ width: 100 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Auth Method:');
@@ -69,13 +69,13 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
const renderStatusDisplay = (
const renderStatusDisplay = async (
props: { hideContextSummary: boolean } = { hideContextSummary: false },
uiState: UIState = createMockUIState(),
settings = createMockSettings(),
config = createMockConfig(),
) =>
render(
) => {
const result = render(
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
@@ -84,6 +84,9 @@ const renderStatusDisplay = (
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
await result.waitUntilReady();
return result;
};
describe('StatusDisplay', () => {
const originalEnv = process.env;
@@ -94,68 +97,77 @@ describe('StatusDisplay', () => {
vi.restoreAllMocks();
});
it('renders nothing by default if context summary is hidden via props', () => {
const { lastFrame } = renderStatusDisplay({ hideContextSummary: true });
expect(lastFrame()).toBe('');
it('renders nothing by default if context summary is hidden via props', async () => {
const { lastFrame, unmount } = await renderStatusDisplay({
hideContextSummary: true,
});
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders ContextSummaryDisplay by default', () => {
const { lastFrame } = renderStatusDisplay();
it('renders ContextSummaryDisplay by default', async () => {
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders system md indicator if env var is set', () => {
it('renders system md indicator if env var is set', async () => {
process.env['GEMINI_SYSTEM_MD'] = 'true';
const { lastFrame } = renderStatusDisplay();
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders HookStatusDisplay when hooks are active', () => {
it('renders HookStatusDisplay when hooks are active', async () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const { lastFrame } = renderStatusDisplay(
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('does NOT render HookStatusDisplay if notifications are disabled in settings', () => {
it('does NOT render HookStatusDisplay if notifications are disabled in settings', async () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const settings = createMockSettings({
hooksConfig: { notifications: false },
});
const { lastFrame } = renderStatusDisplay(
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
settings,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides ContextSummaryDisplay if configured in settings', () => {
it('hides ContextSummaryDisplay if configured in settings', async () => {
const settings = createMockSettings({
ui: { hideContextSummary: true },
});
const { lastFrame } = renderStatusDisplay(
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
undefined,
settings,
);
expect(lastFrame()).toBe('');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('passes backgroundShellCount to ContextSummaryDisplay', () => {
it('passes backgroundShellCount to ContextSummaryDisplay', async () => {
const uiState = createMockUIState({
backgroundShellCount: 3,
});
const { lastFrame } = renderStatusDisplay(
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toContain('Shells: 3');
unmount();
});
});
@@ -10,17 +10,22 @@ import { StickyHeader } from './StickyHeader.js';
import { renderWithProviders } from '../../test-utils/render.js';
describe('StickyHeader', () => {
it.each([true, false])('renders children with isFirst=%s', (isFirst) => {
const { lastFrame } = renderWithProviders(
<StickyHeader
isFirst={isFirst}
width={80}
borderColor="green"
borderDimColor={false}
>
<Text>Hello Sticky</Text>
</StickyHeader>,
);
expect(lastFrame()).toContain('Hello Sticky');
});
it.each([true, false])(
'renders children with isFirst=%s',
async (isFirst) => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<StickyHeader
isFirst={isFirst}
width={80}
borderColor="green"
borderDimColor={false}
>
<Text>Hello Sticky</Text>
</StickyHeader>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Hello Sticky');
unmount();
},
);
});
@@ -16,8 +16,8 @@ describe('SuggestionsDisplay', () => {
{ label: 'Command 3', value: 'command3', description: 'Description 3' },
];
it('renders loading state', () => {
const { lastFrame } = render(
it('renders loading state', async () => {
const { lastFrame, waitUntilReady } = render(
<SuggestionsDisplay
suggestions={[]}
activeIndex={0}
@@ -28,11 +28,12 @@ describe('SuggestionsDisplay', () => {
mode="reverse"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders nothing when empty and not loading', () => {
const { lastFrame } = render(
it('renders nothing when empty and not loading', async () => {
const { lastFrame, waitUntilReady } = render(
<SuggestionsDisplay
suggestions={[]}
activeIndex={0}
@@ -43,11 +44,12 @@ describe('SuggestionsDisplay', () => {
mode="reverse"
/>,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders suggestions list', () => {
const { lastFrame } = render(
it('renders suggestions list', async () => {
const { lastFrame, waitUntilReady } = render(
<SuggestionsDisplay
suggestions={mockSuggestions}
activeIndex={0}
@@ -58,13 +60,14 @@ describe('SuggestionsDisplay', () => {
mode="reverse"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('highlights active item', () => {
it('highlights active item', async () => {
// This test relies on visual inspection or implementation details (colors)
// For now, we just ensure it renders without error and contains the item
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<SuggestionsDisplay
suggestions={mockSuggestions}
activeIndex={1}
@@ -75,17 +78,18 @@ describe('SuggestionsDisplay', () => {
mode="reverse"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('handles scrolling', () => {
it('handles scrolling', async () => {
const manySuggestions = Array.from({ length: 20 }, (_, i) => ({
label: `Cmd ${i}`,
value: `Cmd ${i}`,
description: `Description ${i}`,
}));
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<SuggestionsDisplay
suggestions={manySuggestions}
activeIndex={10}
@@ -96,10 +100,11 @@ describe('SuggestionsDisplay', () => {
mode="reverse"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders MCP tag for MCP prompts', () => {
it('renders MCP tag for MCP prompts', async () => {
const mcpSuggestions = [
{
label: 'MCP Tool',
@@ -108,7 +113,7 @@ describe('SuggestionsDisplay', () => {
},
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<SuggestionsDisplay
suggestions={mcpSuggestions}
activeIndex={0}
@@ -119,6 +124,7 @@ describe('SuggestionsDisplay', () => {
mode="reverse"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
+18 -6
View File
@@ -9,7 +9,7 @@ import { Table } from './Table.js';
import { Text } from 'ink';
describe('Table', () => {
it('should render headers and data correctly', () => {
it('should render headers and data correctly', async () => {
const columns = [
{ key: 'id', header: 'ID', width: 5 },
{ key: 'name', header: 'Name', flexGrow: 1 },
@@ -19,7 +19,11 @@ describe('Table', () => {
{ id: 2, name: 'Bob' },
];
const { lastFrame } = render(<Table columns={columns} data={data} />, 100);
const { lastFrame, waitUntilReady } = render(
<Table columns={columns} data={data} />,
100,
);
await waitUntilReady?.();
const output = lastFrame();
expect(output).toContain('ID');
@@ -31,7 +35,7 @@ describe('Table', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('should support custom cell rendering', () => {
it('should support custom cell rendering', async () => {
const columns = [
{
key: 'value',
@@ -44,17 +48,25 @@ describe('Table', () => {
];
const data = [{ value: 10 }];
const { lastFrame } = render(<Table columns={columns} data={data} />, 100);
const { lastFrame, waitUntilReady } = render(
<Table columns={columns} data={data} />,
100,
);
await waitUntilReady?.();
const output = lastFrame();
expect(output).toContain('20');
expect(lastFrame()).toMatchSnapshot();
});
it('should handle undefined values gracefully', () => {
it('should handle undefined values gracefully', async () => {
const columns = [{ key: 'name', header: 'Name', flexGrow: 1 }];
const data: Array<{ name: string | undefined }> = [{ name: undefined }];
const { lastFrame } = render(<Table columns={columns} data={data} />, 100);
const { lastFrame, waitUntilReady } = render(
<Table columns={columns} data={data} />,
100,
);
await waitUntilReady?.();
const output = lastFrame();
expect(output).toContain('undefined');
});
@@ -30,38 +30,42 @@ describe('ThemeDialog Snapshots', () => {
vi.restoreAllMocks();
});
it('should render correctly in theme selection mode', () => {
it('should render correctly in theme selection mode', async () => {
const settings = createMockSettings();
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog {...baseProps} settings={settings} />,
{ settings },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render correctly in scope selector mode', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = renderWithProviders(
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog {...baseProps} settings={settings} />,
{ settings },
);
await waitUntilReady();
// Press Tab to switch to scope selector mode
act(() => {
await act(async () => {
stdin.write('\t');
});
// Need to wait for the state update to propagate
await new Promise((resolve) => setTimeout(resolve, 100));
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should call onCancel when ESC is pressed', async () => {
const mockOnCancel = vi.fn();
const settings = createMockSettings();
const { stdin } = renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog
{...baseProps}
onCancel={mockOnCancel}
@@ -69,33 +73,43 @@ describe('ThemeDialog Snapshots', () => {
/>,
{ settings },
);
await waitUntilReady();
act(() => {
await act(async () => {
stdin.write('\x1b');
});
// ESC key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled();
});
unmount();
});
it('should call onSelect when a theme is selected', async () => {
const settings = createMockSettings();
const { stdin } = renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog {...baseProps} settings={settings} />,
{
settings,
},
);
await waitUntilReady();
// Press Enter to select the theme
act(() => {
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
await waitFor(() => {
expect(baseProps.onSelect).toHaveBeenCalled();
});
unmount();
});
});
@@ -112,47 +126,53 @@ describe('Initial Theme Selection', () => {
vi.restoreAllMocks();
});
it('should default to a light theme when terminal background is light and no theme is set', () => {
it('should default to a light theme when terminal background is light and no theme is set', async () => {
const settings = createMockSettings(); // No theme set
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog {...baseProps} settings={settings} />,
{
settings,
uiState: { terminalBackgroundColor: '#FFFFFF' }, // Light background
},
);
await waitUntilReady();
// The snapshot will show which theme is highlighted.
// We expect 'DefaultLight' to be the one with the '>' indicator.
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should default to a dark theme when terminal background is dark and no theme is set', () => {
it('should default to a dark theme when terminal background is dark and no theme is set', async () => {
const settings = createMockSettings(); // No theme set
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog {...baseProps} settings={settings} />,
{
settings,
uiState: { terminalBackgroundColor: '#000000' }, // Dark background
},
);
await waitUntilReady();
// We expect 'DefaultDark' to be highlighted.
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should use the theme from settings even if terminal background suggests a different theme type', () => {
it('should use the theme from settings even if terminal background suggests a different theme type', async () => {
const settings = createMockSettings({ ui: { theme: 'DefaultLight' } }); // Light theme set
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog {...baseProps} settings={settings} />,
{
settings,
uiState: { terminalBackgroundColor: '#000000' }, // Dark background
},
);
await waitUntilReady();
// We expect 'DefaultLight' to be highlighted, respecting the settings.
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -165,29 +185,33 @@ describe('Hint Visibility', () => {
terminalWidth: 120,
};
it('should show hint when theme background matches terminal background', () => {
it('should show hint when theme background matches terminal background', async () => {
const settings = createMockSettings({ ui: { theme: 'Default' } });
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog {...baseProps} settings={settings} />,
{
settings,
uiState: { terminalBackgroundColor: '#1E1E2E' },
},
);
await waitUntilReady();
expect(lastFrame()).toContain('(Matches terminal)');
unmount();
});
it('should not show hint when theme background does not match terminal background', () => {
it('should not show hint when theme background does not match terminal background', async () => {
const settings = createMockSettings({ ui: { theme: 'Default' } });
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ThemeDialog {...baseProps} settings={settings} />,
{
settings,
uiState: { terminalBackgroundColor: '#FFFFFF' },
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('(Matches terminal)');
unmount();
});
});
@@ -21,9 +21,13 @@ vi.mock('../semantic-colors.js', () => ({
}));
describe('ThemedGradient', () => {
it('renders children', () => {
const { lastFrame } = render(<ThemedGradient>Hello</ThemedGradient>);
it('renders children', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ThemedGradient>Hello</ThemedGradient>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Hello');
unmount();
});
// Note: Testing actual gradient application is hard with ink-testing-library
+15 -8
View File
@@ -13,13 +13,20 @@ describe('Tips', () => {
it.each([
[0, '3. Create GEMINI.md files'],
[5, '3. /help for more information'],
])('renders correct tips when file count is %i', (count, expectedText) => {
const config = {
getGeminiMdFileCount: vi.fn().mockReturnValue(count),
} as unknown as Config;
])(
'renders correct tips when file count is %i',
async (count, expectedText) => {
const config = {
getGeminiMdFileCount: vi.fn().mockReturnValue(count),
} as unknown as Config;
const { lastFrame } = render(<Tips config={config} />);
const output = lastFrame();
expect(output).toContain(expectedText);
});
const { lastFrame, waitUntilReady, unmount } = render(
<Tips config={config} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(expectedText);
unmount();
},
);
});
@@ -101,65 +101,73 @@ describe('ToastDisplay', () => {
});
});
it('renders nothing by default', () => {
const { lastFrame } = renderToastDisplay();
expect(lastFrame()).toBe('');
it('renders nothing by default', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay();
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders Ctrl+C prompt', () => {
const { lastFrame } = renderToastDisplay({
it('renders Ctrl+C prompt', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
ctrlCPressedOnce: true,
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders warning message', () => {
const { lastFrame } = renderToastDisplay({
it('renders warning message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
transientMessage: {
text: 'This is a warning',
type: TransientMessageType.Warning,
},
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders hint message', () => {
const { lastFrame } = renderToastDisplay({
it('renders hint message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
transientMessage: {
text: 'This is a hint',
type: TransientMessageType.Hint,
},
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders Ctrl+D prompt', () => {
const { lastFrame } = renderToastDisplay({
it('renders Ctrl+D prompt', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
ctrlDPressedOnce: true,
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is empty', () => {
const { lastFrame } = renderToastDisplay({
it('renders Escape prompt when buffer is empty', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[],
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is NOT empty', () => {
const { lastFrame } = renderToastDisplay({
it('renders Escape prompt when buffer is NOT empty', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders Queue Error Message', () => {
const { lastFrame } = renderToastDisplay({
it('renders Queue Error Message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
queueErrorMessage: 'Queue Error',
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box } from 'ink';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { StreamingState, ToolCallStatus } from '../types.js';
import { StreamingState } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
@@ -57,7 +57,7 @@ describe('ToolConfirmationQueue', () => {
vi.clearAllMocks();
});
it('renders the confirming tool with progress indicator', () => {
it('renders the confirming tool with progress indicator', async () => {
const confirmingTool = {
tool: {
callId: 'call-1',
@@ -76,7 +76,7 @@ describe('ToolConfirmationQueue', () => {
total: 3,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
@@ -87,6 +87,7 @@ describe('ToolConfirmationQueue', () => {
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Action Required');
@@ -98,9 +99,10 @@ describe('ToolConfirmationQueue', () => {
const stickyHeaderProps = vi.mocked(StickyHeader).mock.calls[0][0];
expect(stickyHeaderProps.borderColor).toBe(theme.status.warning);
unmount();
});
it('returns null if tool has no confirmation details', () => {
it('returns null if tool has no confirmation details', async () => {
const confirmingTool = {
tool: {
callId: 'call-1',
@@ -112,7 +114,7 @@ describe('ToolConfirmationQueue', () => {
total: 1,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
@@ -123,8 +125,10 @@ describe('ToolConfirmationQueue', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toBe('');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders expansion hint when content is long and constrained', async () => {
@@ -149,7 +153,7 @@ describe('ToolConfirmationQueue', () => {
total: 1,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Box flexDirection="column" height={30}>
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
@@ -166,12 +170,14 @@ describe('ToolConfirmationQueue', () => {
},
},
);
await waitUntilReady();
await waitFor(() =>
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
unmount();
});
it('calculates availableContentHeight based on availableTerminalHeight from UI state', async () => {
@@ -197,7 +203,7 @@ describe('ToolConfirmationQueue', () => {
};
// Use a small availableTerminalHeight to force truncation
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
@@ -213,6 +219,7 @@ describe('ToolConfirmationQueue', () => {
},
},
);
await waitUntilReady();
// With availableTerminalHeight = 10:
// maxHeight = Math.max(10 - 1, 4) = 9
@@ -221,6 +228,7 @@ describe('ToolConfirmationQueue', () => {
// It should show truncation message
await waitFor(() => expect(lastFrame()).toContain('first 49 lines hidden'));
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('provides more height for ask_user by subtracting less overhead', async () => {
@@ -229,7 +237,7 @@ describe('ToolConfirmationQueue', () => {
callId: 'call-1',
name: 'ask_user',
description: 'ask user',
status: ToolCallStatus.Confirming,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'ask_user' as const,
questions: [
@@ -246,7 +254,7 @@ describe('ToolConfirmationQueue', () => {
total: 1,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
@@ -261,6 +269,7 @@ describe('ToolConfirmationQueue', () => {
},
},
);
await waitUntilReady();
// Calculation:
// availableTerminalHeight: 20 -> maxHeight: 19 (20-1)
@@ -273,9 +282,10 @@ describe('ToolConfirmationQueue', () => {
expect(lastFrame()).not.toContain('lines hidden');
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('does not render expansion hint when constrainHeight is false', () => {
it('does not render expansion hint when constrainHeight is false', async () => {
const longDiff = 'line\n'.repeat(50);
const confirmingTool = {
tool: {
@@ -297,7 +307,7 @@ describe('ToolConfirmationQueue', () => {
total: 1,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
@@ -311,13 +321,15 @@ describe('ToolConfirmationQueue', () => {
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Press ctrl-o to show more lines');
expect(output).toMatchSnapshot();
unmount();
});
it('renders AskUser tool confirmation with Success color', () => {
it('renders AskUser tool confirmation with Success color', async () => {
const confirmingTool = {
tool: {
callId: 'call-1',
@@ -334,7 +346,7 @@ describe('ToolConfirmationQueue', () => {
total: 1,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
@@ -345,12 +357,14 @@ describe('ToolConfirmationQueue', () => {
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
const stickyHeaderProps = vi.mocked(StickyHeader).mock.calls[0][0];
expect(stickyHeaderProps.borderColor).toBe(theme.status.success);
unmount();
});
it('renders ExitPlanMode tool confirmation with Success color', async () => {
@@ -370,7 +384,7 @@ describe('ToolConfirmationQueue', () => {
total: 1,
};
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
@@ -391,5 +405,6 @@ describe('ToolConfirmationQueue', () => {
const stickyHeaderProps = vi.mocked(StickyHeader).mock.calls[0][0];
expect(stickyHeaderProps.borderColor).toBe(theme.status.success);
unmount();
});
});
@@ -22,7 +22,7 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
const renderWithMockedStats = async (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session-id',
@@ -36,12 +36,14 @@ const renderWithMockedStats = (metrics: SessionMetrics) => {
startNewPrompt: vi.fn(),
});
return render(<ToolStatsDisplay />);
const result = render(<ToolStatsDisplay />);
await result.waitUntilReady();
return result;
};
describe('<ToolStatsDisplay />', () => {
it('should render "no tool calls" message when there are no active tools', () => {
const { lastFrame } = renderWithMockedStats({
it('should render "no tool calls" message when there are no active tools', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {},
tools: {
totalCalls: 0,
@@ -66,10 +68,11 @@ describe('<ToolStatsDisplay />', () => {
'No tool calls have been made in this session.',
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should display stats for a single tool correctly', () => {
const { lastFrame } = renderWithMockedStats({
it('should display stats for a single tool correctly', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {},
tools: {
totalCalls: 1,
@@ -106,10 +109,11 @@ describe('<ToolStatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain('test-tool');
expect(output).toMatchSnapshot();
unmount();
});
it('should display stats for multiple tools correctly', () => {
const { lastFrame } = renderWithMockedStats({
it('should display stats for multiple tools correctly', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {},
tools: {
totalCalls: 3,
@@ -159,10 +163,11 @@ describe('<ToolStatsDisplay />', () => {
expect(output).toContain('tool-a');
expect(output).toContain('tool-b');
expect(output).toMatchSnapshot();
unmount();
});
it('should handle large values without wrapping or overlapping', () => {
const { lastFrame } = renderWithMockedStats({
it('should handle large values without wrapping or overlapping', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {},
tools: {
totalCalls: 999999999,
@@ -197,10 +202,11 @@ describe('<ToolStatsDisplay />', () => {
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should handle zero decisions gracefully', () => {
const { lastFrame } = renderWithMockedStats({
it('should handle zero decisions gracefully', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {},
tools: {
totalCalls: 1,
@@ -240,5 +246,6 @@ describe('<ToolStatsDisplay />', () => {
expect(output).toContain('Overall Agreement Rate:');
expect(output).toContain('--');
expect(output).toMatchSnapshot();
unmount();
});
});
@@ -9,10 +9,12 @@ import { UpdateNotification } from './UpdateNotification.js';
import { describe, it, expect } from 'vitest';
describe('UpdateNotification', () => {
it('renders message', () => {
const { lastFrame } = render(
it('renders message', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<UpdateNotification message="Update available!" />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Update available!');
unmount();
});
});
@@ -31,7 +31,7 @@ describe('<UserIdentity />', () => {
vi.clearAllMocks();
});
it('should render login message and auth indicator', () => {
it('should render login message and auth indicator', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
@@ -39,9 +39,10 @@ describe('<UserIdentity />', () => {
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Logged in with Google: test@example.com');
@@ -49,7 +50,7 @@ describe('<UserIdentity />', () => {
unmount();
});
it('should render login message without colon if email is missing', () => {
it('should render login message without colon if email is missing', async () => {
// Modify the mock for this specific test
vi.mocked(UserAccountManager).mockImplementationOnce(
() =>
@@ -65,9 +66,10 @@ describe('<UserIdentity />', () => {
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Logged in with Google');
@@ -76,7 +78,7 @@ describe('<UserIdentity />', () => {
unmount();
});
it('should render plan name on a separate line if provided', () => {
it('should render plan name on a separate line if provided', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
@@ -84,9 +86,10 @@ describe('<UserIdentity />', () => {
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Premium Plan');
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Logged in with Google: test@example.com');
@@ -105,21 +108,22 @@ describe('<UserIdentity />', () => {
unmount();
});
it('should not render if authType is missing', () => {
it('should not render if authType is missing', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue(
{} as unknown as ContentGeneratorConfig,
);
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
await waitUntilReady();
expect(lastFrame()).toBe('');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should render non-Google auth message', () => {
it('should render non-Google auth message', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.USE_GEMINI,
@@ -127,9 +131,10 @@ describe('<UserIdentity />', () => {
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(`Authenticated with ${AuthType.USE_GEMINI}`);
@@ -67,10 +67,11 @@ describe('ValidationDialog', () => {
});
describe('initial render (choosing state)', () => {
it('should render the main message and two options', () => {
const { lastFrame, unmount } = render(
it('should render the main message and two options', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ValidationDialog onChoice={mockOnChoice} />,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'Further action is required to use this service.',
@@ -95,27 +96,31 @@ describe('ValidationDialog', () => {
unmount();
});
it('should render learn more URL when provided', () => {
const { lastFrame, unmount } = render(
it('should render learn more URL when provided', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ValidationDialog
learnMoreUrl="https://example.com/help"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Learn more:');
expect(lastFrame()).toContain('https://example.com/help');
unmount();
});
it('should call onChoice with cancel when ESCAPE is pressed', () => {
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
it('should call onChoice with cancel when ESCAPE is pressed', async () => {
const { waitUntilReady, unmount } = render(
<ValidationDialog onChoice={mockOnChoice} />,
);
await waitUntilReady();
// Verify the keypress hook is active
expect(mockKeypressOptions.isActive).toBe(true);
// Simulate ESCAPE key press
act(() => {
await act(async () => {
mockKeypressHandler({
name: 'escape',
ctrl: false,
@@ -126,6 +131,10 @@ describe('ValidationDialog', () => {
sequence: '\x1b',
});
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
expect(mockOnChoice).toHaveBeenCalledWith('cancel');
unmount();
@@ -133,42 +142,52 @@ describe('ValidationDialog', () => {
});
describe('onChoice handling', () => {
it('should call onChoice with change_auth when that option is selected', () => {
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
it('should call onChoice with change_auth when that option is selected', async () => {
const { waitUntilReady, unmount } = render(
<ValidationDialog onChoice={mockOnChoice} />,
);
await waitUntilReady();
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
act(() => {
await act(async () => {
onSelect('change_auth');
});
await waitUntilReady();
expect(mockOnChoice).toHaveBeenCalledWith('change_auth');
unmount();
});
it('should call onChoice with verify when no validation link is provided', () => {
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
it('should call onChoice with verify when no validation link is provided', async () => {
const { waitUntilReady, unmount } = render(
<ValidationDialog onChoice={mockOnChoice} />,
);
await waitUntilReady();
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
act(() => {
await act(async () => {
onSelect('verify');
});
await waitUntilReady();
expect(mockOnChoice).toHaveBeenCalledWith('verify');
unmount();
});
it('should open browser and transition to waiting state when verify is selected with a link', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
await waitUntilReady();
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
'https://accounts.google.com/verify',
@@ -182,17 +201,19 @@ describe('ValidationDialog', () => {
it('should show URL in message when browser cannot be launched', async () => {
mockShouldLaunchBrowser.mockReturnValue(false);
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
await waitUntilReady();
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Please open this URL in a browser:');
@@ -205,17 +226,19 @@ describe('ValidationDialog', () => {
it('should show error and options when browser fails to open', async () => {
mockOpenBrowserSecurely.mockRejectedValue(new Error('Browser not found'));
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
await waitUntilReady();
expect(lastFrame()).toContain('Browser not found');
// RadioButtonSelect should be rendered again with options in error state
@@ -1,8 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AdminSettingsChangedDialog > renders correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ Admin settings have changed. Please restart the session to apply new settings. Press 'r' to restart, or 'Ctrl+C'
twice to exit.
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Admin settings have changed. Please restart the session to apply new settings. Press 'r' to │
restart, or 'Ctrl+C' twice to exit.
╰──────────────────────────────────────────────────────────────────────────────────────────────────
"
`;
@@ -66,7 +66,8 @@ Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
4. /help for more information.
"
`;
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
@@ -112,7 +113,8 @@ Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
4. /help for more information.
"
`;
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
@@ -15,7 +15,8 @@ Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
4. /help for more information.
"
`;
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
@@ -33,7 +34,8 @@ Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
4. /help for more information.
"
`;
exports[`<AppHeader /> > should render the banner with default text 1`] = `
@@ -47,14 +49,15 @@ exports[`<AppHeader /> > should render the banner with default text 1`] = `
███░ ░░█████████
░░░ ░░░░░░░░░
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ This is the default banner
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
4. /help for more information.
"
`;
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
@@ -68,12 +71,13 @@ exports[`<AppHeader /> > should render the banner with warning text 1`] = `
███░ ░░█████████
░░░ ░░░░░░░░░
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ There are capacity issues
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ There are capacity issues │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information."
4. /help for more information.
"
`;
@@ -1,13 +1,31 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode 1`] = `"auto-accept edits shift+tab to manual"`;
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode 1`] = `
"auto-accept edits shift+tab to manual
"
`;
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode with plan enabled 1`] = `"auto-accept edits shift+tab to plan"`;
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode with plan enabled 1`] = `
"auto-accept edits shift+tab to plan
"
`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode 1`] = `"shift+tab to accept edits"`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode 1`] = `
"shift+tab to accept edits
"
`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode with plan enabled 1`] = `"shift+tab to accept edits"`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode with plan enabled 1`] = `
"shift+tab to accept edits
"
`;
exports[`ApprovalModeIndicator > renders correctly for PLAN mode 1`] = `"plan shift+tab to manual"`;
exports[`ApprovalModeIndicator > renders correctly for PLAN mode 1`] = `
"plan shift+tab to manual
"
`;
exports[`ApprovalModeIndicator > renders correctly for YOLO mode 1`] = `"YOLO ctrl+y"`;
exports[`ApprovalModeIndicator > renders correctly for YOLO mode 1`] = `
"YOLO ctrl+y
"
`;
@@ -7,7 +7,8 @@ exports[`AskUserDialog > Choice question placeholder > uses default placeholder
2. JavaScript
● 3. Enter a custom value
Enter to submit · Esc to cancel"
Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
@@ -17,7 +18,8 @@ exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Oth
2. JavaScript
● 3. Type another language...
Enter to submit · Esc to cancel"
Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
@@ -30,7 +32,8 @@ exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scrol
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel"
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
@@ -68,7 +71,8 @@ exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll
Description 15
16. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
@@ -77,7 +81,8 @@ exports[`AskUserDialog > Text type questions > renders text input for type: "tex
> e.g., UserProfileCard
Enter to submit · Esc to cancel"
Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = `
@@ -86,7 +91,8 @@ exports[`AskUserDialog > Text type questions > shows correct keyboard hints for
> Enter your response
Enter to submit · Esc to cancel"
Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = `
@@ -95,7 +101,8 @@ exports[`AskUserDialog > Text type questions > shows default placeholder when no
> Enter your response
Enter to submit · Esc to cancel"
Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
@@ -108,7 +115,8 @@ Review your answers:
Tests → (not answered)
Docs → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
"
`;
exports[`AskUserDialog > hides progress header for single question 1`] = `
@@ -120,7 +128,8 @@ exports[`AskUserDialog > hides progress header for single question 1`] = `
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > renders question and options 1`] = `
@@ -132,7 +141,8 @@ exports[`AskUserDialog > renders question and options 1`] = `
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = `
@@ -146,7 +156,8 @@ Which framework?
Progressive framework
3. Enter a custom value
Enter to select · ←/→ to switch questions · Esc to cancel"
Enter to select · ←/→ to switch questions · Esc to cancel
"
`;
exports[`AskUserDialog > shows keyboard hints 1`] = `
@@ -158,7 +169,8 @@ exports[`AskUserDialog > shows keyboard hints 1`] = `
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
@@ -172,7 +184,8 @@ Which database should we use?
Document database
3. Enter a custom value
Enter to select · ←/→ to switch questions · Esc to cancel"
Enter to select · ←/→ to switch questions · Esc to cancel
"
`;
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
@@ -185,5 +198,6 @@ Review your answers:
License → (not answered)
README → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
"
`;
@@ -1,56 +1,66 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
Starting server...
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List
(Focused) (Ctrl+L)
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────
│ 1: npm sta... (PID: 1003) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
1. npm start (PID: 1001)
2. tail -f log.txt (PID: 1002)
● 3. exit 0 (PID: 1003) (Exit Code: 0)
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List
(Focused) (Ctrl+L)
Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
1. npm start (PID: 1001)
2. tail -f log.txt (PID: 1002)
│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
"
`;
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server...
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────
"
`;
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
● 1. npm start (PID: 1001)
2. tail -f log.txt (PID: 1002)
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List
(Focused) (Ctrl+L)
Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
● 1. npm start (PID: 1001)
│ 2. tail -f log.txt (PID: 1002) │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────
│ 1: npm sta... (PID: 1002) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
1. npm start (PID: 1001)
● 2. tail -f log.txt (PID: 1002)
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List
(Focused) (Ctrl+L)
Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
1. npm start (PID: 1001)
│ ● 2. tail -f log.txt (PID: 1002) │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;

Some files were not shown because too many files have changed in this diff Show More