test: support tests that include color information (#20220)

This commit is contained in:
Jacob Richman
2026-02-25 15:31:35 -08:00
committed by GitHub
parent 78dfe9dea8
commit f9f916e1dc
68 changed files with 2342 additions and 492 deletions

View File

@@ -6,20 +6,78 @@
/// <reference types="vitest/globals" />
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Assertion } from 'vitest';
import { expect } from 'vitest';
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';
// RegExp to detect invalid characters: backspace, and ANSI escape codes
// eslint-disable-next-line no-control-regex
const invalidCharsRegex = /[\b\x1b]/;
const callCountByTest = new Map<string, number>();
export async function toMatchSvgSnapshot(
this: Assertion,
renderInstance: {
lastFrameRaw?: (options?: { allowEmpty?: boolean }) => string;
lastFrame?: (options?: { allowEmpty?: boolean }) => string;
generateSvg: () => string;
},
options?: { allowEmpty?: boolean; name?: string },
) {
const currentTestName = expect.getState().currentTestName;
if (!currentTestName) {
throw new Error('toMatchSvgSnapshot must be called within a test');
}
const testPath = expect.getState().testPath;
if (!testPath) {
throw new Error('toMatchSvgSnapshot requires testPath');
}
let textContent: string;
if (renderInstance.lastFrameRaw) {
textContent = renderInstance.lastFrameRaw({
allowEmpty: options?.allowEmpty,
});
} else if (renderInstance.lastFrame) {
textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty });
} else {
throw new Error(
'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame',
);
}
const svgContent = renderInstance.generateSvg();
const sanitize = (name: string) =>
name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-');
const testId = testPath + ':' + currentTestName;
let count = callCountByTest.get(testId) ?? 0;
count++;
callCountByTest.set(testId, count);
const snapshotName =
options?.name ??
(count > 1 ? `${currentTestName}-${count}` : currentTestName);
const svgFileName =
sanitize(path.basename(testPath).replace(/\.test\.tsx?$/, '')) +
'-' +
sanitize(snapshotName) +
'.snap.svg';
const svgDir = path.join(path.dirname(testPath), '__snapshots__');
const svgFilePath = path.join(svgDir, svgFileName);
// Assert the text matches standard snapshot, stripping ANSI for stability
expect(stripAnsi(textContent)).toMatchSnapshot();
// Assert the SVG matches the file snapshot
await expect(svgContent).toMatchFileSnapshot(svgFilePath);
return { pass: true, message: () => '' };
}
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
const { isNot } = this as any;
@@ -53,15 +111,22 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect.extend({
toHaveOnlyValidCharacters,
toMatchSvgSnapshot,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
// Extend Vitest's `expect` interface with the custom matcher's type definition.
declare module 'vitest' {
interface Assertion<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type
interface Assertion<T = any> extends CustomMatchers<T> {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface AsymmetricMatchersContaining extends CustomMatchers {}
interface CustomMatchers<T = unknown> {
toHaveOnlyValidCharacters(): T;
}
interface AsymmetricMatchersContaining {
toHaveOnlyValidCharacters(): void;
toMatchSvgSnapshot(options?: {
allowEmpty?: boolean;
name?: string;
}): Promise<void>;
}
}

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import stripAnsi from 'strip-ansi';
import { format } from 'node:util';
export function createMockDebugLogger(options: { stripAnsi?: boolean } = {}) {
const emitConsoleLog = vi.fn();
const debugLogger = {
log: vi.fn((message: unknown, ...args: unknown[]) => {
let formatted =
typeof message === 'string' ? format(message, ...args) : message;
if (options.stripAnsi && typeof formatted === 'string') {
formatted = stripAnsi(formatted);
}
emitConsoleLog('log', formatted);
}),
error: vi.fn((message: unknown, ...args: unknown[]) => {
let formatted =
typeof message === 'string' ? format(message, ...args) : message;
if (options.stripAnsi && typeof formatted === 'string') {
formatted = stripAnsi(formatted);
}
emitConsoleLog('error', formatted);
}),
warn: vi.fn((message: unknown, ...args: unknown[]) => {
let formatted =
typeof message === 'string' ? format(message, ...args) : message;
if (options.stripAnsi && typeof formatted === 'string') {
formatted = stripAnsi(formatted);
}
emitConsoleLog('warn', formatted);
}),
debug: vi.fn(),
info: vi.fn(),
};
return { emitConsoleLog, debugLogger };
}
/**
* A helper specifically designed for `vi.mock('@google/gemini-cli-core', ...)` to easily
* mock both `debugLogger` and `coreEvents.emitConsoleLog`.
*
* Example:
* ```typescript
* vi.mock('@google/gemini-cli-core', async (importOriginal) => {
* const { mockCoreDebugLogger } = await import('../../test-utils/mockDebugLogger.js');
* return mockCoreDebugLogger(
* await importOriginal<typeof import('@google/gemini-cli-core')>(),
* { stripAnsi: true }
* );
* });
* ```
*/
export function mockCoreDebugLogger<T extends Record<string, unknown>>(
actual: T,
options?: { stripAnsi?: boolean },
): T {
const { emitConsoleLog, debugLogger } = createMockDebugLogger(options);
return {
...actual,
coreEvents: {
...(typeof actual['coreEvents'] === 'object' &&
actual['coreEvents'] !== null
? actual['coreEvents']
: {}),
emitConsoleLog,
},
debugLogger,
} as T;
}

View File

@@ -51,6 +51,7 @@ import { SessionStatsProvider } from '../ui/contexts/SessionContext.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { DefaultLight } from '../ui/themes/default-light.js';
import { pickDefaultThemeName } from '../ui/themes/theme.js';
import { generateSvgForTerminal } from './svg.js';
export const persistentStateMock = new FakePersistentState();
@@ -105,7 +106,12 @@ class XtermStdout extends EventEmitter {
private queue: { promise: Promise<void> };
isTTY = true;
getColorDepth(): number {
return 24;
}
private lastRenderOutput: string | undefined = undefined;
private lastRenderStaticContent: string | undefined = undefined;
constructor(state: TerminalState, queue: { promise: Promise<void> }) {
super();
@@ -138,6 +144,7 @@ class XtermStdout extends EventEmitter {
clear = () => {
this.state.terminal.reset();
this.lastRenderOutput = undefined;
this.lastRenderStaticContent = undefined;
};
dispose = () => {
@@ -146,10 +153,32 @@ class XtermStdout extends EventEmitter {
onRender = (staticContent: string, output: string) => {
this.renderCount++;
this.lastRenderStaticContent = staticContent;
this.lastRenderOutput = output;
this.emit('render');
};
private normalizeFrame = (text: string): string =>
text.replace(/\r\n/g, '\n');
generateSvg = (): string => generateSvgForTerminal(this.state.terminal);
lastFrameRaw = (options: { allowEmpty?: boolean } = {}) => {
const result =
(this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? '');
const normalized = this.normalizeFrame(result);
if (normalized === '' && !options.allowEmpty) {
throw new Error(
'lastFrameRaw() returned an empty string. If this is intentional, use lastFrameRaw({ allowEmpty: true }). ' +
'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.',
);
}
return normalized;
};
lastFrame = (options: { allowEmpty?: boolean } = {}) => {
const buffer = this.state.terminal.buffer.active;
const allLines: string[] = [];
@@ -163,9 +192,7 @@ class XtermStdout extends EventEmitter {
}
const result = trimmed.join('\n');
// Normalize for cross-platform snapshot stability:
// Normalize any \r\n to \n
const normalized = result.replace(/\r\n/g, '\n');
const normalized = this.normalizeFrame(result);
if (normalized === '' && !options.allowEmpty) {
throw new Error(
@@ -213,9 +240,11 @@ class XtermStdout extends EventEmitter {
const currentFrame = stripAnsi(
this.lastFrame({ allowEmpty: true }),
).trim();
const expectedFrame = stripAnsi(this.lastRenderOutput ?? '')
.trim()
.replace(/\r\n/g, '\n');
const expectedFrame = this.normalizeFrame(
stripAnsi(
(this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''),
),
).trim();
lastCurrent = currentFrame;
lastExpected = expectedFrame;
@@ -340,6 +369,8 @@ export type RenderInstance = {
stdin: XtermStdin;
frames: string[];
lastFrame: (options?: { allowEmpty?: boolean }) => string;
lastFrameRaw: (options?: { allowEmpty?: boolean }) => string;
generateSvg: () => string;
terminal: Terminal;
waitUntilReady: () => Promise<void>;
capturedOverflowState: OverflowState | undefined;
@@ -424,6 +455,8 @@ export const render = (
stdin,
frames: stdout.frames,
lastFrame: stdout.lastFrame,
lastFrameRaw: stdout.lastFrameRaw,
generateSvg: stdout.generateSvg,
terminal: state.terminal,
waitUntilReady: () => stdout.waitUntilReady(),
};
@@ -767,6 +800,7 @@ export function renderHook<Result, Props>(
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
generateSvg: () => string;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
@@ -789,6 +823,7 @@ export function renderHook<Result, Props>(
let inkRerender: (tree: React.ReactElement) => void = () => {};
let unmount: () => void = () => {};
let waitUntilReady: () => Promise<void> = async () => {};
let generateSvg: () => string = () => '';
act(() => {
const renderResult = render(
@@ -799,6 +834,7 @@ export function renderHook<Result, Props>(
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
generateSvg = renderResult.generateSvg;
});
function rerender(props?: Props) {
@@ -815,7 +851,7 @@ export function renderHook<Result, Props>(
});
}
return { result, rerender, unmount, waitUntilReady };
return { result, rerender, unmount, waitUntilReady, generateSvg };
}
export function renderHookWithProviders<Result, Props>(
@@ -837,6 +873,7 @@ export function renderHookWithProviders<Result, Props>(
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
generateSvg: () => string;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
@@ -887,5 +924,6 @@ export function renderHookWithProviders<Result, Props>(
});
},
waitUntilReady: () => renderResult.waitUntilReady(),
generateSvg: () => renderResult.generateSvg(),
};
}

View File

@@ -0,0 +1,190 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Terminal } from '@xterm/headless';
export const generateSvgForTerminal = (terminal: Terminal): string => {
const activeBuffer = terminal.buffer.active;
const getHexColor = (
isRGB: boolean,
isPalette: boolean,
isDefault: boolean,
colorCode: number,
): string | null => {
if (isDefault) return null;
if (isRGB) {
return `#${colorCode.toString(16).padStart(6, '0')}`;
}
if (isPalette) {
if (colorCode >= 0 && colorCode <= 15) {
return (
[
'#000000',
'#cd0000',
'#00cd00',
'#cdcd00',
'#0000ee',
'#cd00cd',
'#00cdcd',
'#e5e5e5',
'#7f7f7f',
'#ff0000',
'#00ff00',
'#ffff00',
'#5c5cff',
'#ff00ff',
'#00ffff',
'#ffffff',
][colorCode] || null
);
} else if (colorCode >= 16 && colorCode <= 231) {
const v = [0, 95, 135, 175, 215, 255];
const c = colorCode - 16;
const b = v[c % 6];
const g = v[Math.floor(c / 6) % 6];
const r = v[Math.floor(c / 36) % 6];
return `#${[r, g, b].map((x) => x?.toString(16).padStart(2, '0')).join('')}`;
} else if (colorCode >= 232 && colorCode <= 255) {
const gray = 8 + (colorCode - 232) * 10;
const hex = gray.toString(16).padStart(2, '0');
return `#${hex}${hex}${hex}`;
}
}
return null;
};
const escapeXml = (unsafe: string): string =>
// eslint-disable-next-line no-control-regex
unsafe.replace(/[<>&'"\x00-\x08\x0B-\x0C\x0E-\x1F]/g, (c) => {
switch (c) {
case '<':
return '&lt;';
case '>':
return '&gt;';
case '&':
return '&amp;';
case "'":
return '&apos;';
case '"':
return '&quot;';
default:
return '';
}
});
const charWidth = 9;
const charHeight = 17;
const padding = 10;
// Find the actual number of rows with content to avoid rendering trailing blank space.
let contentRows = terminal.rows;
for (let y = terminal.rows - 1; y >= 0; y--) {
const line = activeBuffer.getLine(y);
if (line && line.translateToString(true).trim().length > 0) {
contentRows = y + 1;
break;
}
}
if (contentRows === 0) contentRows = 1; // Minimum 1 row
const width = terminal.cols * charWidth + padding * 2;
const height = contentRows * charHeight + padding * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
`;
svg += ` <style>
`;
svg += ` text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
`;
svg += ` </style>
`;
svg += ` <rect width="${width}" height="${height}" fill="#000000" />
`; // Terminal background
svg += ` <g transform="translate(${padding}, ${padding})">
`;
for (let y = 0; y < contentRows; y++) {
const line = activeBuffer.getLine(y);
if (!line) continue;
let currentFgHex: string | null = null;
let currentBgHex: string | null = null;
let currentBlockStartCol = -1;
let currentBlockText = '';
let currentBlockNumCells = 0;
const finalizeBlock = (_endCol: number) => {
if (currentBlockStartCol !== -1) {
if (currentBlockText.length > 0) {
const xPos = currentBlockStartCol * charWidth;
const yPos = y * charHeight;
if (currentBgHex) {
const rectWidth = currentBlockNumCells * charWidth;
svg += ` <rect x="${xPos}" y="${yPos}" width="${rectWidth}" height="${charHeight}" fill="${currentBgHex}" />
`;
}
if (currentBlockText.trim().length > 0) {
const fill = currentFgHex || '#ffffff'; // Default text color
const textWidth = currentBlockNumCells * charWidth;
// Use textLength to ensure the block fits exactly into its designated cells
svg += ` <text x="${xPos}" y="${yPos + 2}" fill="${fill}" textLength="${textWidth}" lengthAdjust="spacingAndGlyphs">${escapeXml(currentBlockText)}</text>
`;
}
}
}
};
for (let x = 0; x < line.length; x++) {
const cell = line.getCell(x);
if (!cell) continue;
const cellWidth = cell.getWidth();
if (cellWidth === 0) continue; // Skip continuation cells of wide characters
let fgHex = getHexColor(
cell.isFgRGB(),
cell.isFgPalette(),
cell.isFgDefault(),
cell.getFgColor(),
);
let bgHex = getHexColor(
cell.isBgRGB(),
cell.isBgPalette(),
cell.isBgDefault(),
cell.getBgColor(),
);
if (cell.isInverse()) {
const tempFgHex = fgHex;
fgHex = bgHex || '#000000';
bgHex = tempFgHex || '#ffffff';
}
let chars = cell.getChars();
if (chars === '') chars = ' '.repeat(cellWidth);
if (
fgHex !== currentFgHex ||
bgHex !== currentBgHex ||
currentBlockStartCol === -1
) {
finalizeBlock(x);
currentFgHex = fgHex;
currentBgHex = bgHex;
currentBlockStartCol = x;
currentBlockText = chars;
currentBlockNumCells = cellWidth;
} else {
currentBlockText += chars;
currentBlockNumCells += cellWidth;
}
}
finalizeBlock(line.length);
}
svg += ` </g>\n</svg>`;
return svg;
};