mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-09 01:27:41 -07:00
Migrate core render util to use xterm.js as part of the rendering loop. (#19044)
This commit is contained in:
@@ -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');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, afterEach, expect } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { AppRig } from './AppRig.js';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -68,7 +69,11 @@ describe('AppRig', () => {
|
||||
);
|
||||
rig = new AppRig({ fakeResponsesPath });
|
||||
await rig.initialize();
|
||||
rig.render();
|
||||
await act(async () => {
|
||||
rig!.render();
|
||||
// Allow async initializations (like banners) to settle within the act boundary
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
// Wait for initial render
|
||||
await rig.waitForIdle();
|
||||
|
||||
@@ -501,7 +501,7 @@ export class AppRig {
|
||||
|
||||
get lastFrame() {
|
||||
if (!this.renderResult) return '';
|
||||
return stripAnsi(this.renderResult.lastFrame() || '');
|
||||
return stripAnsi(this.renderResult.lastFrame({ allowEmpty: true }) || '');
|
||||
}
|
||||
|
||||
getStaticOutput() {
|
||||
|
||||
@@ -13,14 +13,14 @@ import { vi } from 'vitest';
|
||||
// The version of waitFor from vitest is still fine to use if you aren't waiting
|
||||
// for React state updates.
|
||||
export async function waitFor(
|
||||
assertion: () => void,
|
||||
assertion: () => void | Promise<void>,
|
||||
{ timeout = 2000, interval = 50 } = {},
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
assertion();
|
||||
await assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (Date.now() - startTime > timeout) {
|
||||
|
||||
@@ -5,36 +5,48 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, act } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { renderHook, render } from './render.js';
|
||||
import { waitFor } from './async.js';
|
||||
|
||||
describe('render', () => {
|
||||
it('should render a component', () => {
|
||||
const { lastFrame } = render(<Text>Hello World</Text>);
|
||||
expect(lastFrame()).toBe('Hello World');
|
||||
it('should render a component', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<Text>Hello World</Text>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toBe('Hello World\n');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should support rerender', () => {
|
||||
const { lastFrame, rerender } = render(<Text>Hello</Text>);
|
||||
expect(lastFrame()).toBe('Hello');
|
||||
it('should support rerender', async () => {
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<Text>Hello</Text>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toBe('Hello\n');
|
||||
|
||||
rerender(<Text>World</Text>);
|
||||
expect(lastFrame()).toBe('World');
|
||||
await act(async () => {
|
||||
rerender(<Text>World</Text>);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toBe('World\n');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should support unmount', () => {
|
||||
const cleanup = vi.fn();
|
||||
it('should support unmount', async () => {
|
||||
const cleanupMock = vi.fn();
|
||||
function TestComponent() {
|
||||
useEffect(() => cleanup, []);
|
||||
useEffect(() => cleanupMock, []);
|
||||
return <Text>Hello</Text>;
|
||||
}
|
||||
|
||||
const { unmount } = render(<TestComponent />);
|
||||
const { unmount, waitUntilReady } = render(<TestComponent />);
|
||||
await waitUntilReady();
|
||||
unmount();
|
||||
|
||||
expect(cleanup).toHaveBeenCalled();
|
||||
expect(cleanupMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,49 +60,74 @@ describe('renderHook', () => {
|
||||
return { count, value };
|
||||
};
|
||||
|
||||
const { result, rerender } = renderHook(useTestHook, {
|
||||
initialProps: { value: 1 },
|
||||
});
|
||||
const { result, rerender, waitUntilReady, unmount } = renderHook(
|
||||
useTestHook,
|
||||
{
|
||||
initialProps: { value: 1 },
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.value).toBe(1);
|
||||
await waitFor(() => expect(result.current.count).toBe(1));
|
||||
|
||||
// Rerender with new props
|
||||
rerender({ value: 2 });
|
||||
await act(async () => {
|
||||
rerender({ value: 2 });
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(result.current.value).toBe(2);
|
||||
await waitFor(() => expect(result.current.count).toBe(2));
|
||||
|
||||
// Rerender without arguments should use previous props (value: 2)
|
||||
// This would previously crash or pass undefined if not fixed
|
||||
rerender();
|
||||
await act(async () => {
|
||||
rerender();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(result.current.value).toBe(2);
|
||||
// Count should not increase because value didn't change
|
||||
await waitFor(() => expect(result.current.count).toBe(2));
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle initial render without props', () => {
|
||||
it('should handle initial render without props', async () => {
|
||||
const useTestHook = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
return { count, increment: () => setCount((c) => c + 1) };
|
||||
};
|
||||
|
||||
const { result, rerender } = renderHook(useTestHook);
|
||||
const { result, rerender, waitUntilReady, unmount } =
|
||||
renderHook(useTestHook);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current.count).toBe(0);
|
||||
|
||||
rerender();
|
||||
await act(async () => {
|
||||
rerender();
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(result.current.count).toBe(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should update props if undefined is passed explicitly', () => {
|
||||
it('should update props if undefined is passed explicitly', async () => {
|
||||
const useTestHook = (val: string | undefined) => val;
|
||||
const { result, rerender } = renderHook(useTestHook, {
|
||||
initialProps: 'initial',
|
||||
});
|
||||
const { result, rerender, waitUntilReady, unmount } = renderHook(
|
||||
useTestHook,
|
||||
{
|
||||
initialProps: 'initial' as string | undefined,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
rerender(undefined);
|
||||
await act(async () => {
|
||||
rerender(undefined);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(result.current).toBeUndefined();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render as inkRender } from 'ink-testing-library';
|
||||
import { render as inkRenderDirect, type Instance as InkInstance } from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Box } from 'ink';
|
||||
import type React from 'react';
|
||||
import { Terminal } from '@xterm/headless';
|
||||
import { vi } from 'vitest';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { act, useState } from 'react';
|
||||
import os from 'node:os';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
@@ -40,6 +43,15 @@ import { pickDefaultThemeName } from '../ui/themes/theme.js';
|
||||
|
||||
export const persistentStateMock = new FakePersistentState();
|
||||
|
||||
if (process.env['NODE_ENV'] === 'test') {
|
||||
// We mock NODE_ENV to development during tests that use render.tsx
|
||||
// so that animations (which check process.env.NODE_ENV !== 'test')
|
||||
// are actually tested. We mutate process.env directly here because
|
||||
// vi.stubEnv() is cleared by vi.unstubAllEnvs() in test-setup.ts
|
||||
// after each test.
|
||||
process.env['NODE_ENV'] = 'development';
|
||||
}
|
||||
|
||||
vi.mock('../utils/persistentState.js', () => ({
|
||||
persistentState: persistentStateMock,
|
||||
}));
|
||||
@@ -50,51 +62,356 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({
|
||||
isITerm2: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Wrapper around ink-testing-library's render that ensures act() is called
|
||||
type TerminalState = {
|
||||
terminal: Terminal;
|
||||
cols: number;
|
||||
rows: number;
|
||||
};
|
||||
|
||||
class XtermStdout extends EventEmitter {
|
||||
private state: TerminalState;
|
||||
private pendingWrites = 0;
|
||||
private renderCount = 0;
|
||||
private queue: { promise: Promise<void> };
|
||||
isTTY = true;
|
||||
|
||||
private lastRenderOutput: string | undefined = undefined;
|
||||
private lastRenderStaticContent: string | undefined = undefined;
|
||||
|
||||
constructor(state: TerminalState, queue: { promise: Promise<void> }) {
|
||||
super();
|
||||
this.state = state;
|
||||
this.queue = queue;
|
||||
}
|
||||
|
||||
get columns() {
|
||||
return this.state.terminal.cols;
|
||||
}
|
||||
|
||||
get rows() {
|
||||
return this.state.terminal.rows;
|
||||
}
|
||||
|
||||
get frames(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
write = (data: string) => {
|
||||
this.pendingWrites++;
|
||||
this.queue.promise = this.queue.promise.then(async () => {
|
||||
await new Promise<void>((resolve) =>
|
||||
this.state.terminal.write(data, resolve),
|
||||
);
|
||||
this.pendingWrites--;
|
||||
});
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
this.state.terminal.reset();
|
||||
this.lastRenderOutput = undefined;
|
||||
this.lastRenderStaticContent = undefined;
|
||||
};
|
||||
|
||||
dispose = () => {
|
||||
this.state.terminal.dispose();
|
||||
};
|
||||
|
||||
onRender = (staticContent: string, output: string) => {
|
||||
this.renderCount++;
|
||||
this.lastRenderStaticContent = staticContent;
|
||||
this.lastRenderOutput = output;
|
||||
this.emit('render');
|
||||
};
|
||||
|
||||
lastFrame = (options: { allowEmpty?: boolean } = {}) => {
|
||||
let result: string;
|
||||
// On Windows, xterm.js headless can sometimes have timing or rendering issues
|
||||
// that lead to duplicated content or incorrect buffer state in tests.
|
||||
// As a fallback, we can trust the raw output Ink provided during onRender.
|
||||
if (os.platform() === 'win32') {
|
||||
result =
|
||||
(this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? '');
|
||||
} else {
|
||||
const buffer = this.state.terminal.buffer.active;
|
||||
const allLines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
allLines.push(buffer.getLine(i)?.translateToString(true) ?? '');
|
||||
}
|
||||
|
||||
const trimmed = [...allLines];
|
||||
while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') {
|
||||
trimmed.pop();
|
||||
}
|
||||
result = trimmed.join('\n');
|
||||
}
|
||||
|
||||
// Normalize for cross-platform snapshot stability:
|
||||
// Normalize any \r\n to \n
|
||||
const normalized = result.replace(/\r\n/g, '\n');
|
||||
|
||||
if (normalized === '' && !options.allowEmpty) {
|
||||
throw new Error(
|
||||
'lastFrame() returned an empty string. If this is intentional, use lastFrame({ allowEmpty: true }). ' +
|
||||
'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.',
|
||||
);
|
||||
}
|
||||
return normalized === '' ? normalized : normalized + '\n';
|
||||
};
|
||||
|
||||
async waitUntilReady() {
|
||||
const startRenderCount = this.renderCount;
|
||||
if (!vi.isFakeTimers()) {
|
||||
// Give Ink a chance to start its rendering loop
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
await act(async () => {
|
||||
if (vi.isFakeTimers()) {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
} else {
|
||||
// Wait for at least one render to be called if we haven't rendered yet or since start of this call,
|
||||
// but don't wait forever as some renders might be synchronous or skipped.
|
||||
if (this.renderCount === startRenderCount) {
|
||||
const renderPromise = new Promise((resolve) =>
|
||||
this.once('render', resolve),
|
||||
);
|
||||
const timeoutPromise = new Promise((resolve) =>
|
||||
setTimeout(resolve, 50),
|
||||
);
|
||||
await Promise.race([renderPromise, timeoutPromise]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50;
|
||||
|
||||
let lastCurrent = '';
|
||||
let lastExpected = '';
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
// Ensure all pending writes to the terminal are processed.
|
||||
await this.queue.promise;
|
||||
|
||||
const currentFrame = stripAnsi(
|
||||
this.lastFrame({ allowEmpty: true }),
|
||||
).trim();
|
||||
const expectedFrame = stripAnsi(
|
||||
(this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''),
|
||||
)
|
||||
.trim()
|
||||
.replace(/\r\n/g, '\n');
|
||||
|
||||
lastCurrent = currentFrame;
|
||||
lastExpected = expectedFrame;
|
||||
|
||||
const isMatch = () => {
|
||||
if (expectedFrame === '...') {
|
||||
return currentFrame !== '';
|
||||
}
|
||||
|
||||
// If both are empty, it's a match.
|
||||
// We consider undefined lastRenderOutput as effectively empty for this check
|
||||
// to support hook testing where Ink may skip rendering completely.
|
||||
if (
|
||||
(this.lastRenderOutput === undefined || expectedFrame === '') &&
|
||||
currentFrame === ''
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.lastRenderOutput === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match.
|
||||
if (expectedFrame === '' || currentFrame === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the current frame contains the expected content.
|
||||
// We use includes because xterm might have some formatting or
|
||||
// extra whitespace that Ink doesn't account for in its raw output metrics.
|
||||
return currentFrame.includes(expectedFrame);
|
||||
};
|
||||
|
||||
if (this.pendingWrites === 0 && isMatch()) {
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
await act(async () => {
|
||||
if (vi.isFakeTimers()) {
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`waitUntilReady() timed out after ${maxAttempts} attempts.\n` +
|
||||
`Expected content (stripped ANSI):\n"${lastExpected}"\n` +
|
||||
`Actual content (stripped ANSI):\n"${lastCurrent}"\n` +
|
||||
`Pending writes: ${this.pendingWrites}\n` +
|
||||
`Render count: ${this.renderCount}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XtermStderr extends EventEmitter {
|
||||
private state: TerminalState;
|
||||
private pendingWrites = 0;
|
||||
private queue: { promise: Promise<void> };
|
||||
isTTY = true;
|
||||
|
||||
constructor(state: TerminalState, queue: { promise: Promise<void> }) {
|
||||
super();
|
||||
this.state = state;
|
||||
this.queue = queue;
|
||||
}
|
||||
|
||||
write = (data: string) => {
|
||||
this.pendingWrites++;
|
||||
this.queue.promise = this.queue.promise.then(async () => {
|
||||
await new Promise<void>((resolve) =>
|
||||
this.state.terminal.write(data, resolve),
|
||||
);
|
||||
this.pendingWrites--;
|
||||
});
|
||||
};
|
||||
|
||||
dispose = () => {
|
||||
this.state.terminal.dispose();
|
||||
};
|
||||
|
||||
lastFrame = () => '';
|
||||
}
|
||||
|
||||
class XtermStdin extends EventEmitter {
|
||||
isTTY = true;
|
||||
data: string | null = null;
|
||||
constructor(options: { isTTY?: boolean } = {}) {
|
||||
super();
|
||||
this.isTTY = options.isTTY ?? true;
|
||||
}
|
||||
|
||||
write = (data: string) => {
|
||||
this.data = data;
|
||||
this.emit('readable');
|
||||
this.emit('data', data);
|
||||
};
|
||||
|
||||
setEncoding() {}
|
||||
setRawMode() {}
|
||||
resume() {}
|
||||
pause() {}
|
||||
ref() {}
|
||||
unref() {}
|
||||
|
||||
read = () => {
|
||||
const { data } = this;
|
||||
this.data = null;
|
||||
return data;
|
||||
};
|
||||
}
|
||||
|
||||
export type RenderInstance = {
|
||||
rerender: (tree: React.ReactElement) => void;
|
||||
unmount: () => void;
|
||||
cleanup: () => void;
|
||||
stdout: XtermStdout;
|
||||
stderr: XtermStderr;
|
||||
stdin: XtermStdin;
|
||||
frames: string[];
|
||||
lastFrame: (options?: { allowEmpty?: boolean }) => string;
|
||||
terminal: Terminal;
|
||||
waitUntilReady: () => Promise<void>;
|
||||
};
|
||||
|
||||
const instances: InkInstance[] = [];
|
||||
|
||||
// Wrapper around ink's render that ensures act() is called and uses Xterm for output
|
||||
export const render = (
|
||||
tree: React.ReactElement,
|
||||
terminalWidth?: number,
|
||||
): ReturnType<typeof inkRender> => {
|
||||
let renderResult: ReturnType<typeof inkRender> =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
undefined as unknown as ReturnType<typeof inkRender>;
|
||||
act(() => {
|
||||
renderResult = inkRender(tree);
|
||||
): RenderInstance => {
|
||||
const cols = terminalWidth ?? 100;
|
||||
const rows = 40;
|
||||
const terminal = new Terminal({
|
||||
cols,
|
||||
rows,
|
||||
allowProposedApi: true,
|
||||
convertEol: true,
|
||||
});
|
||||
|
||||
if (terminalWidth !== undefined && renderResult?.stdout) {
|
||||
// Override the columns getter on the stdout instance provided by ink-testing-library
|
||||
Object.defineProperty(renderResult.stdout, 'columns', {
|
||||
get: () => terminalWidth,
|
||||
configurable: true,
|
||||
});
|
||||
const state: TerminalState = {
|
||||
terminal,
|
||||
cols,
|
||||
rows,
|
||||
};
|
||||
const writeQueue = { promise: Promise.resolve() };
|
||||
const stdout = new XtermStdout(state, writeQueue);
|
||||
const stderr = new XtermStderr(state, writeQueue);
|
||||
const stdin = new XtermStdin();
|
||||
|
||||
// Trigger a rerender so Ink can pick up the new terminal width
|
||||
act(() => {
|
||||
renderResult.rerender(tree);
|
||||
let instance!: InkInstance;
|
||||
stdout.clear();
|
||||
act(() => {
|
||||
instance = inkRenderDirect(tree, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
debug: false,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
onRender: (metrics: { output: string; staticOutput?: string }) => {
|
||||
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const originalUnmount = renderResult.unmount;
|
||||
const originalRerender = renderResult.rerender;
|
||||
instances.push(instance);
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
originalUnmount();
|
||||
});
|
||||
},
|
||||
rerender: (newTree: React.ReactElement) => {
|
||||
act(() => {
|
||||
originalRerender(newTree);
|
||||
stdout.clear();
|
||||
instance.rerender(newTree);
|
||||
});
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
instance.unmount();
|
||||
});
|
||||
stdout.dispose();
|
||||
stderr.dispose();
|
||||
},
|
||||
cleanup: instance.cleanup,
|
||||
stdout,
|
||||
stderr,
|
||||
stdin,
|
||||
frames: stdout.frames,
|
||||
lastFrame: stdout.lastFrame,
|
||||
terminal: state.terminal,
|
||||
waitUntilReady: () => stdout.waitUntilReady(),
|
||||
};
|
||||
};
|
||||
|
||||
export const cleanup = () => {
|
||||
for (const instance of instances) {
|
||||
act(() => {
|
||||
instance.unmount();
|
||||
});
|
||||
instance.cleanup();
|
||||
}
|
||||
instances.length = 0;
|
||||
};
|
||||
|
||||
export const simulateClick = async (
|
||||
stdin: ReturnType<typeof inkRender>['stdin'],
|
||||
stdin: XtermStdin,
|
||||
col: number,
|
||||
row: number,
|
||||
button: 0 | 1 | 2 = 0, // 0 for left, 1 for middle, 2 for right
|
||||
@@ -151,7 +468,7 @@ export const mockSettings = new LoadedSettings(
|
||||
const baseMockUiState = {
|
||||
renderMarkdown: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
terminalWidth: 120,
|
||||
terminalWidth: 100,
|
||||
terminalHeight: 40,
|
||||
currentModel: 'gemini-pro',
|
||||
terminalBackgroundColor: 'black',
|
||||
@@ -258,7 +575,13 @@ export const renderWithProviders = (
|
||||
};
|
||||
appState?: AppState;
|
||||
} = {},
|
||||
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
|
||||
): RenderInstance & {
|
||||
simulateClick: (
|
||||
col: number,
|
||||
row: number,
|
||||
button?: 0 | 1 | 2,
|
||||
) => Promise<void>;
|
||||
} => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const baseState: UIState = new Proxy(
|
||||
{ ...baseMockUiState, ...providedUiState },
|
||||
@@ -377,7 +700,11 @@ export const renderWithProviders = (
|
||||
terminalWidth,
|
||||
);
|
||||
|
||||
return { ...renderResult, simulateClick };
|
||||
return {
|
||||
...renderResult,
|
||||
simulateClick: (col: number, row: number, button?: 0 | 1 | 2) =>
|
||||
simulateClick(renderResult.stdin, col, row, button),
|
||||
};
|
||||
};
|
||||
|
||||
export function renderHook<Result, Props>(
|
||||
@@ -390,6 +717,7 @@ export function renderHook<Result, Props>(
|
||||
result: { current: Result };
|
||||
rerender: (props?: Props) => void;
|
||||
unmount: () => void;
|
||||
waitUntilReady: () => Promise<void>;
|
||||
} {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result = { current: undefined as unknown as Result };
|
||||
@@ -411,6 +739,7 @@ export function renderHook<Result, Props>(
|
||||
|
||||
let inkRerender: (tree: React.ReactElement) => void = () => {};
|
||||
let unmount: () => void = () => {};
|
||||
let waitUntilReady: () => Promise<void> = async () => {};
|
||||
|
||||
act(() => {
|
||||
const renderResult = render(
|
||||
@@ -420,6 +749,7 @@ export function renderHook<Result, Props>(
|
||||
);
|
||||
inkRerender = renderResult.rerender;
|
||||
unmount = renderResult.unmount;
|
||||
waitUntilReady = renderResult.waitUntilReady;
|
||||
});
|
||||
|
||||
function rerender(props?: Props) {
|
||||
@@ -436,7 +766,7 @@ export function renderHook<Result, Props>(
|
||||
});
|
||||
}
|
||||
|
||||
return { result, rerender, unmount };
|
||||
return { result, rerender, unmount, waitUntilReady };
|
||||
}
|
||||
|
||||
export function renderHookWithProviders<Result, Props>(
|
||||
@@ -457,6 +787,7 @@ export function renderHookWithProviders<Result, Props>(
|
||||
result: { current: Result };
|
||||
rerender: (props?: Props) => void;
|
||||
unmount: () => void;
|
||||
waitUntilReady: () => Promise<void>;
|
||||
} {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result = { current: undefined as unknown as Result };
|
||||
@@ -506,5 +837,6 @@ export function renderHookWithProviders<Result, Props>(
|
||||
renderResult.unmount();
|
||||
});
|
||||
},
|
||||
waitUntilReady: () => renderResult.waitUntilReady(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-4
@@ -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. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
+4
-2
@@ -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
Reference in New Issue
Block a user