diff --git a/integration-tests/stdout-stderr-output-error.responses b/integration-tests/stdout-stderr-output-error.responses new file mode 100644 index 0000000000..9ab3a83984 --- /dev/null +++ b/integration-tests/stdout-stderr-output-error.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I could not find the file `nonexistent-file-that-does-not-exist.txt` in the current directory or its subdirectories. Please verify the file path or name."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":25,"totalTokenCount":35,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10}]}}]} diff --git a/integration-tests/stdout-stderr-output.responses b/integration-tests/stdout-stderr-output.responses new file mode 100644 index 0000000000..e78165ae60 --- /dev/null +++ b/integration-tests/stdout-stderr-output.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you today?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":9,"totalTokenCount":14,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} diff --git a/integration-tests/stdout-stderr-output.test.ts b/integration-tests/stdout-stderr-output.test.ts new file mode 100644 index 0000000000..f401e3a6a8 --- /dev/null +++ b/integration-tests/stdout-stderr-output.test.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { join } from 'node:path'; +import { TestRig } from './test-helper.js'; + +describe('stdout-stderr-output', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should send model response to stdout and app messages to stderr', async ({ + signal, + }) => { + await rig.setup('prompt-output-test', { + fakeResponsesPath: join( + import.meta.dirname, + 'stdout-stderr-output.responses', + ), + }); + + const { stdout, exitCode } = await rig.runWithStreams(['-p', 'Say hello'], { + signal, + }); + + expect(exitCode).toBe(0); + expect(stdout.toLowerCase()).toContain('hello'); + expect(stdout).not.toMatch(/^\[ERROR\]/m); + expect(stdout).not.toMatch(/^\[INFO\]/m); + }); + + it('should handle missing file with message to stdout and error to stderr', async ({ + signal, + }) => { + await rig.setup('error-output-test', { + fakeResponsesPath: join( + import.meta.dirname, + 'stdout-stderr-output-error.responses', + ), + }); + + const { stdout, exitCode } = await rig.runWithStreams( + ['-p', '@nonexistent-file-that-does-not-exist.txt explain this'], + { signal }, + ); + + expect(exitCode).toBe(0); + expect(stdout.toLowerCase()).toMatch( + /could not find|not exist|does not exist/, + ); + }); +}); diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index e2b06b9609..b1dcadb097 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -555,6 +555,48 @@ export class TestRig { return filteredLines.join('\n'); } + /** + * Runs the CLI and returns stdout and stderr separately. + * Useful for tests that need to verify correct stream routing. + */ + runWithStreams( + args: string[], + options?: { signal?: AbortSignal }, + ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + return new Promise((resolve, reject) => { + const { command, initialArgs } = this._getCommandAndArgs([ + '--approval-mode=yolo', + ]); + + const allArgs = [...initialArgs, ...args]; + + const child = spawn(command, allArgs, { + cwd: this.testDir!, + stdio: 'pipe', + env: { ...process.env, GEMINI_CLI_HOME: this.homeDir! }, + signal: options?.signal, + }); + this._spawnedProcesses.push(child); + + let stdout = ''; + let stderr = ''; + + child.on('error', reject); + + child.stdout!.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr!.on('data', (chunk) => { + stderr += chunk; + }); + + child.stdin!.end(); + child.on('close', (exitCode) => { + resolve({ stdout, stderr, exitCode }); + }); + }); + } + runCommand( args: string[], options: {