diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 8c62592bc6..2feaf1116e 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -693,6 +693,10 @@ export class AppRig { return stripAnsi(this.renderResult.stdout.lastFrame() || ''); } + generateSvg(): string { + return this.renderResult?.generateSvg() ?? ''; + } + async waitForOutput(pattern: string | RegExp, timeout = 30000) { await this.waitUntil( () => { @@ -708,6 +712,33 @@ export class AppRig { ); } + async waitForComponent(componentName: string, timeout = 30000) { + await this.waitUntil( + () => { + const rootNode = this.renderResult?.rootNode; + if (!rootNode) return false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const find = (node: any): boolean => { + if (node.internal_componentName === componentName) return true; + if (node.internal_testId === componentName) return true; + if (node.childNodes) { + for (const child of node.childNodes) { + if (child.nodeName !== '#text' && find(child)) return true; + } + } + return false; + }; + + return find(rootNode); + }, + { + timeout, + message: `Timed out waiting for component: ${componentName}`, + }, + ); + } + async waitForIdle(timeout = 20000) { await this.waitForOutput('Type your message', timeout); } diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts index ae9b44ee44..b8781b3715 100644 --- a/packages/cli/src/test-utils/customMatchers.ts +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -10,11 +10,81 @@ import { expect, type Assertion } from 'vitest'; import path from 'node:path'; import stripAnsi from 'strip-ansi'; import type { TextBuffer } from '../ui/components/shared/text-buffer.js'; +import { type DOMElement as _DOMElement, type DOMNode } from 'ink'; + +export interface CustomMatchers { + toMatchSvgSnapshot(options?: { + allowEmpty?: boolean; + name?: string; + }): Promise; + toVisuallyContain(componentName: string): R; + toHaveOnlyValidCharacters(): R; +} // RegExp to detect invalid characters: backspace, and ANSI escape codes // eslint-disable-next-line no-control-regex const invalidCharsRegex = /[\b\x1b]/; +/** + * Traverses the Ink tree to find a node matching a predicate. + */ +function findInTree( + node: DOMNode, + predicate: (node: DOMNode) => boolean, +): DOMNode | undefined { + if (predicate(node)) return node; + if (node.nodeName !== '#text') { + for (const child of (node as _DOMElement).childNodes) { + const found = findInTree(child, predicate); + if (found) return found; + } + } + return undefined; +} + +/** + * Checks if the Ink DOM tree contains a specific component by name or testId. + */ +export function toVisuallyContain( + this: Assertion, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + received: any, + componentName: string, +) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { isNot } = this as any; + + const rootNode = received.rootNode || received.renderResult?.rootNode; + const svg = + typeof received.generateSvg === 'function' ? received.generateSvg() : ''; + + // 1. Check logical tree presence (Automatic via Ink Root) + const isTreePresent = rootNode + ? !!findInTree(rootNode, (node) => { + if (node.nodeName === '#text') return false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const el = node as any; + const match = + el.internal_componentName === componentName || + el.internal_testId === componentName; + return match; + }) + : false; + + // 2. Check physical presence in the SVG audit trail (Fallback) + const isPhysicallyPresent = svg.includes( + ``, + ); + + const pass = isTreePresent || isPhysicallyPresent; + + return { + pass, + message: () => + `Expected component "${componentName}" ${isNot ? 'NOT ' : ''}to be present in the Ink tree or SVG metadata.`, + }; +} + const callCountByTest = new Map(); export async function toMatchSvgSnapshot( @@ -101,7 +171,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { pass, message: () => `Expected buffer ${isNot ? 'not ' : ''}to have only valid characters, but found invalid characters in lines:\n${invalidLines - .map((l) => ` [${l.line}]: "${l.content}"`) /* This line was changed */ + .map((l) => ` [${l.line}]: "${l.content}"`) .join('\n')}`, actual: buffer.lines, expected: 'Lines with no line breaks, backspaces, or escape codes.', @@ -112,6 +182,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { expect.extend({ toHaveOnlyValidCharacters, toMatchSvgSnapshot, + toVisuallyContain, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); @@ -128,5 +199,6 @@ declare module 'vitest' { allowEmpty?: boolean; name?: string; }): Promise; + toVisuallyContain(componentName: string): T; } } diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 74bac044c4..da4b3f8c35 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -8,6 +8,7 @@ import { render as inkRenderDirect, type Instance as InkInstance, type RenderOptions, + type DOMElement, } from 'ink'; import { EventEmitter } from 'node:events'; import { Box } from 'ink'; @@ -377,6 +378,7 @@ export type RenderInstance = { waitUntilReady: () => Promise; capturedOverflowState: OverflowState | undefined; capturedOverflowActions: OverflowActions | undefined; + rootNode: DOMElement; }; const instances: InkInstance[] = []; @@ -460,8 +462,14 @@ export const render = ( lastFrameRaw: stdout.lastFrameRaw, generateSvg: stdout.generateSvg, terminal: state.terminal, + get rootNode() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (instance as any).rootNode as DOMElement; + }, waitUntilReady: () => stdout.waitUntilReady(), - }; + capturedOverflowState: undefined, + capturedOverflowActions: undefined, + } as RenderInstance; }; export const cleanup = () => { diff --git a/packages/cli/src/test-utils/svg.ts b/packages/cli/src/test-utils/svg.ts index 92d3f53c2f..49871a3e88 100644 --- a/packages/cli/src/test-utils/svg.ts +++ b/packages/cli/src/test-utils/svg.ts @@ -5,9 +5,14 @@ */ import type { Terminal } from '@xterm/headless'; +import { type DOMElement } from 'ink'; -export const generateSvgForTerminal = (terminal: Terminal): string => { +export const generateSvgForTerminal = ( + terminal: Terminal, + options: { rootNode?: DOMElement } = {}, +): string => { const activeBuffer = terminal.buffer.active; + const { rootNode } = options; const getHexColor = ( isRGB: boolean, @@ -208,6 +213,24 @@ export const generateSvgForTerminal = (terminal: Terminal): string => { finalizeBlock(line.length); } + // Inject unique component names from the tree as comments for auditing + if (rootNode) { + const components = new Set(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const collect = (node: any) => { + if (node.internal_componentName) + components.add(node.internal_componentName); + if (node.internal_testId) components.add(node.internal_testId); + if (node.childNodes) { + for (const child of node.childNodes) collect(child); + } + }; + collect(rootNode); + for (const name of components) { + svg += ` \n`; + } + } + svg += ` \n`; return svg; }; diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.ux.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.ux.test.tsx new file mode 100644 index 0000000000..bf0d8928d2 --- /dev/null +++ b/packages/cli/src/ui/components/SuggestionsDisplay.ux.test.tsx @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AppRig } from '../../test-utils/AppRig.js'; +import { SuggestionsDisplay } from './SuggestionsDisplay.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe('SuggestionsDisplay UX Journey', () => { + let rig: AppRig; + + beforeEach(async () => { + const fakeResponsesPath = path.join( + __dirname, + '..', + '..', + 'test-utils', + 'fixtures', + 'simple.responses', + ); + rig = new AppRig({ fakeResponsesPath }); + await rig.initialize(); + rig.render(); + await rig.waitForIdle(); + }); + + afterEach(async () => { + await rig.unmount(); + }); + + it('should visually show the suggestions display when / is typed', async () => { + // Initially should not have suggestions + expect(rig).not.toVisuallyContain(SuggestionsDisplay.name); + + // Type '/' to trigger suggestions + await rig.type('/'); + + // Wait for SuggestionsDisplay to appear (Automatic lookup!) + await rig.waitForComponent(SuggestionsDisplay.name); + + // Assert that the component is now present in the tree + expect(rig).toVisuallyContain(SuggestionsDisplay.name); + + // Also verify text for sanity + expect(rig.lastFrame).toContain('about'); + + // Capture the state for manual inspection if needed + await expect(rig).toMatchSvgSnapshot({ name: 'suggestions-opened' }); + }); +}); diff --git a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay-ux-suggestions-opened.snap.svg b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay-ux-suggestions-opened.snap.svg new file mode 100644 index 0000000000..a23e362b46 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay-ux-suggestions-opened.snap.svg @@ -0,0 +1,44 @@ + + + + + + + + Gemini CLI vtest-version + + + + + + + Authenticated with gemini-api-key /auth + + + Tips for getting started: + 1. Create GEMINI.md files to customize your interactions + 2. /help for more information + 3. Ask coding questions, edit code or run commands + 4. Be specific for the best results + ──────────────────────────────────────────────────────────── + Shift+Tab to accept edits + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > / + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + about Show version info + agents Manage agents + auth Manage authentication + bug Submit a bug report + chat Browse auto-saved conversations and ma… + clear Clear the screen and conversation hist… + commands Manage custom slash commands. Usage: /… + compress Compresses the context by replacing it… + + (1/38) + workspace (/directory) sandbox /model + ~ no sandbox test-model + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.ux.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.ux.test.tsx.snap new file mode 100644 index 0000000000..fa3827b4f5 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.ux.test.tsx.snap @@ -0,0 +1,38 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SuggestionsDisplay UX Journey > should visually show the suggestions display when / is typed 1`] = ` +" + ▝▜▄ Gemini CLI vtest-version + ▝▜▄ + ▗▟▀ Authenticated with gemini-api-key /auth + ▝▀ + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results + + +──────────────────────────────────────────────────────────── + Shift+Tab to accept edits + + +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > / +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + about Show version info + agents Manage agents + auth Manage authentication + bug Submit a bug report + chat Browse auto-saved conversations and ma… + clear Clear the screen and conversation hist… + commands Manage custom slash commands. Usage: /… + compress Compresses the context by replacing it… + ▼ + (1/38) + workspace (/directory) sandbox /model + ~ no sandbox test-model +" +`;