feat(cli): implement logical component tracking for visual journey testing

- Expose Ink rootNode in AppRig and render utilities for tree traversal
- Add toVisuallyContain matcher to verify component presence in the Ink DOM
- Inject component metadata as comments into generated SVGs for auditing
- Add waitForComponent to AppRig for deterministic UI state synchronization
- Implement visual journey test for SuggestionsDisplay
This commit is contained in:
Taylor Mullen
2026-03-17 17:01:04 -07:00
parent fc51e50bc6
commit daf5114aa4
7 changed files with 275 additions and 3 deletions

View File

@@ -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);
}

View File

@@ -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<R = unknown> {
toMatchSvgSnapshot(options?: {
allowEmpty?: boolean;
name?: string;
}): Promise<R>;
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(
`<!-- component: ${componentName} -->`,
);
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<string, number>();
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<void>;
toVisuallyContain(componentName: string): T;
}
}

View File

@@ -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<void>;
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 = () => {

View File

@@ -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<string>();
// 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 += ` <!-- component: ${name} -->\n`;
}
}
svg += ` </g>\n</svg>`;
return svg;
};

View File

@@ -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' });
});
});

View File

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="581" viewBox="0 0 1100 581">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="1100" height="581" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="63" y="19" fill="#ffffff" textLength="1017" lengthAdjust="spacingAndGlyphs"> Gemini CLI vtest-version </text>
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="63" y="53" fill="#ffffff" textLength="1017" lengthAdjust="spacingAndGlyphs"> Authenticated with gemini-api-key /auth </text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">Tips for getting started: </text>
<text x="0" y="138" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">1. Create GEMINI.md files to customize your interactions </text>
<text x="0" y="155" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">2. /help for more information </text>
<text x="0" y="172" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands </text>
<text x="0" y="189" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results </text>
<text x="0" y="240" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">──────────────────────────────────────────────────────────── </text>
<text x="0" y="257" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> Shift+Tab to accept edits </text>
<text x="0" y="308" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ </text>
<text x="0" y="325" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> &gt; /</text>
<rect x="36" y="323" width="9" height="17" fill="#ffffff" />
<text x="0" y="342" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ </text>
<text x="0" y="359" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> about Show version info </text>
<text x="0" y="376" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> agents Manage agents </text>
<text x="0" y="393" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> auth Manage authentication </text>
<text x="0" y="410" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> bug Submit a bug report </text>
<text x="0" y="427" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> chat Browse auto-saved conversations and ma… </text>
<text x="0" y="444" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> clear Clear the screen and conversation hist… </text>
<text x="0" y="461" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> commands Manage custom slash commands. Usage: /… </text>
<text x="0" y="478" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> compress Compresses the context by replacing it… </text>
<text x="0" y="495" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> (1/38) </text>
<text x="0" y="529" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> workspace (/directory) sandbox /model </text>
<text x="0" y="546" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> ~ no sandbox test-model </text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -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
"
`;