diff --git a/integration-tests/ctrl-c-exit.test.ts b/integration-tests/ctrl-c-exit.test.ts new file mode 100644 index 0000000000..023509aaa6 --- /dev/null +++ b/integration-tests/ctrl-c-exit.test.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { TestRig } from './test-helper.js'; + +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); + + // 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, + ); + + // Send second Ctrl+C + ptyProcess.write('\x03'); + + const result = await promise; + + // Expect a graceful exit (code 0) + expect( + result.exitCode, + `Process exited with code ${result.exitCode}. Output: ${result.output}`, + ).toBe(0); + + // Check that the quitting message is displayed + 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); + }); +}); diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index f86b72d787..e928cbe45f 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -10,6 +10,7 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { env } from 'node:process'; import fs from 'node:fs'; +import * as pty from '@lydell/node-pty'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -719,4 +720,39 @@ export class TestRig { } return lastApiRequest; } + + runInteractive(...args: string[]): { + ptyProcess: pty.IPty; + promise: Promise<{ exitCode: number; signal?: number; output: string }>; + } { + const commandArgs = [this.bundlePath, '--yolo', ...args]; + + const ptyProcess = pty.spawn('node', commandArgs, { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: this.testDir!, + env: process.env as { [key: string]: string }, + }); + + let output = ''; + ptyProcess.onData((data) => { + output += data; + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + process.stdout.write(data); + } + }); + + const promise = new Promise<{ + exitCode: number; + signal?: number; + output: string; + }>((resolve) => { + ptyProcess.onExit(({ exitCode, signal }) => { + resolve({ exitCode, signal, output }); + }); + }); + + return { ptyProcess, promise }; + } }