From 2dcb3534479c88e4f0d23596d9614dd206489aac Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 30 Mar 2026 21:22:32 +0000 Subject: [PATCH] feat: add e2e tests for dev server lifecycle --- e2e-tests/dev-lifecycle.test.ts | 110 ++++++++++++++++++++++++++++++++ src/test-utils/cli-runner.ts | 12 +++- src/test-utils/index.ts | 2 +- 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 e2e-tests/dev-lifecycle.test.ts diff --git a/e2e-tests/dev-lifecycle.test.ts b/e2e-tests/dev-lifecycle.test.ts new file mode 100644 index 00000000..ee2632da --- /dev/null +++ b/e2e-tests/dev-lifecycle.test.ts @@ -0,0 +1,110 @@ +import { waitForServerReady } from '../src/cli/operations/dev/utils.js'; +import { cleanSpawnEnv, parseJsonOutput, spawnAndCollect } from '../src/test-utils/index.js'; +import { baseCanRun as canRun, runAgentCoreCLI } from './e2e-helper.js'; +import { type ChildProcess, spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +const DEV_SERVER_PORT = 18080; +const DEV_SERVER_PORT_STR = String(DEV_SERVER_PORT); + +describe.sequential('e2e: dev server lifecycle', () => { + let testDir: string; + let projectPath: string; + let serverProcess: ChildProcess | null = null; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-dev-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const result = await runAgentCoreCLI( + [ + 'create', + '--name', + 'DevTest', + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir + ); + expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0); + projectPath = z.object({ projectPath: z.string() }).parse(parseJsonOutput(result.stdout)).projectPath; + }, 120000); + + afterAll(async () => { + if (serverProcess?.pid) { + try { + process.kill(-serverProcess.pid, 'SIGTERM'); + } catch { + // Process group already exited + } + await new Promise(resolve => { + serverProcess?.on('exit', () => resolve()); + setTimeout(resolve, 5000); + }); + serverProcess = null; + } + if (testDir) { + await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + } + }, 30000); + + it.skipIf(!canRun)( + 'dev --logs starts the server and accepts connections', + async () => { + serverProcess = spawn('agentcore', ['dev', '--logs', '--port', DEV_SERVER_PORT_STR], { + cwd: projectPath, + stdio: 'pipe', + detached: true, + env: cleanSpawnEnv(), + }); + + const ready = await waitForServerReady(DEV_SERVER_PORT, 90000); + expect(ready, 'Dev server should accept connections').toBe(true); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'dev invokes the running server and returns a response', + async () => { + const result = await spawnAndCollect( + 'agentcore', + ['dev', 'What is 2 plus 2? Reply with just the number.', '--port', DEV_SERVER_PORT_STR], + projectPath + ); + + expect(result.exitCode, `Invoke failed (exit ${result.exitCode}): ${result.stderr}`).toBe(0); + expect(result.stdout.length, 'Should produce a response').toBeGreaterThan(0); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'dev --stream returns a response', + async () => { + const result = await spawnAndCollect( + 'agentcore', + ['dev', 'Say hello', '--stream', '--port', DEV_SERVER_PORT_STR], + projectPath + ); + + expect(result.exitCode, `Stream invoke failed (exit ${result.exitCode}): ${result.stderr}`).toBe(0); + expect(result.stdout.length, 'Should produce output').toBeGreaterThan(0); + }, + 60000 + ); +}); diff --git a/src/test-utils/cli-runner.ts b/src/test-utils/cli-runner.ts index b289bdbf..10cf1bbf 100644 --- a/src/test-utils/cli-runner.ts +++ b/src/test-utils/cli-runner.ts @@ -13,6 +13,16 @@ export interface RunResult { exitCode: number; } +/** + * Build a clean env for spawned CLI processes. + * Strips INIT_CWD which npm/npx sets to the runner's directory — without this, + * the CLI resolves the working directory from INIT_CWD instead of the spawn's cwd. + * @see https://docs.npmjs.com/cli/v10/commands/npm-run-script + */ +export function cleanSpawnEnv(extraEnv: Record = {}): NodeJS.ProcessEnv { + return { ...process.env, INIT_CWD: undefined, ...extraEnv }; +} + /** * Spawn a command, collect output, and strip ANSI codes. */ @@ -25,7 +35,7 @@ export function spawnAndCollect( return new Promise(resolve => { const proc = spawn(command, args, { cwd, - env: { ...process.env, INIT_CWD: undefined, ...extraEnv }, + env: cleanSpawnEnv(extraEnv), }); let stdout = ''; diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts index b82ed798..ff127a35 100644 --- a/src/test-utils/index.ts +++ b/src/test-utils/index.ts @@ -3,7 +3,7 @@ * Import these helpers instead of duplicating code in each test file. */ -export { runCLI, spawnAndCollect, type RunResult } from './cli-runner.js'; +export { runCLI, spawnAndCollect, cleanSpawnEnv, type RunResult } from './cli-runner.js'; export { exists } from './fs-helpers.js'; export { hasCommand, hasAwsCredentials, prereqs } from './prereqs.js'; export { createTestProject, type TestProject, type CreateTestProjectOptions } from './project-factory.js';