From 2a3c5f0b16d4f49a028939660441e5fec77ffe1e Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 30 Jul 2026 02:15:36 -0700 Subject: [PATCH 1/5] Fix: scrub ambient cloud env vars in the vitest unit projects Several unit tests read Google Cloud configuration straight out of process.env, so a developer who has run `gcloud auth application-default login` or exported GOOGLE_CLOUD_PROJECT sees failures that CI can never reproduce: GitHub Actions runners export none of these variables. Wire a setup file into the three unit:* projects that deletes the eight ambient cloud/credential variables from each worker's environment before the test module is imported. A plain delete (rather than vi.stubEnv) survives vi.unstubAllEnvs(), so no test can resurrect the ambient value. Only the unit:* projects are wired. integration, e2e and cross-language keep the ambient environment, because that is how a developer supplies real credentials to them -- scrubbing run-wide via globalSetup would silently turn the e2e suite into skips. --- tests/hermetic_env.ts | 40 +++++++++++++++++++++++++++++++++++++ tests/hermetic_env_setup.ts | 12 +++++++++++ vitest.config.ts | 3 +++ 3 files changed, 55 insertions(+) create mode 100644 tests/hermetic_env.ts create mode 100644 tests/hermetic_env_setup.ts diff --git a/tests/hermetic_env.ts b/tests/hermetic_env.ts new file mode 100644 index 000000000..9dffe0144 --- /dev/null +++ b/tests/hermetic_env.ts @@ -0,0 +1,40 @@ +/** + * @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`) and + * that ADK source code reads directly from `process.env`. + */ +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/vitest.config.ts b/vitest.config.ts index 0e27b0364..368b6e31e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ test: { name: 'unit:core', environment: 'node', + setupFiles: ['./tests/hermetic_env_setup.ts'], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -36,6 +37,7 @@ export default defineConfig({ test: { name: 'unit:dev', environment: 'node', + setupFiles: ['./tests/hermetic_env_setup.ts'], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -50,6 +52,7 @@ export default defineConfig({ test: { name: 'unit:integrations', environment: 'node', + setupFiles: ['./tests/hermetic_env_setup.ts'], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( From f06fc291771b655cad896f05a43da08fe84cfedf Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 30 Jul 2026 02:15:44 -0700 Subject: [PATCH 2/5] Test: cover the ambient cloud env scrub and its setupFiles wiring Two cases, each pinning a distinct failure mode. The first drives scrubAmbientCloudEnv table-driven off the exported list and is the case that runs meaningfully in CI. The second asserts the unit worker environment is clean, which pins the setupFiles wiring in vitest.config.ts: hermetic_env.ts is side-effect free, so that assertion only holds when the setup file really ran. --- core/test/utils/hermetic_env_test.ts | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 core/test/utils/hermetic_env_test.ts 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(); + } + }); +}); From f79bb7426e76e1267cadd3fd60a1fa3cbaf8c6a1 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 30 Jul 2026 02:21:11 -0700 Subject: [PATCH 3/5] Docs: correct the provenance note on the ambient env var list GOOGLE_APPLICATION_CREDENTIALS is not read by ADK source -- it is consumed by google-auth-library beneath the genai SDK. Saying otherwise invites a future reader who greps for it to prune the one entry that points at real credentials. --- tests/hermetic_env.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/hermetic_env.ts b/tests/hermetic_env.ts index 9dffe0144..bb12a47be 100644 --- a/tests/hermetic_env.ts +++ b/tests/hermetic_env.ts @@ -6,8 +6,13 @@ /** * Cloud configuration and credential variables that a developer machine is - * likely to export (via `gcloud`, a shell profile, or a sourced `.env`) and - * that ADK source code reads directly from `process.env`. + * 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', From c5c8a696d9c4178a93b8fb81029a3ea5331ac3e3 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 30 Jul 2026 17:20:11 -0700 Subject: [PATCH 4/5] Fix: pin the test log level inside the vitest workers `tests/global_setup.ts` called `setLogLevel(LogLevel.ERROR)` from a vitest `globalSetup` module, which runs once in the Vitest main process. Every test file runs in a separate forked worker with a fresh module graph, and the level lives in module-level state on `SimpleLogger`, so the pin never reached a single test. ADK still logged at INFO throughout the suite. Move the pin to a per-project `setupFiles` module, which vitest evaluates inside each worker before the test file. Root-level `setupFiles` is not inherited by inline `projects` entries unless they set `extends: true`, so the entry is repeated on each project, matching how `alias` is already repeated. The setup file imports the logger module directly instead of the `@google/adk` barrel: a setup file is evaluated before the test module, so importing the public entry point there instantiates the real core module graph before a test file's hoisted `vi.mock` calls can replace any of it, which breaks mocking in over a hundred test files. --- tests/global_setup.ts | 15 --------------- tests/setup_log_level.ts | 17 +++++++++++++++++ vitest.config.ts | 19 +++++++++++++++---- 3 files changed, 32 insertions(+), 19 deletions(-) delete mode 100644 tests/global_setup.ts create mode 100644 tests/setup_log_level.ts 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/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 368b6e31e..fcae28b76 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,7 +22,10 @@ export default defineConfig({ test: { name: 'unit:core', environment: 'node', - setupFiles: ['./tests/hermetic_env_setup.ts'], + setupFiles: [ + './tests/hermetic_env_setup.ts', + './tests/setup_log_level.ts', + ], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -37,7 +40,10 @@ export default defineConfig({ test: { name: 'unit:dev', environment: 'node', - setupFiles: ['./tests/hermetic_env_setup.ts'], + setupFiles: [ + './tests/hermetic_env_setup.ts', + './tests/setup_log_level.ts', + ], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -52,7 +58,10 @@ export default defineConfig({ test: { name: 'unit:integrations', environment: 'node', - setupFiles: ['./tests/hermetic_env_setup.ts'], + setupFiles: [ + './tests/hermetic_env_setup.ts', + './tests/setup_log_level.ts', + ], alias: { '@google/adk': path.resolve(__dirname, './core/src'), '@google/adk-integrations': path.resolve( @@ -67,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( @@ -81,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( @@ -95,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( @@ -124,6 +136,5 @@ export default defineConfig({ lines: 86, }, }, - globalSetup: ['./tests/global_setup.ts'], }, }); From 4abddd70e84987e008b1433910e6a2e0c735a19a Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 30 Jul 2026 17:20:15 -0700 Subject: [PATCH 5/5] Test: pin the worker log level with a regression test Asserts what the logger actually writes, since `SimpleLogger.logLevel` is private and there is no public read accessor: an `info` call produces no console output while an `error` call still does. Winston's Console transport writes to the stream held by whatever `globalThis.console` is at log time, so the helper swaps in a `node:console` Console bound to an in-memory `Writable`. Spying on `process.stdout.write` or `console.log` captures nothing, because vitest replaces the global console with its own instance backed by a private stream. --- core/test/utils/log_level_pin_test.ts | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 core/test/utils/log_level_pin_test.ts 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'); + }); +});