diff --git a/e2eTest/README.md b/e2eTest/README.md index f26311ff17..bcd1e4aedf 100644 --- a/e2eTest/README.md +++ b/e2eTest/README.md @@ -9,6 +9,10 @@ export AGENTCORE_CLI_PATH="node $PWD/dist/index.js" bun run test:e2e ``` +Local `dev` for container templates (for example `agent-python-strands-container`) needs a +container runtime with a reachable daemon (Docker, Podman, or Finch). Without one, their +"runs locally" cases are skipped; deploy and cloud invoke still run. + To run tagged tests: ```sh diff --git a/e2eTest/helpers/container.ts b/e2eTest/helpers/container.ts new file mode 100644 index 0000000000..44a3bd4e4c --- /dev/null +++ b/e2eTest/helpers/container.ts @@ -0,0 +1,18 @@ +import { spawnSync } from "node:child_process"; + +/** The container tools `agentcore dev` accepts for Container-build runtimes, in its probe order. */ +const CONTAINER_TOOLS = ["docker", "podman", "finch"] as const; +const PROBE_TIMEOUT_MS = 15_000; + +/** + * Given the current host, returns the first container tool whose daemon is reachable. + * ` info` needs a running daemon, unlike `--version`, which also succeeds on + * hosts that ship only the CLI (for example CodeBuild images without privileged mode). + */ +export function detectContainerTool(): (typeof CONTAINER_TOOLS)[number] | undefined { + for (const tool of CONTAINER_TOOLS) { + const probe = spawnSync(tool, ["info"], { stdio: "ignore", timeout: PROBE_TIMEOUT_MS }); + if (!probe.error && probe.status === 0) return tool; + } + return undefined; +} diff --git a/e2eTest/helpers/retry.ts b/e2eTest/helpers/retry.ts index cff42515c9..f984a2067d 100644 --- a/e2eTest/helpers/retry.ts +++ b/e2eTest/helpers/retry.ts @@ -1,3 +1,6 @@ +/** An error that `retry` rethrows immediately instead of retrying until the deadline. */ +export class NonRetryableError extends Error {} + /** Given an async operation, retries it until success or the timeout expires. */ export async function retry( operation: () => Promise, @@ -11,6 +14,7 @@ export async function retry( try { return await operation(); } catch (error) { + if (error instanceof NonRetryableError) throw error; lastError = error; const remainingMs = deadline - Date.now(); if (remainingMs <= 0) throw lastError; diff --git a/e2eTest/project/templates.test.ts b/e2eTest/project/templates.test.ts index 0f0edb729d..44770d92c0 100644 --- a/e2eTest/project/templates.test.ts +++ b/e2eTest/project/templates.test.ts @@ -5,12 +5,15 @@ import { join } from "node:path"; import z from "zod"; import { E2E_PREFIX, TAGS } from "../constants"; import { CliRunner, parseResult, type RunResult } from "../helpers/run"; -import { retry } from "../helpers/retry"; +import { detectContainerTool } from "../helpers/container"; +import { NonRetryableError, retry } from "../helpers/retry"; import { TIMEOUT_MS } from "../timeouts"; type RuntimeTemplateTestCase = { name: string; template: string; + /** Container templates need a container runtime on the host for local `dev`. */ + build: "CodeZip" | "Container"; protocol: "HTTP" | "MCP" | "A2A" | "AGUI"; payload: Record; invokeFlags?: string[]; @@ -20,42 +23,49 @@ const RUNTIME_TEMPLATES: RuntimeTemplateTestCase[] = [ { name: "agent_python_minimal", template: "agent-python-minimal", + build: "CodeZip", protocol: "HTTP", payload: { prompt: "Reply with a short greeting." }, }, { name: "agent_python_strands", template: "agent-python-strands", + build: "CodeZip", protocol: "HTTP", payload: { prompt: "Reply with a short greeting." }, }, { name: "py_strands_container", template: "agent-python-strands-container", + build: "Container", protocol: "HTTP", payload: { prompt: "Reply with a short greeting." }, }, { name: "agent_python_langchain", template: "agent-python-langchain", + build: "CodeZip", protocol: "HTTP", payload: { prompt: "Reply with a short greeting." }, }, { name: "agent_ts_strands", template: "agent-typescript-strands", + build: "CodeZip", protocol: "HTTP", payload: { prompt: "Reply with a short greeting." }, }, { name: "agent_ts_vercel", template: "agent-typescript-vercel", + build: "CodeZip", protocol: "HTTP", payload: { prompt: "Reply with a short greeting." }, }, { name: "mcp_python_fastmcp", template: "mcp-python-fastmcp", + build: "CodeZip", protocol: "MCP", payload: { jsonrpc: "2.0", @@ -79,6 +89,7 @@ const RUNTIME_TEMPLATES: RuntimeTemplateTestCase[] = [ { name: "a2a_python_strands", template: "a2a-python-strands", + build: "CodeZip", protocol: "A2A", payload: { jsonrpc: "2.0", @@ -96,6 +107,7 @@ const RUNTIME_TEMPLATES: RuntimeTemplateTestCase[] = [ { name: "agui_python_strands", template: "agui-python-strands", + build: "CodeZip", protocol: "AGUI", payload: { threadId: "agentcore-e2e", @@ -177,11 +189,13 @@ describe( describe("local invocation", { sequential: true }, () => { const runtimePorts = new Map(); + const runtimeFailures = new Map(); + const containerTool = detectContainerTool(); let dev: ReturnType | undefined; let pendingOutput = ""; let devOutput = ""; - /** Given dev-process output, records the ports announced by running runtimes. */ + /** Given dev-process output, records announced ports and startup failures per runtime. */ const captureDevOutput = (chunk: Buffer) => { const text = chunk.toString(); devOutput += text; @@ -189,10 +203,13 @@ describe( const lines = pendingOutput.split(/\r?\n/); pendingOutput = lines.pop() ?? ""; - // parse the out for the ports each agent is running on + // parse the output for the ports each agent is running on, and for agents + // the supervisor gave up on (dev keeps serving the others, so it never exits) for (const line of lines) { - const match = line.match(/Agent '([^']+)' is running on port (\d+)\./); - if (match?.[1] && match[2]) runtimePorts.set(match[1], Number(match[2])); + const running = line.match(/Agent '([^']+)' is running on port (\d+)\./); + if (running?.[1] && running[2]) runtimePorts.set(running[1], Number(running[2])); + const failed = line.match(/Agent '([^']+)' failed to start: (.*)$/); + if (failed?.[1] && failed[2]) runtimeFailures.set(failed[1], failed[2]); } }; @@ -210,10 +227,14 @@ describe( await new Promise((resolve) => dev?.once("close", resolve)); }); - test.each(RUNTIME_TEMPLATES)( + test.for(RUNTIME_TEMPLATES)( "$name runs locally", { concurrent: true, timeout: TIMEOUT_MS.PROJECT_INVOKE }, - async (runtime) => { + async (runtime, { skip }) => { + skip( + runtime.build === "Container" && !containerTool, + "no container runtime (docker, podman, or finch) is available on this host", + ); const sessionId = getSessionId(`${runtime.name}`); // The server may take a bit to get ready, so we retry on a timeout. @@ -227,6 +248,13 @@ describe( ); } + const failure = runtimeFailures.get(runtime.name); + if (failure) { + throw new NonRetryableError( + `Runtime '${runtime.name}' failed to start: ${failure}\nstdout/stderr = ${devOutput}`, + ); + } + const port = runtimePorts.get(runtime.name); if (!port) { throw new Error(