diff --git a/.github/workflows/cross-language-integration.yml b/.github/workflows/cross-language-integration.yml index 9ba2a2d7b..2f9c23552 100644 --- a/.github/workflows/cross-language-integration.yml +++ b/.github/workflows/cross-language-integration.yml @@ -23,7 +23,7 @@ jobs: go-version: '1.25' - name: Install dependencies - run: npm install + run: npm ci - name: Install Go dependencies shell: bash diff --git a/.github/workflows/validation.yaml b/.github/workflows/validation.yaml index 7bb00098e..aa48fb336 100644 --- a/.github/workflows/validation.yaml +++ b/.github/workflows/validation.yaml @@ -29,7 +29,7 @@ jobs: python-version: '3.11' - name: Install dependencies - run: npm install + run: npm ci - name: Check for secrets leaks run: npx secretlint "**/*" diff --git a/.gitignore b/.gitignore index 757115323..c3265df07 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ .idea/ .vscode/* !.vscode/settings.json +# Regenerated per test run; pins nothing (see installFixtureDeps). tests/**/package-lock.json coverage/ .env diff --git a/tests/integration/agent_loader/agent_dirname_test.ts b/tests/integration/agent_loader/agent_dirname_test.ts index 13b5acdff..a4c823517 100644 --- a/tests/integration/agent_loader/agent_dirname_test.ts +++ b/tests/integration/agent_loader/agent_dirname_test.ts @@ -4,14 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {exec, spawn} from 'node:child_process'; -import * as fs from 'node:fs/promises'; +import {spawn} from 'node:child_process'; import * as path from 'node:path'; -import {promisify} from 'node:util'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; -import {sendInput} from '../test_case_utils.js'; +import { + cleanupFixtureDeps, + installFixtureDeps, + sendInput, +} from '../test_case_utils.js'; -const execAsync = promisify(exec); const dirname = process.cwd(); const TEST_EXECUTION_TIMEOUT = 40000; @@ -25,7 +26,7 @@ describe.each(['__dirname', '__filename', 'import_meta_url'])( ); beforeAll(async () => { - await execAsync('npm install', {cwd: projectPath}); + await installFixtureDeps(projectPath); }); it( @@ -47,15 +48,7 @@ describe.each(['__dirname', '__filename', 'import_meta_url'])( ); afterAll(async () => { - await fs - .rm(path.join(projectPath, 'node_modules'), { - recursive: true, - force: true, - }) - .catch(() => {}); - await fs - .unlink(path.join(projectPath, 'package-lock.json')) - .catch(() => {}); + await cleanupFixtureDeps(projectPath); }); }, ); diff --git a/tests/integration/app_loader/app_loader_test.ts b/tests/integration/app_loader/app_loader_test.ts index 7d06a3fd8..48f9823c8 100644 --- a/tests/integration/app_loader/app_loader_test.ts +++ b/tests/integration/app_loader/app_loader_test.ts @@ -5,15 +5,16 @@ */ import {App, isApp, isBaseAgent} from '@google/adk'; -import {exec, spawn} from 'node:child_process'; -import * as fs from 'node:fs/promises'; +import {spawn} from 'node:child_process'; import * as path from 'node:path'; -import {promisify} from 'node:util'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; import {AgentLoader} from '../../../dev/src/utils/agent_loader.js'; -import {sendInput} from '../test_case_utils.js'; +import { + cleanupFixtureDeps, + installFixtureDeps, + sendInput, +} from '../test_case_utils.js'; -const execAsync = promisify(exec); const dirname = process.cwd(); const TEST_EXECUTION_TIMEOUT = 40000; @@ -28,7 +29,7 @@ describe('App loader CLI integration', () => { ); beforeAll(async () => { - await execAsync('npm install', {cwd: projectPath}); + await installFixtureDeps(projectPath); }); it( @@ -53,15 +54,7 @@ describe('App loader CLI integration', () => { ); afterAll(async () => { - await fs - .rm(path.join(projectPath, 'node_modules'), { - recursive: true, - force: true, - }) - .catch(() => {}); - await fs - .unlink(path.join(projectPath, 'package-lock.json')) - .catch(() => {}); + await cleanupFixtureDeps(projectPath); }); }, ); @@ -75,7 +68,7 @@ describe('AgentLoader discovery and loading integration', () => { let loader: AgentLoader; beforeAll(async () => { - await execAsync('npm install', {cwd: projectPath}); + await installFixtureDeps(projectPath); loader = new AgentLoader(projectPath); }); @@ -129,14 +122,6 @@ describe('AgentLoader discovery and loading integration', () => { afterAll(async () => { await loader.disposeAll(); - await fs - .rm(path.join(projectPath, 'node_modules'), { - recursive: true, - force: true, - }) - .catch(() => {}); - await fs - .unlink(path.join(projectPath, 'package-lock.json')) - .catch(() => {}); + await cleanupFixtureDeps(projectPath); }); }); diff --git a/tests/integration/build_setup/build_setup_test.ts b/tests/integration/build_setup/build_setup_test.ts index 4a4f1eb53..61f59a5bc 100644 --- a/tests/integration/build_setup/build_setup_test.ts +++ b/tests/integration/build_setup/build_setup_test.ts @@ -7,13 +7,23 @@ import {exec, spawn} from 'node:child_process'; import * as fs from 'node:fs/promises'; import {promisify} from 'node:util'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; -import {getResponse, sendInput} from '../test_case_utils.js'; +import { + cleanupFixtureDeps, + getResponse, + installFixtureDeps, + sendInput, +} from '../test_case_utils.js'; const execAsync = promisify(exec); const dirname = process.cwd(); const TEST_EXECUTION_TIMEOUT = 20000; +/** The subset of a fixture lockfile this suite asserts on. */ +interface FixtureLockfile { + packages: Record; +} + describe('Build setup', () => { describe.each([ 'js_commonjs', @@ -26,7 +36,7 @@ describe('Build setup', () => { const projectPath = `${dirname}/tests/integration/build_setup/${buildSetup}`; beforeAll(async () => { - await execAsync('npm install', {cwd: projectPath}); + await installFixtureDeps(projectPath); if (buildSetup.startsWith('ts_')) { let buildResult; @@ -45,6 +55,19 @@ describe('Build setup', () => { } }); + it('should link the workspace packages rather than pack them', async () => { + const lock: FixtureLockfile = JSON.parse( + await fs.readFile(`${projectPath}/package-lock.json`, 'utf-8'), + ); + + // Not `lstat().isSymbolicLink()`: npm creates junctions on Windows, but + // records `link: true` on every npm major and OS in the matrix. + expect(lock.packages['node_modules/@google/adk']?.link).toBe(true); + expect(lock.packages['node_modules/@google/adk-devtools']?.link).toBe( + true, + ); + }); + it( 'should build and run agent successfully', async () => { @@ -109,15 +132,10 @@ describe('Build setup', () => { ); afterAll(async () => { - await fs - .rm(`${projectPath}/node_modules`, {recursive: true, force: true}) - .catch(() => {}); - await fs.unlink(`${projectPath}/package-lock.json`).catch(() => {}); + await cleanupFixtureDeps(projectPath); if (buildSetup.startsWith('ts_')) { - await fs - .rm(`${projectPath}/dist`, {recursive: true, force: true}) - .catch(() => {}); + await fs.rm(`${projectPath}/dist`, {recursive: true, force: true}); } }); }); diff --git a/tests/integration/skills/script_js/agent_test.ts b/tests/integration/skills/script_js/agent_test.ts index c904b0196..db1001dfe 100644 --- a/tests/integration/skills/script_js/agent_test.ts +++ b/tests/integration/skills/script_js/agent_test.ts @@ -3,13 +3,16 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {exec, spawn} from 'node:child_process'; +import {spawn} from 'node:child_process'; import * as fs from 'node:fs/promises'; -import {promisify} from 'node:util'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; -import {normalizeLineEndings, sendInput} from '../../test_case_utils.js'; +import { + cleanupFixtureDeps, + installFixtureDeps, + normalizeLineEndings, + sendInput, +} from '../../test_case_utils.js'; -const execAsync = promisify(exec); const dirname = process.cwd(); const PROJECT_PATH = `${dirname}/tests/integration/skills/script_js`; const TEST_EXECUTION_TIMEOUT = 60000; @@ -30,7 +33,7 @@ const TEST_EXECUTION_TIMEOUT = 60000; */ describe('Agent with skills that generates JS script and runs it locally', () => { beforeAll(async () => { - await execAsync('npm install', {cwd: PROJECT_PATH}); + await installFixtureDeps(PROJECT_PATH); }); it( @@ -94,15 +97,10 @@ describe('Agent with skills that generates JS script and runs it locally', () => afterAll(async () => { // delete generated files - await fs - .rm(`${PROJECT_PATH}/ephemeral_entanglement.md`, {force: true}) - .catch(() => {}); - await fs.rm(`${PROJECT_PATH}/index.html`, {force: true}).catch(() => {}); - await fs.rm(`${PROJECT_PATH}/sketch.js`, {force: true}).catch(() => {}); + await fs.rm(`${PROJECT_PATH}/ephemeral_entanglement.md`, {force: true}); + await fs.rm(`${PROJECT_PATH}/index.html`, {force: true}); + await fs.rm(`${PROJECT_PATH}/sketch.js`, {force: true}); - await fs - .rm(`${PROJECT_PATH}/node_modules`, {recursive: true, force: true}) - .catch(() => {}); - await fs.unlink(`${PROJECT_PATH}/package-lock.json`).catch(() => {}); + await cleanupFixtureDeps(PROJECT_PATH); }); }); diff --git a/tests/integration/test_case_utils.ts b/tests/integration/test_case_utils.ts index ad5088fc0..1df9b0352 100644 --- a/tests/integration/test_case_utils.ts +++ b/tests/integration/test_case_utils.ts @@ -18,9 +18,14 @@ import { GenerateContentResponse, GoogleGenAI, } from '@google/genai'; -import {ChildProcessWithoutNullStreams} from 'node:child_process'; +import {ChildProcessWithoutNullStreams, exec} from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import {promisify} from 'node:util'; import {expect} from 'vitest'; +const execAsync = promisify(exec); + /** * Represents a raw generate content response. */ @@ -363,6 +368,40 @@ export abstract class BaseTestServer { } } +/** + * Installs an integration fixture's dependencies. + * + * `--install-links=false` is pinned rather than left to the contributor's npm, + * whose default for it flipped between majors: linking the `file:`-specified + * `core` and `dev` workspaces measured 0.6s against 64s for packing their + * transitive graph, and only the link mode resolves that graph through the + * repo-root `node_modules` that the committed root `package-lock.json` pins. + * `--no-audit --no-fund` just drop registry chatter no test asserts on. + * + * Rejects if npm fails, so a broken fixture install surfaces as a hook failure. + */ +export async function installFixtureDeps(projectPath: string): Promise { + await execAsync('npm install --install-links=false --no-audit --no-fund', { + cwd: projectPath, + }); +} + +/** + * Removes what {@link installFixtureDeps} generated, so no fixture leaves + * `node_modules` or a lockfile in the working tree. + * + * `force` ignores an install that never got that far, but anything else -- a + * locked file, a permission error -- is left to reject, so a fixture that + * cannot be cleaned fails its `afterAll` instead of polluting the next run. + */ +export async function cleanupFixtureDeps(projectPath: string): Promise { + await fs.rm(path.join(projectPath, 'node_modules'), { + recursive: true, + force: true, + }); + await fs.rm(path.join(projectPath, 'package-lock.json'), {force: true}); +} + export function sendInput( childProcess: ChildProcessWithoutNullStreams, input: string, diff --git a/vitest.config.ts b/vitest.config.ts index 8cbf9bea9..90cad50ac 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,9 +13,9 @@ import {defineConfig} from 'vitest/config'; * matching `afterAll` hooks recursively remove the resulting `node_modules`. * That exceeds Vitest's 10s default on a slow or loaded machine. * - * The twelve `build_setup` hook runs take ~16s combined warm on ubuntu-latest, - * but a cold, network-bound install has been measured at ~70s, so 120s covers - * the worst case. Trade-off: a genuinely stuck hook takes this long to surface. + * The install is neither the dominant cost nor network-bound any more -- see + * `installFixtureDeps`; `npm run build` for the `ts_*` fixtures is what remains. + * Trade-off: a genuinely stuck hook takes this long to surface. * * This is the only definition of that budget. Suites must not pass a timeout * argument to an install or teardown hook: it shadows this floor rather than