Compare commits

...

3 Commits

Author SHA1 Message Date
Taylor Mullen 85581fd3e1 refactor(cli): address code review feedback for visual journey testing
- Replace 'any' with 'TestableDOMNode' for Ink tree traversal in AppRig and custom matchers
- Rename 'toVisuallyContain' to 'toContainComponent' for clarity
- Remove SVG audit trail (component injection) to prevent snapshot churn
2026-03-31 17:00:41 -07:00
Taylor Mullen 05bf04c852 feat(cli): implement logical component tracking for visual journey testing
- Enhance AppRig and matchers to support robust component discovery via node attributes and styles

- Update SuggestionsDisplay to support logical component tagging

- Fix act() warnings and stability issues in SuggestionsDisplay tests

- Refresh snapshots and rebase on origin/main
2026-03-31 16:46:46 -07:00
Taylor Mullen 800aea6cfb 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
2026-03-31 14:06:47 -07:00
11 changed files with 314 additions and 14 deletions
+1 -1
View File
@@ -17551,7 +17551,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.5.0",
"ink": "npm:@jrichman/ink@6.6.2",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+1 -1
View File
@@ -49,7 +49,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.5.0",
"ink": "npm:@jrichman/ink@6.6.2",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+56
View File
@@ -705,6 +705,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(
() => {
@@ -720,6 +724,58 @@ export class AppRig {
);
}
async waitForComponent(componentName: string, timeout = 30000) {
await this.waitUntil(
() => {
const rootNode = this.renderResult?.rootNode;
if (!rootNode) return false;
type TestableDOMNode = import('ink').DOMNode & {
internal_componentName?: string;
internal_testId?: string;
attributes?: {
internal_componentName?: string;
internal_testId?: string;
};
style?: {
internal_componentName?: string;
internal_testId?: string;
};
};
const find = (node: TestableDOMNode): boolean => {
if (
node.internal_componentName === componentName ||
node.internal_testId === componentName ||
node.attributes?.internal_testId === componentName ||
node.attributes?.internal_componentName === componentName ||
node.style?.internal_testId === componentName ||
node.style?.internal_componentName === componentName
) {
return true;
}
if ('childNodes' in node && node.childNodes) {
for (const child of node.childNodes) {
if (
child.nodeName !== '#text' &&
find(child as TestableDOMNode)
) {
return true;
}
}
}
return false;
};
return find(rootNode as TestableDOMNode);
},
{
timeout,
message: `Timed out waiting for component: ${componentName}\nLast frame:\n${this.lastFrame}`,
},
);
}
async waitForIdle(timeout = 20000) {
await this.waitForOutput('Type your message', timeout);
}
+89 -5
View File
@@ -10,11 +10,87 @@ 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>;
toContainComponent(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]/;
type TestableDOMNode = DOMNode & {
internal_componentName?: string;
internal_testId?: string;
attributes?: {
internal_componentName?: string;
internal_testId?: string;
};
style?: {
internal_componentName?: string;
internal_testId?: string;
};
};
/**
* Traverses the Ink tree to find a node matching a predicate.
*/
function findInTree(
node: TestableDOMNode,
predicate: (node: TestableDOMNode) => boolean,
): TestableDOMNode | undefined {
if (predicate(node)) return node;
if ('childNodes' in node && node.childNodes) {
for (const child of node.childNodes) {
const found = findInTree(child as TestableDOMNode, predicate);
if (found) return found;
}
}
return undefined;
}
/**
* Checks if the Ink DOM tree contains a specific component by name or testId.
*/
export function toContainComponent(
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;
// Check logical tree presence (Automatic via Ink Root)
const pass = rootNode
? !!findInTree(rootNode as TestableDOMNode, (node) => {
if (node.nodeName === '#text') return false;
const match =
node.internal_componentName === componentName ||
node.internal_testId === componentName ||
node.attributes?.internal_componentName === componentName ||
node.attributes?.internal_testId === componentName ||
node.style?.internal_componentName === componentName ||
node.style?.internal_testId === componentName;
return match;
})
: false;
return {
pass,
message: () =>
`Expected component "${componentName}" ${isNot ? 'NOT ' : ''}to be present in the Ink tree.`,
};
}
const callCountByTest = new Map<string, number>();
export async function toMatchSvgSnapshot(
@@ -37,11 +113,17 @@ export async function toMatchSvgSnapshot(
let textContent: string;
if (renderInstance.lastFrameRaw) {
textContent = renderInstance.lastFrameRaw({
allowEmpty: options?.allowEmpty,
});
textContent =
typeof renderInstance.lastFrameRaw === 'function'
? renderInstance.lastFrameRaw({
allowEmpty: options?.allowEmpty,
})
: renderInstance.lastFrameRaw;
} else if (renderInstance.lastFrame) {
textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty });
textContent =
typeof renderInstance.lastFrame === 'function'
? renderInstance.lastFrame({ allowEmpty: options?.allowEmpty })
: renderInstance.lastFrame;
} else {
throw new Error(
'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame',
@@ -101,7 +183,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.',
@@ -111,6 +193,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
expect.extend({
toHaveOnlyValidCharacters,
toMatchSvgSnapshot,
toContainComponent,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
@@ -127,5 +210,6 @@ declare module 'vitest' {
allowEmpty?: boolean;
name?: string;
}): Promise<void>;
toContainComponent(componentName: string): T;
}
}
+8 -1
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';
@@ -374,6 +375,7 @@ export type RenderInstance = {
waitUntilReady: () => Promise<void>;
capturedOverflowState: OverflowState | undefined;
capturedOverflowActions: OverflowActions | undefined;
rootNode: DOMElement;
};
export type RenderWithProvidersInstance = RenderInstance & {
@@ -464,8 +466,13 @@ export const render = async (
lastFrameRaw: stdout.lastFrameRaw,
generateSvg: stdout.generateSvg,
terminal: state.terminal,
get rootNode() {
return (instance as InkInstance & { rootNode: DOMElement }).rootNode;
},
waitUntilReady: () => stdout.waitUntilReady(),
};
capturedOverflowState: undefined,
capturedOverflowActions: undefined,
} as RenderInstance;
};
export const cleanup = () => {
@@ -80,7 +80,13 @@ export function SuggestionsDisplay({
mode === 'slash' ? Math.min(maxLabelLength, Math.floor(width * 0.5)) : 0;
return (
<Box flexDirection="column" paddingX={1} width={width}>
<Box
flexDirection="column"
paddingX={1}
width={width}
// @ts-expect-error - internal_testId is used for testing component presence
internal_testId={SuggestionsDisplay.name}
>
{scrollOffset > 0 && <Text color={theme.text.primary}></Text>}
{visibleSuggestions.map((suggestion, index) => {
@@ -0,0 +1,58 @@
/**
* @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();
await rig.render();
await rig.waitForIdle();
// Allow async command loading to settle
await new Promise((resolve) => setTimeout(resolve, 500));
});
afterEach(async () => {
await rig.unmount();
});
it('should visually show the suggestions display when / is typed', async () => {
// Initially should not have suggestions
expect(rig).not.toContainComponent(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).toContainComponent(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' });
});
});
@@ -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="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="19" fill="#ffffff" textLength="1026" lengthAdjust="spacingAndGlyphs"> Gemini CLI vtest-version </text>
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="53" fill="#ffffff" textLength="1026" lengthAdjust="spacingAndGlyphs"> Authenticated with gemini-api-key /auth </text>
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" 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="257" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">──────────────────────────────────────────────────────────── </text>
<text x="0" y="274" 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"> compress Compresses the context by replacing it… </text>
<text x="0" y="376" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> directory Manage workspace directories </text>
<text x="0" y="393" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> footer Configure which items appear in the fo… </text>
<text x="0" y="410" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> quit Exit the cli </text>
<text x="0" y="427" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> stats Check session stats. Usage: /stats [se… </text>
<text x="0" y="444" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> tasks Toggle background tasks view </text>
<text x="0" y="461" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> about Show version info </text>
<text x="0" y="478" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> agents Manage agents </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

@@ -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
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> /
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
compress Compresses the context by replacing it…
directory Manage workspace directories
footer Configure which items appear in the fo…
quit Exit the cli
stats Check session stats. Usage: /stats [se…
tasks Toggle background tasks view
about Show version info
agents Manage agents
(1/38)
workspace (/directory) sandbox /model
~ no sandbox test-model
"
`;
@@ -9,13 +9,14 @@ import { useCompletion } from './useCompletion.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
function useDebouncedValue<T>(value: T, delay = 200): T {
function useDebouncedValue<T>(value: T, delay = 200, enabled = true): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
if (!enabled) return;
const handle = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handle);
}, [value, delay]);
return debounced;
}, [value, delay, enabled]);
return enabled ? debounced : value;
}
export interface UseReverseSearchCompletionReturn {
@@ -48,7 +49,11 @@ export function useReverseSearchCompletion(
setVisibleStartIndex,
} = useCompletion();
const debouncedQuery = useDebouncedValue(buffer.text, 100);
const debouncedQuery = useDebouncedValue(
buffer.text,
100,
reverseSearchActive,
);
// incremental search
const prevQueryRef = useRef<string>('');
+3 -1
View File
@@ -72,7 +72,9 @@ beforeEach(() => {
if (
relevantStack.includes('OverflowContext.tsx') ||
relevantStack.includes('useTimedMessage.ts')
relevantStack.includes('useTimedMessage.ts') ||
relevantStack.includes('useSlashCompletion.ts') ||
relevantStack.includes('slashCommandProcessor.ts')
) {
return;
}