diff --git a/integration-tests/context-compress-interactive.test.ts b/integration-tests/context-compress-interactive.test.ts new file mode 100644 index 0000000000..ddfa6839ae --- /dev/null +++ b/integration-tests/context-compress-interactive.test.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it, beforeEach, afterEach } from 'vitest'; +import { TestRig, type } from './test-helper.js'; + +describe('Interactive Mode', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it.skipIf(process.platform === 'win32')( + 'should trigger chat compression with /compress command', + async () => { + await rig.setup('interactive-compress-test'); + + const { ptyProcess } = rig.runInteractive(); + + let fullOutput = ''; + ptyProcess.onData((data) => (fullOutput += data)); + + const authDialogAppeared = await rig.waitForText( + 'How would you like to authenticate', + 5000, + ); + + // select the second option if auth dialog come's up + if (authDialogAppeared) { + ptyProcess.write('2'); + } + + // Wait for the app to be ready + const isReady = await rig.waitForText('Type your message', 15000); + expect( + isReady, + 'CLI did not start up in interactive mode correctly', + ).toBe(true); + + const longPrompt = + 'Dont do anything except returning a 1000 token long paragragh with the at the end to indicate end of response. This is a moderately long sentence.'; + + await type(ptyProcess, longPrompt); + await type(ptyProcess, '\r'); + + await rig.waitForText('einstein', 25000); + + await type(ptyProcess, '/compress'); + // A small delay to allow React to re-render the command list. + await new Promise((resolve) => setTimeout(resolve, 100)); + await type(ptyProcess, '\r'); + + const foundEvent = await rig.waitForTelemetryEvent( + 'chat_compression', + 90000, + ); + expect(foundEvent, 'chat_compression telemetry event was not found').toBe( + true, + ); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'should handle compression failure on token inflation', + async () => { + await rig.setup('interactive-compress-test'); + + const { ptyProcess } = rig.runInteractive(); + + let fullOutput = ''; + ptyProcess.onData((data) => (fullOutput += data)); + + const authDialogAppeared = await rig.waitForText( + 'How would you like to authenticate', + 5000, + ); + + // select the second option if auth dialog come's up + if (authDialogAppeared) { + ptyProcess.write('2'); + } + + // Wait for the app to be ready + const isReady = await rig.waitForText('Type your message', 25000); + expect( + isReady, + 'CLI did not start up in interactive mode correctly', + ).toBe(true); + + await type(ptyProcess, '/compress'); + await new Promise((resolve) => setTimeout(resolve, 100)); + await type(ptyProcess, '\r'); + + const foundEvent = await rig.waitForTelemetryEvent( + 'chat_compression', + 90000, + ); + expect(foundEvent).toBe(true); + + const compressionFailed = await rig.waitForText( + 'compression was not beneficial', + 25000, + ); + + expect(compressionFailed).toBe(true); + }, + ); +}); diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index ccebae614d..df7adb1e75 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -12,6 +12,7 @@ import { env } from 'node:process'; import { DEFAULT_GEMINI_MODEL } from '../packages/core/src/config/models.js'; import fs from 'node:fs'; import * as pty from '@lydell/node-pty'; +import stripAnsi from 'strip-ansi'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -112,6 +113,15 @@ export function validateModelOutput( return true; } +// Simulates typing a string one character at a time to avoid paste detection. +export async function type(ptyProcess: pty.IPty, text: string) { + const delay = 5; + for (const char of text) { + ptyProcess.write(char); + await new Promise((resolve) => setTimeout(resolve, delay)); + } +} + interface ParsedLog { attributes?: { 'event.name'?: string; @@ -134,6 +144,7 @@ export class TestRig { testDir: string | null; testName?: string; _lastRunStdout?: string; + _interactiveOutput = ''; constructor() { this.bundlePath = join(__dirname, '..', 'bundle/gemini.js'); @@ -782,6 +793,20 @@ export class TestRig { return null; } + async waitForText(text: string, timeout?: number): Promise { + if (!timeout) { + timeout = this.getDefaultTimeout(); + } + return this.poll( + () => + stripAnsi(this._interactiveOutput) + .toLowerCase() + .includes(text.toLowerCase()), + timeout, + 200, + ); + } + runInteractive(...args: string[]): { ptyProcess: pty.IPty; promise: Promise<{ exitCode: number; signal?: number; output: string }>; @@ -789,6 +814,8 @@ export class TestRig { const { command, initialArgs } = this._getCommandAndArgs(['--yolo']); const commandArgs = [...initialArgs, ...args]; + this._interactiveOutput = ''; // Reset output for the new run + const ptyProcess = pty.spawn(command, commandArgs, { name: 'xterm-color', cols: 80, @@ -797,9 +824,8 @@ export class TestRig { env: process.env as { [key: string]: string }, }); - let output = ''; ptyProcess.onData((data) => { - output += data; + this._interactiveOutput += data; if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { process.stdout.write(data); } @@ -811,7 +837,7 @@ export class TestRig { output: string; }>((resolve) => { ptyProcess.onExit(({ exitCode, signal }) => { - resolve({ exitCode, signal, output }); + resolve({ exitCode, signal, output: this._interactiveOutput }); }); }); diff --git a/package-lock.json b/package-lock.json index d6d2cdba3c..1668c241ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "prettier": "^3.5.3", "react-devtools-core": "^4.28.5", "semver": "^7.7.2", + "strip-ansi": "^7.1.2", "tsx": "^4.20.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", @@ -14736,9 +14737,9 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" diff --git a/package.json b/package.json index ceb63ea411..95baad8498 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "prettier": "^3.5.3", "react-devtools-core": "^4.28.5", "semver": "^7.7.2", + "strip-ansi": "^7.1.2", "tsx": "^4.20.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4",