diff --git a/core/test/utils/hermetic_env_test.ts b/core/test/utils/hermetic_env_test.ts new file mode 100644 index 000000000..812a509f4 --- /dev/null +++ b/core/test/utils/hermetic_env_test.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import { + AMBIENT_CLOUD_ENV_VARS, + scrubAmbientCloudEnv, +} from '../../../tests/hermetic_env.js'; + +describe('scrubAmbientCloudEnv', () => { + it('removes every ambient cloud variable and keeps the rest', () => { + const env: Record = {PATH: '/usr/bin'}; + for (const name of AMBIENT_CLOUD_ENV_VARS) { + env[name] = `ambient-${name}`; + } + + scrubAmbientCloudEnv(env); + + expect(env).toEqual({PATH: '/usr/bin'}); + }); +}); + +/** + * Pins the `setupFiles` wiring in `vitest.config.ts`. Importing + * `hermetic_env.js` above has no side effects, so this only passes when the + * setup file really ran in this worker. + * + * Vacuous on a CI runner by construction -- GitHub Actions exports none of + * these -- and the assertion that fires on a developer machine. + */ +describe('unit test worker environment', () => { + it('has no ambient cloud variables', () => { + for (const name of AMBIENT_CLOUD_ENV_VARS) { + expect(process.env[name]).toBeUndefined(); + } + }); +}); diff --git a/core/test/utils/log_level_pin_test.ts b/core/test/utils/log_level_pin_test.ts new file mode 100644 index 000000000..037a6df45 --- /dev/null +++ b/core/test/utils/log_level_pin_test.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {getLogger} from '@google/adk'; +import {Console} from 'node:console'; +import {Writable} from 'node:stream'; +import {describe, expect, it} from 'vitest'; + +/** + * Returns everything `fn` writes through the global console. + * + * `SimpleLogger`'s winston Console transport writes to the stream held by + * whatever `globalThis.console` is at log time, so swapping in a `Console` + * bound to an in-memory stream captures it. Vitest replaces the global console + * with its own instance backed by a private stream, which is why spying on + * `process.stdout.write` or `console.log` captures nothing here. + */ +function captureConsoleOutput(fn: () => void): string { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk: unknown, _encoding: string, callback: () => void) { + chunks.push(String(chunk)); + callback(); + }, + }); + const original = globalThis.console; + globalThis.console = new Console({stdout: stream, stderr: stream}); + try { + fn(); + } finally { + globalThis.console = original; + } + return chunks.join(''); +} + +/** + * Pins the `setupFiles` wiring in `vitest.config.ts`. The log level is + * module-level state, so a `globalSetup` file running in the Vitest main + * process cannot reach the forked worker this test runs in; only a setup file + * can. There is no public read accessor for the effective level, so these + * assert on what the logger writes. + */ +describe('test worker log level', () => { + it('suppresses info logs', () => { + expect(captureConsoleOutput(() => getLogger().info('info-pin-probe'))).toBe( + '', + ); + }); + + it('still emits error logs', () => { + expect( + captureConsoleOutput(() => getLogger().error('error-pin-probe')), + ).toContain('error-pin-probe'); + }); +}); diff --git a/tests/global_setup.ts b/tests/global_setup.ts deleted file mode 100644 index f41775252..000000000 --- a/tests/global_setup.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {LogLevel, setLogLevel} from '@google/adk'; - -export function setup() { - setLogLevel(LogLevel.ERROR); -} - -export function teardown() { - setLogLevel(LogLevel.INFO); -} diff --git a/tests/hermetic_env.ts b/tests/hermetic_env.ts new file mode 100644 index 000000000..bb12a47be --- /dev/null +++ b/tests/hermetic_env.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Cloud configuration and credential variables that a developer machine is + * likely to export (via `gcloud`, a shell profile, or a sourced `.env`). + * + * All but one are read straight from `process.env` by ADK source. + * `GOOGLE_APPLICATION_CREDENTIALS` is the exception: it is consumed by + * `google-auth-library` beneath the genai SDK, so grepping ADK for it finds + * nothing. Keep it in the list -- it is the entry that points at real + * credentials. + */ +export const AMBIENT_CLOUD_ENV_VARS = [ + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_CLOUD_AGENT_ENGINE_ID', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_GENAI_API_KEY', + 'GOOGLE_GENAI_USE_VERTEXAI', +]; + +/** + * Removes the ambient cloud configuration from `env`. + * + * GitHub Actions runners never export these variables, so a unit test that + * reads one of them passes in CI and fails only on a developer machine. + * Unit tests that need a value set one explicitly with `vi.stubEnv`. + * + * This module is deliberately free of side effects: `hermetic_env_setup.ts` is + * what applies the scrub. That split is what lets a test import the list and + * still observe whether the setup file actually ran. + */ +export function scrubAmbientCloudEnv( + env: Record = process.env, +) { + for (const name of AMBIENT_CLOUD_ENV_VARS) { + delete env[name]; + } +} diff --git a/tests/hermetic_env_setup.ts b/tests/hermetic_env_setup.ts new file mode 100644 index 000000000..003045dc4 --- /dev/null +++ b/tests/hermetic_env_setup.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {scrubAmbientCloudEnv} from './hermetic_env.js'; + +// Runs in every unit-test worker before the test module is imported, so no +// unit test can read the developer machine's real gcloud / Vertex AI settings. +// A plain delete (rather than `vi.stubEnv`) survives `vi.unstubAllEnvs()`. +scrubAmbientCloudEnv(); diff --git a/tests/setup_log_level.ts b/tests/setup_log_level.ts new file mode 100644 index 000000000..c59e5589c --- /dev/null +++ b/tests/setup_log_level.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {LogLevel, setLogLevel} from '../core/src/utils/logger.js'; + +// Must run inside the test worker: vitest `globalSetup` executes in the main +// process, and the log level is module-level state a forked worker never +// inherits. +// +// Import the logger module, not the `@google/adk` barrel. A setup file is +// evaluated before the test module, so importing the barrel here caches the +// whole real core graph before a test file's `vi.mock` can replace any of it, +// which breaks mocking in over a hundred suites. +setLogLevel(LogLevel.ERROR); diff --git a/vitest.config.ts b/vitest.config.ts index 0e27b0364..fcae28b76 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,6 +22,10 @@ export default defineConfig({ test: { name: 'unit:core', environment: 'node', + setupFiles: [ + './tests/hermetic_env_setup.ts', + './tests/setup_log_level.ts', + ], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -36,6 +40,10 @@ export default defineConfig({ test: { name: 'unit:dev', environment: 'node', + setupFiles: [ + './tests/hermetic_env_setup.ts', + './tests/setup_log_level.ts', + ], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -50,6 +58,10 @@ export default defineConfig({ test: { name: 'unit:integrations', environment: 'node', + setupFiles: [ + './tests/hermetic_env_setup.ts', + './tests/setup_log_level.ts', + ], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -64,6 +76,7 @@ export default defineConfig({ test: { name: 'integration', environment: 'node', + setupFiles: ['./tests/setup_log_level.ts'], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -78,6 +91,7 @@ export default defineConfig({ test: { name: 'e2e', environment: 'node', + setupFiles: ['./tests/setup_log_level.ts'], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -92,6 +106,7 @@ export default defineConfig({ test: { name: 'cross-language', environment: 'node', + setupFiles: ['./tests/setup_log_level.ts'], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -121,6 +136,5 @@ export default defineConfig({ lines: 86, }, }, - globalSetup: ['./tests/global_setup.ts'], }, });