diff --git a/integration-tests/context-compress-interactive.test.ts b/integration-tests/context-compress-interactive.test.ts index 9f809c3dba..ca5eb4c07d 100644 --- a/integration-tests/context-compress-interactive.test.ts +++ b/integration-tests/context-compress-interactive.test.ts @@ -21,8 +21,7 @@ describe('Interactive Mode', () => { it('should trigger chat compression with /compress command', async () => { await rig.setup('interactive-compress-test'); - const { ptyProcess } = rig.runInteractive(); - await rig.ensureReadyForInput(ptyProcess); + const ptyProcess = await rig.runInteractive(); 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.'; @@ -50,18 +49,12 @@ describe('Interactive Mode', () => { it.skip('should handle compression failure on token inflation', async () => { await rig.setup('interactive-compress-test'); - const { ptyProcess } = rig.runInteractive(); - await rig.ensureReadyForInput(ptyProcess); + const ptyProcess = await rig.runInteractive(); await type(ptyProcess, '/compress'); await new Promise((resolve) => setTimeout(resolve, 100)); await type(ptyProcess, '\r'); - const compressionFailed = await rig.waitForText( - 'compression was not beneficial', - 25000, - ); - - expect(compressionFailed).toBe(true); + await rig.waitForText('compression was not beneficial', 25000); }); }); diff --git a/integration-tests/ctrl-c-exit.test.ts b/integration-tests/ctrl-c-exit.test.ts index 0d503f2498..6981eef15c 100644 --- a/integration-tests/ctrl-c-exit.test.ts +++ b/integration-tests/ctrl-c-exit.test.ts @@ -7,33 +7,36 @@ import { describe, it, expect } from 'vitest'; import * as os from 'node:os'; import { TestRig } from './test-helper.js'; +import * as pty from '@lydell/node-pty'; + +function waitForExit(ptyProcess: pty.IPty): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error(`Test timed out: process did not exit within a minute.`), + ), + 60000, + ); + ptyProcess.onExit(({ exitCode }) => { + clearTimeout(timer); + resolve(exitCode); + }); + }); +} describe('Ctrl+C exit', () => { it('should exit gracefully on second Ctrl+C', async () => { const rig = new TestRig(); await rig.setup('should exit gracefully on second Ctrl+C'); - const { ptyProcess, promise } = rig.runInteractive(); - - let output = ''; - ptyProcess.onData((data) => { - output += data; - }); - - // Wait for the app to be ready by looking for the initial prompt indicator - await rig.poll(() => output.includes('▶'), 5000, 100); + const ptyProcess = await rig.runInteractive(); // Send first Ctrl+C ptyProcess.write('\x03'); - // Wait for the exit prompt - await rig.poll( - () => output.includes('Press Ctrl+C again to exit'), - 1500, - 50, - ); + await rig.waitForText('Press Ctrl+C again to exit', 5000); - // Send second Ctrl+C if (os.platform() === 'win32') { // This is a workaround for node-pty/winpty on Windows. // Reliably sending a second Ctrl+C signal to a process that is already @@ -44,50 +47,21 @@ describe('Ctrl+C exit', () => { // simulating a successful exit. We accept that we cannot test the // graceful shutdown message on Windows in this automated context. ptyProcess.kill(); - } else { - // On Unix-like systems, send the second Ctrl+C to trigger the graceful exit. - ptyProcess.write('\x03'); - } - const timeout = new Promise((_, reject) => - setTimeout( - () => - reject( - new Error( - `Test timed out: process did not exit within a minute. Output: ${output}`, - ), - ), - 60000, - ), - ); - - const result = await Promise.race([promise, timeout]); - - // On Windows, killing the process may result in a non-zero exit code. On - // other platforms, a graceful exit is code 0. - if (os.platform() === 'win32') { + const exitCode = await waitForExit(ptyProcess); // On Windows, the exit code after ptyProcess.kill() can be unpredictable // (often 1), so we accept any non-null exit code as a pass condition, // focusing on the fact that the process did terminate. - expect( - result.exitCode, - `Process exited with code ${result.exitCode}. Output: ${result.output}`, - ).not.toBeNull(); - } else { - // Expect a graceful exit (code 0) on non-Windows platforms - expect( - result.exitCode, - `Process exited with code ${result.exitCode}. Output: ${result.output}`, - ).toBe(0); - - // Only check for the quitting message on non-Windows platforms due to the - // forceful kill workaround. - const quittingMessage = 'Agent powering down. Goodbye!'; - // The regex below is intentionally matching the ESC control character (\x1b) - // to strip ANSI color codes from the terminal output. - // eslint-disable-next-line no-control-regex - const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); - expect(cleanOutput).toContain(quittingMessage); + expect(exitCode, `Process exited with code ${exitCode}.`).not.toBeNull(); + return; } + + // Send second Ctrl+C + ptyProcess.write('\x03'); + + const exitCode = await waitForExit(ptyProcess); + expect(exitCode, `Process exited with code ${exitCode}.`).toBe(0); + + await rig.waitForText('Agent powering down. Goodbye!', 5000); }); }); diff --git a/integration-tests/file-system-interactive.test.ts b/integration-tests/file-system-interactive.test.ts index c9886ae95a..4f5653bcb2 100644 --- a/integration-tests/file-system-interactive.test.ts +++ b/integration-tests/file-system-interactive.test.ts @@ -23,23 +23,7 @@ describe('Interactive file system', () => { rig.setup('interactive-read-then-write'); rig.createFile(fileName, '1.0.0'); - const { ptyProcess } = rig.runInteractive(); - - 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', 30000); - expect(isReady, 'CLI did not start up in interactive mode correctly').toBe( - true, - ); + const ptyProcess = await rig.runInteractive(); // Step 1: Read the file const readPrompt = `Read the version from ${fileName}`; @@ -49,11 +33,7 @@ describe('Interactive file system', () => { const readCall = await rig.waitForToolCall('read_file', 30000); expect(readCall, 'Expected to find a read_file tool call').toBe(true); - const containsExpectedVersion = await rig.waitForText('1.0.0', 30000); - expect( - containsExpectedVersion, - 'Expected to see version "1.0.0" in output', - ).toBe(true); + await rig.waitForText('1.0.0', 30000); // Step 2: Write the file const writePrompt = `now change the version to 1.0.1 in the file`; diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index c07771e50d..2fcf29dc47 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { expect } from 'vitest'; import { execSync, spawn } from 'node:child_process'; import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; @@ -189,6 +190,11 @@ export class TestRig { otlpEndpoint: '', outfile: telemetryPath, }, + security: { + auth: { + selectedType: 'gemini-api-key', + }, + }, model: DEFAULT_GEMINI_MODEL, sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false, ...options.settings, // Allow tests to override/add settings @@ -801,11 +807,11 @@ export class TestRig { return null; } - async waitForText(text: string, timeout?: number): Promise { + async waitForText(text: string, timeout?: number) { if (!timeout) { timeout = this.getDefaultTimeout(); } - return this.poll( + const found = await this.poll( () => stripAnsi(this._interactiveOutput) .toLowerCase() @@ -813,12 +819,10 @@ export class TestRig { timeout, 200, ); + expect(found, `Did not find expected text: "${text}"`).toBe(true); } - runInteractive(...args: string[]): { - ptyProcess: pty.IPty; - promise: Promise<{ exitCode: number; signal?: number; output: string }>; - } { + async runInteractive(...args: string[]): Promise { const { command, initialArgs } = this._getCommandAndArgs(['--yolo']); const commandArgs = [...initialArgs, ...args]; const isWindows = os.platform() === 'win32'; @@ -850,60 +854,9 @@ export class TestRig { } }); - const promise = new Promise<{ - exitCode: number; - signal?: number; - output: string; - }>((resolve) => { - ptyProcess.onExit(({ exitCode, signal }) => { - resolve({ exitCode, signal, output: this._interactiveOutput }); - }); - }); + // Wait for the app to be ready + await this.waitForText('Type your message', 30000); - return { ptyProcess, promise }; - } - - /** - * Waits for an interactive session to be fully ready for input. - * This is a higher-level utility to be used with `runInteractive`. - * - * It handles the initial setup boilerplate: - * 1. Automatically handles the authentication prompt if it appears. - * 2. Waits for the "Type your message" prompt to ensure the CLI is ready for input. - * - * Throws an error if the session fails to become ready within the timeout. - * - * @param ptyProcess The process returned from `runInteractive`. - */ - async ensureReadyForInput(ptyProcess: pty.IPty): Promise { - const timeout = 25000; - const pollingInterval = 200; - const startTime = Date.now(); - let authPromptHandled = false; - - while (Date.now() - startTime < timeout) { - const output = stripAnsi(this._interactiveOutput).toLowerCase(); - - // If the ready prompt appears, we're done. - if (output.includes('type your message')) { - return; - } - - // If the auth prompt appears and we haven't handled it yet. - if ( - !authPromptHandled && - output.includes('how would you like to authenticate') - ) { - ptyProcess.write('2'); - authPromptHandled = true; - } - - // Wait for the next poll. - await new Promise((resolve) => setTimeout(resolve, pollingInterval)); - } - - throw new Error( - `CLI did not start up in interactive mode correctly. Output: ${this._interactiveOutput}`, - ); + return ptyProcess; } }