diff --git a/src/__tests__/hermetic-env-setup.test.ts b/src/__tests__/hermetic-env-setup.test.ts new file mode 100644 index 000000000..22ad6f8b4 --- /dev/null +++ b/src/__tests__/hermetic-env-setup.test.ts @@ -0,0 +1,41 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import vitestConfig from '../../vitest.config.ts'; + +const HERMETIC_ENV_SETUP = 'src/__tests__/hermetic-env-setup.ts'; +const AMBIENT_DAEMON_VARS = [ + 'AGENT_DEVICE_DAEMON_BASE_URL', + 'AGENT_DEVICE_DAEMON_AUTH_TOKEN', +] as const; + +type ProjectShape = { test?: { name?: string; setupFiles?: readonly string[] } }; + +// Wiring: the scrub only helps if every project loads it as a setup file. CI runs with the +// vars unset, so a dropped wiring is otherwise invisible — assert it structurally instead. +test('every vitest project wires the hermetic-env setup', () => { + const projects = (vitestConfig.test?.projects ?? []) as unknown as ReadonlyArray; + assert.ok(projects.length > 0, 'expected configured vitest projects'); + for (const project of projects) { + const name = project.test?.name ?? '(unnamed)'; + const setupFiles = project.test?.setupFiles ?? []; + assert.ok( + setupFiles.includes(HERMETIC_ENV_SETUP), + `project "${name}" must wire ${HERMETIC_ENV_SETUP} in setupFiles`, + ); + } +}); + +// Behavior: re-import a fresh copy of the setup module with the daemon vars set, so the scrub +// is exercised on any host (CI included, where the vars are otherwise absent). +afterEach(() => { + for (const name of AMBIENT_DAEMON_VARS) delete process.env[name]; +}); + +test('importing hermetic-env-setup scrubs the ambient daemon connection vars', async () => { + for (const name of AMBIENT_DAEMON_VARS) process.env[name] = 'leaked-from-host'; + vi.resetModules(); + await import('./hermetic-env-setup.ts'); + for (const name of AMBIENT_DAEMON_VARS) { + assert.equal(process.env[name], undefined, `${name} must be scrubbed when the setup loads`); + } +}); diff --git a/src/__tests__/hermetic-env-setup.ts b/src/__tests__/hermetic-env-setup.ts new file mode 100644 index 000000000..654603849 --- /dev/null +++ b/src/__tests__/hermetic-env-setup.ts @@ -0,0 +1,23 @@ +// Unit tests must be hermetic with respect to the host's daemon-connection +// environment. A machine actually running agent-device — including this repo's +// own remote dev containers — exports AGENT_DEVICE_DAEMON_BASE_URL and +// AGENT_DEVICE_DAEMON_AUTH_TOKEN pointing at a live daemon. Production +// flag-default resolution folds those into every command's input and connection +// config (resolveConfigBackedFlagDefaults -> readEnvFlagDefaults, and the daemon +// client's own env fallbacks), so a configured host silently diverges from CI: +// tests that assert an exact command input/config shape gain phantom +// daemonBaseUrl/daemonAuthToken keys, and daemon-client tests take the remote +// path ("Remote daemon is unavailable") instead of the local one they exercise. +// +// CI runs with these unset. Delete them here so a configured host matches CI. +// Tests that genuinely need them assign their own value or pass an explicit env +// object; that happens inside the test, after this module has loaded, so this +// scrub does not interfere. +const AMBIENT_DAEMON_ENV_VARS = [ + 'AGENT_DEVICE_DAEMON_BASE_URL', + 'AGENT_DEVICE_DAEMON_AUTH_TOKEN', +] as const; + +for (const name of AMBIENT_DAEMON_ENV_VARS) { + delete process.env[name]; +} diff --git a/vitest.config.ts b/vitest.config.ts index d2fd6638c..bc9c79d73 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'vitest/config'; import slowTestGateReporter from './scripts/vitest-slow-test-reporter.ts'; -// Tests that stub a real binary (adb/xcrun) by mutating process.env.PATH and +// Tests that stub a real binary (adb/xcrun/npx) by mutating process.env.PATH and // then spawn it, so each case waits real subprocess/retry/poll time. Run at the // unit suite's default ~7x file parallelism they contend for CPU and their stub // spawns get starved past an internal budget, so production takes a generic @@ -14,6 +14,8 @@ const SUBPROCESS_STUB_TESTS = [ 'src/platforms/android/__tests__/{app-lifecycle-install,app-lifecycle-open,device-input-state,input-actions,notifications,settings}.test.ts', 'src/daemon/__tests__/runtime-hints.test.ts', 'src/platforms/apple/core/__tests__/index.test.ts', + // Stubs npx + the package managers on PATH and spawns a real Metro dev server per case. + 'src/__tests__/client-metro.test.ts', ]; export default defineConfig({ @@ -40,11 +42,14 @@ export default defineConfig({ // job (scripts/maestro-conformance), like the layering guard. ], exclude: SUBPROCESS_STUB_TESTS, - setupFiles: ['src/__tests__/process-memo-setup.ts'], + setupFiles: [ + 'src/__tests__/hermetic-env-setup.ts', + 'src/__tests__/process-memo-setup.ts', + ], }, }, { - // The subprocess-stub tests stub adb/xcrun by mutating process.env + // The subprocess-stub tests stub adb/xcrun/npx by mutating process.env // (PATH, AGENT_DEVICE_TEST_ARGS_FILE) and wait real subprocess/retry/poll // time, so the group runs serialized with per-file isolation — the same // execution contract the pre-split android index.test.ts aggregation @@ -52,7 +57,10 @@ export default defineConfig({ test: { name: 'subprocess-stub', include: SUBPROCESS_STUB_TESTS, - setupFiles: ['src/__tests__/process-memo-setup.ts'], + setupFiles: [ + 'src/__tests__/hermetic-env-setup.ts', + 'src/__tests__/process-memo-setup.ts', + ], fileParallelism: false, isolate: true, maxWorkers: 1, @@ -62,21 +70,30 @@ export default defineConfig({ test: { name: 'provider-integration', include: ['test/integration/provider-scenarios/**/*.test.ts'], - setupFiles: ['src/__tests__/process-memo-setup.ts'], + setupFiles: [ + 'src/__tests__/hermetic-env-setup.ts', + 'src/__tests__/process-memo-setup.ts', + ], }, }, { test: { name: 'interaction-contract', include: ['test/integration/interaction-contract/**/*.test.ts'], - setupFiles: ['src/__tests__/process-memo-setup.ts'], + setupFiles: [ + 'src/__tests__/hermetic-env-setup.ts', + 'src/__tests__/process-memo-setup.ts', + ], }, }, { test: { name: 'output-economy', include: ['test/output-economy/**/*.test.ts'], - setupFiles: ['src/__tests__/process-memo-setup.ts'], + setupFiles: [ + 'src/__tests__/hermetic-env-setup.ts', + 'src/__tests__/process-memo-setup.ts', + ], }, }, ],