From 9ab5cde2c6c5f56e7251ec8eea7fe539314e0fd7 Mon Sep 17 00:00:00 2001 From: "T.J Ariyawansa" Date: Mon, 4 May 2026 22:34:03 +0000 Subject: [PATCH 1/4] test: add phase 1 missing integration tests --- .../add-remove-runtime-endpoint.test.ts | 89 +++++++++++++++++++ integ-tests/import.test.ts | 58 ++++++++++++ integ-tests/tag.test.ts | 40 +++++++++ integ-tests/validate.test.ts | 77 +++++++++++++++- 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 integ-tests/add-remove-runtime-endpoint.test.ts create mode 100644 integ-tests/import.test.ts create mode 100644 integ-tests/tag.test.ts diff --git a/integ-tests/add-remove-runtime-endpoint.test.ts b/integ-tests/add-remove-runtime-endpoint.test.ts new file mode 100644 index 000000000..4a24abfef --- /dev/null +++ b/integ-tests/add-remove-runtime-endpoint.test.ts @@ -0,0 +1,89 @@ +import { + type TestProject, + createTestProject, + parseJsonOutput, + readProjectConfig, + runCLI, +} from '../src/test-utils/index.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +async function runSuccess(args: string[], cwd: string) { + const result = await runCLI(args, cwd); + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', true); + return json as Record; +} + +async function runFailure(args: string[], cwd: string) { + const result = await runCLI(args, cwd); + expect(result.exitCode).toBe(1); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', false); + expect(json).toHaveProperty('error'); + return json as Record; +} + +describe('integration: add and remove runtime-endpoint', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + name: 'RuntimeEP', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }, 120_000); + + afterAll(async () => { + await project.cleanup(); + }); + + it('adds an endpoint to a runtime and writes it to agentcore.json', async () => { + await runSuccess( + ['add', 'runtime-endpoint', '--runtime', project.agentName, '--endpoint', 'prod', '--version', '1', '--json'], + project.projectPath + ); + + const spec = await readProjectConfig(project.projectPath); + const runtime = spec.runtimes.find(r => r.name === project.agentName); + expect(runtime).toBeDefined(); + expect(runtime!.endpoints).toBeDefined(); + expect(runtime!.endpoints!.prod).toBeDefined(); + expect(runtime!.endpoints!.prod!.version).toBe(1); + }); + + it('rejects duplicate endpoint name', async () => { + const json = await runFailure( + ['add', 'runtime-endpoint', '--runtime', project.agentName, '--endpoint', 'prod', '--version', '1', '--json'], + project.projectPath + ); + + // RuntimeEndpointPrimitive returns: `Endpoint "" already exists on runtime "".` + expect(String(json.error)).toMatch(/already exists|prod|duplicate/i); + }); + + it('removes endpoint from runtime', async () => { + await runSuccess( + ['remove', 'runtime-endpoint', '--name', `${project.agentName}/prod`, '--yes', '--json'], + project.projectPath + ); + + const spec = await readProjectConfig(project.projectPath); + const runtime = spec.runtimes.find(r => r.name === project.agentName); + expect(runtime).toBeDefined(); + expect(runtime!.endpoints?.prod).toBeUndefined(); + }); + + it('returns error when removing non-existent endpoint', async () => { + const json = await runFailure( + ['remove', 'runtime-endpoint', '--name', `${project.agentName}/nonexistent`, '--yes', '--json'], + project.projectPath + ); + + // RuntimeEndpointPrimitive returns: `Runtime endpoint "" not found.` + expect(String(json.error)).toMatch(/not found|nonexistent/i); + }); +}); diff --git a/integ-tests/import.test.ts b/integ-tests/import.test.ts new file mode 100644 index 000000000..d1d1064c8 --- /dev/null +++ b/integ-tests/import.test.ts @@ -0,0 +1,58 @@ +/* eslint-disable security/detect-non-literal-fs-filename */ +import { type TestProject, createTestProject, runCLI } from '../src/test-utils/index.js'; +import { randomUUID } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * These tests only exercise the Phase-1-local paths of `agentcore import` that + * do NOT touch AWS. See src/cli/commands/import/actions.ts: AWS calls (notably + * `validateAwsCredentials()`) only fire when the parsed YAML contains a + * physical `agent_id` or `memory_id`. All fixtures here omit those fields. + * + * Note: the top-level `agentcore import --source ...` command does not expose a + * `--json` flag (only the import-subcommands do), so assertions read from the + * combined stdout/stderr stream. + */ +describe('integration: import command (Phase 1 — no AWS)', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + name: 'ImportTest', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }, 120_000); + + afterAll(async () => { + await project.cleanup(); + }); + + it('returns error when source file does not exist', async () => { + const missingPath = `/tmp/agentcore-missing-${randomUUID()}.yaml`; + const result = await runCLI(['import', '--source', missingPath, '--yes'], project.projectPath); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + // src/cli/commands/import/command.ts logs `Source file not found: ` + // via console.error before calling handleImport. + expect(output).toMatch(/not found|ENOENT|no such file/i); + }); + + it('returns error when YAML parses to zero agents', async () => { + // A comment-only YAML parses cleanly but yields { agents: [] }. handleImport + // then returns `No agents found in the YAML config` (see actions.ts step 3). + const brokenPath = join(project.projectPath, 'broken.yaml'); + await writeFile(brokenPath, '# comment only\n', 'utf-8'); + + const result = await runCLI(['import', '--source', brokenPath, '--yes'], project.projectPath); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toMatch(/No agents|agents/i); + }); +}); diff --git a/integ-tests/tag.test.ts b/integ-tests/tag.test.ts new file mode 100644 index 000000000..6f6d422d5 --- /dev/null +++ b/integ-tests/tag.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from 'vitest'; + +/** + * BLOCKED: The `tag` command has `src/cli/commands/tag/action.ts` + `types.ts` + * but NO `command.ts` and NO `index.ts`. It is NOT exported from + * `src/cli/commands/index.ts`, so `agentcore tag ...` is currently an unknown + * command and cannot be reached via the CLI. + * + * Enable this suite once the `tag` subcommand is wired up: + * 1. Add `src/cli/commands/tag/command.ts` that registers subcommands on the + * Commander `program`. + * 2. Add `src/cli/commands/tag/index.ts` that re-exports `registerTag`. + * 3. Export `registerTag` from `src/cli/commands/index.ts` and wire it in + * `src/cli/cli.ts`. + * + * Planned integration coverage (see src/cli/commands/tag/action.ts for the + * underlying action handlers): + * - addTag: `agentcore tag add --tag = --json` + * - removeTag: `agentcore tag remove --tag --json` + * - listTags: `agentcore tag list --json` + * - setDefaultTag: `agentcore tag set-default --tag = --json` + * - removeDefaultTag: `agentcore tag remove-default --tag --json` + */ +describe.skip('integration: tag command (BLOCKED — command not registered)', () => { + it.skip('addTag writes tag to agentcore.json', () => { + // TODO: implement once src/cli/commands/tag/command.ts and index.ts exist + }); + it.skip('removeTag deletes tag from agentcore.json', () => { + // TODO: implement once the tag command is wired into the CLI + }); + it.skip('listTags returns JSON array of tags', () => { + // TODO: implement once the tag command is wired into the CLI + }); + it.skip('setDefaultTag writes default tag to agentcore.json', () => { + // TODO: implement once the tag command is wired into the CLI + }); + it.skip('removeDefaultTag deletes default tag from agentcore.json', () => { + // TODO: implement once the tag command is wired into the CLI + }); +}); diff --git a/integ-tests/validate.test.ts b/integ-tests/validate.test.ts index 56ad03f16..c50fc843b 100644 --- a/integ-tests/validate.test.ts +++ b/integ-tests/validate.test.ts @@ -2,11 +2,32 @@ import { createTestProject, runCLI } from '../src/test-utils/index.js'; import type { TestProject } from '../src/test-utils/index.js'; import { randomUUID } from 'node:crypto'; -import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +/** + * Overwrite a file's content for the duration of `fn`, then restore the + * original contents — even if `fn` throws. Used to inject a broken config + * into the shared project fixture without polluting later tests. + */ +async function withTempFileContent( + projectPath: string, + relPath: string, + newContent: string, + fn: () => Promise +): Promise { + const full = join(projectPath, relPath); + const original = await readFile(full, 'utf-8'); + try { + await writeFile(full, newContent, 'utf-8'); + await fn(); + } finally { + await writeFile(full, original, 'utf-8'); + } +} + describe('integration: validate command', () => { let project: TestProject; @@ -66,4 +87,58 @@ describe('integration: validate command', () => { await rm(emptyDir, { recursive: true, force: true }); } }); + + it('reports error for corrupted aws-targets.json', async () => { + await withTempFileContent(project.projectPath, 'agentcore/aws-targets.json', '{invalid json!!!', async () => { + const result = await runCLI(['validate'], project.projectPath); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('aws-targets.json'); + }); + }); + + it('reports error for corrupted deployed-state.json', async () => { + // Fresh projects don't include deployed-state.json. The path resolver + // places it at `/agentcore/.cli/deployed-state.json` + // (see src/lib/schemas/io/path-resolver.ts:getStatePath). + const stateDir = join(project.projectPath, 'agentcore', '.cli'); + const statePath = join(stateDir, 'deployed-state.json'); + try { + await mkdir(stateDir, { recursive: true }); + await writeFile(statePath, '{invalid}', 'utf-8'); + + const result = await runCLI(['validate'], project.projectPath); + + expect(result.exitCode).toBe(1); + // formatError labels this file as `.cli/state.json` regardless of actual + // filename (see src/cli/commands/validate/action.ts). + const output = result.stdout + result.stderr; + expect(output).toContain('.cli/state.json'); + } finally { + await rm(statePath, { force: true }); + } + }); + + it('reports error for empty aws-targets.json', async () => { + await withTempFileContent(project.projectPath, 'agentcore/aws-targets.json', '', async () => { + const result = await runCLI(['validate'], project.projectPath); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('aws-targets.json'); + }); + }); + + it('reports error for invalid schema in aws-targets.json', async () => { + const badSchema = JSON.stringify([{ name: 123, account: true, region: [] }]); + await withTempFileContent(project.projectPath, 'agentcore/aws-targets.json', badSchema, async () => { + const result = await runCLI(['validate'], project.projectPath); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + // ConfigValidationError emits a zod issue summary; we don't pin exact text. + expect(output.length, 'Should produce error output').toBeGreaterThan(0); + }); + }); }); From 3984163795ffb0311d1713c92e19dff9d3cc8c2c Mon Sep 17 00:00:00 2001 From: Local E2E Date: Mon, 4 May 2026 22:58:37 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test:=20remove=20tag=20placeholder=20test?= =?UTF-8?q?=20=E2=80=94=20command=20not=20yet=20registered=20in=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- integ-tests/tag.test.ts | 40 ---------------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 integ-tests/tag.test.ts diff --git a/integ-tests/tag.test.ts b/integ-tests/tag.test.ts deleted file mode 100644 index 6f6d422d5..000000000 --- a/integ-tests/tag.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it } from 'vitest'; - -/** - * BLOCKED: The `tag` command has `src/cli/commands/tag/action.ts` + `types.ts` - * but NO `command.ts` and NO `index.ts`. It is NOT exported from - * `src/cli/commands/index.ts`, so `agentcore tag ...` is currently an unknown - * command and cannot be reached via the CLI. - * - * Enable this suite once the `tag` subcommand is wired up: - * 1. Add `src/cli/commands/tag/command.ts` that registers subcommands on the - * Commander `program`. - * 2. Add `src/cli/commands/tag/index.ts` that re-exports `registerTag`. - * 3. Export `registerTag` from `src/cli/commands/index.ts` and wire it in - * `src/cli/cli.ts`. - * - * Planned integration coverage (see src/cli/commands/tag/action.ts for the - * underlying action handlers): - * - addTag: `agentcore tag add --tag = --json` - * - removeTag: `agentcore tag remove --tag --json` - * - listTags: `agentcore tag list --json` - * - setDefaultTag: `agentcore tag set-default --tag = --json` - * - removeDefaultTag: `agentcore tag remove-default --tag --json` - */ -describe.skip('integration: tag command (BLOCKED — command not registered)', () => { - it.skip('addTag writes tag to agentcore.json', () => { - // TODO: implement once src/cli/commands/tag/command.ts and index.ts exist - }); - it.skip('removeTag deletes tag from agentcore.json', () => { - // TODO: implement once the tag command is wired into the CLI - }); - it.skip('listTags returns JSON array of tags', () => { - // TODO: implement once the tag command is wired into the CLI - }); - it.skip('setDefaultTag writes default tag to agentcore.json', () => { - // TODO: implement once the tag command is wired into the CLI - }); - it.skip('removeDefaultTag deletes default tag from agentcore.json', () => { - // TODO: implement once the tag command is wired into the CLI - }); -}); From e18008f79c2ad772d4fa3af75747b3cc0b385429 Mon Sep 17 00:00:00 2001 From: Local E2E Date: Tue, 5 May 2026 15:44:05 +0000 Subject: [PATCH 3/4] test: remove phase reference from import test describe block --- integ-tests/import.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/integ-tests/import.test.ts b/integ-tests/import.test.ts index d1d1064c8..1974518c1 100644 --- a/integ-tests/import.test.ts +++ b/integ-tests/import.test.ts @@ -6,16 +6,14 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; /** - * These tests only exercise the Phase-1-local paths of `agentcore import` that - * do NOT touch AWS. See src/cli/commands/import/actions.ts: AWS calls (notably - * `validateAwsCredentials()`) only fire when the parsed YAML contains a - * physical `agent_id` or `memory_id`. All fixtures here omit those fields. + * Tests for `agentcore import` error paths that run without AWS credentials. + * AWS calls only fire when the parsed YAML contains a physical `agent_id` or + * `memory_id` — all fixtures here omit those fields. * - * Note: the top-level `agentcore import --source ...` command does not expose a - * `--json` flag (only the import-subcommands do), so assertions read from the - * combined stdout/stderr stream. + * Note: `agentcore import --source ...` does not expose a `--json` flag, so + * assertions read from the combined stdout/stderr stream. */ -describe('integration: import command (Phase 1 — no AWS)', () => { +describe('integration: import command', () => { let project: TestProject; beforeAll(async () => { From b7b79f90621d99c9a890dda9a02b65384bda886b Mon Sep 17 00:00:00 2001 From: Local E2E Date: Tue, 5 May 2026 16:36:26 +0000 Subject: [PATCH 4/4] chore: add run-e2e-local.sh script for running integ and e2e tests locally --- scripts/run-e2e-local.sh | 133 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100755 scripts/run-e2e-local.sh diff --git a/scripts/run-e2e-local.sh b/scripts/run-e2e-local.sh new file mode 100755 index 000000000..ee43e68f6 --- /dev/null +++ b/scripts/run-e2e-local.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Run integration or E2E tests locally. +# +# INTEG MODE (no AWS required): +# ./scripts/run-e2e-local.sh --integ # all integ tests +# ./scripts/run-e2e-local.sh --integ integ-tests/foo.test.ts # specific file +# +# E2E MODE (requires AWS role + secrets): +# Required env vars: +# E2E_ROLE_ARN — IAM role ARN to assume +# E2E_SECRET_ARN — Secrets Manager ARN with ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY +# Optional env vars: +# AWS_REGION — defaults to us-east-1 +# +# ./scripts/run-e2e-local.sh # runs strands-bedrock.test.ts (default) +# ./scripts/run-e2e-local.sh --all # runs the full e2e suite +# ./scripts/run-e2e-local.sh e2e-tests/foo.test.ts # runs a specific e2e file +# +# Prerequisites: aws CLI, node >=20.19, npm, git, uv, jq + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ── Parse arguments ───────────────────────────────────────────────────────────── +RUN_ALL=false +RUN_INTEG=false +TEST_FILES=() +for arg in "$@"; do + case "$arg" in + --all) RUN_ALL=true ;; + --integ) RUN_INTEG=true ;; + *) TEST_FILES+=("$arg") ;; + esac +done + +cd "$REPO_ROOT" + +# ── Integ mode — no AWS needed ─────────────────────────────────────────────────── +if [[ "$RUN_INTEG" == "true" ]]; then + echo "=== Installing dependencies ===" + npm ci + + echo "=== Building CLI ===" + npm run build + + echo "=== Running integration tests ===" + if [[ ${#TEST_FILES[@]} -gt 0 ]]; then + echo "Running: ${TEST_FILES[*]}" + npx vitest run --project integ "${TEST_FILES[@]}" + else + echo "Running all integ tests" + npx vitest run --project integ + fi + exit 0 +fi + +# ── E2E mode — requires AWS ─────────────────────────────────────────────────────── +ROLE_ARN="${E2E_ROLE_ARN:-}" +SECRET_ARN="${E2E_SECRET_ARN:-}" +AWS_REGION="${AWS_REGION:-us-east-1}" + +if [[ -z "$ROLE_ARN" ]]; then + echo "❌ E2E_ROLE_ARN is not set. Export it before running this script:" + echo " export E2E_ROLE_ARN=arn:aws:iam:::role/" + exit 1 +fi + +if [[ -z "$SECRET_ARN" ]]; then + echo "❌ E2E_SECRET_ARN is not set. Export it before running this script:" + echo " export E2E_SECRET_ARN=arn:aws:secretsmanager:::secret:" + exit 1 +fi + +echo "=== Assuming IAM role ===" +CREDS=$(aws sts assume-role \ + --role-arn "$ROLE_ARN" \ + --role-session-name "local-e2e-$(date +%s)" \ + --duration-seconds 3600 \ + --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' \ + --output text) + +export AWS_ACCESS_KEY_ID=$(echo "$CREDS" | awk '{print $1}') +export AWS_SECRET_ACCESS_KEY=$(echo "$CREDS" | awk '{print $2}') +export AWS_SESSION_TOKEN=$(echo "$CREDS" | awk '{print $3}') +export AWS_REGION + +echo "✅ Assumed role successfully" + +echo "=== Fetching API keys from Secrets Manager ===" +SECRET_JSON=$(aws secretsmanager get-secret-value \ + --secret-id "$SECRET_ARN" \ + --region "$AWS_REGION" \ + --query SecretString \ + --output text) + +export ANTHROPIC_API_KEY=$(echo "$SECRET_JSON" | jq -r '.ANTHROPIC_API_KEY // empty') +export OPENAI_API_KEY=$(echo "$SECRET_JSON" | jq -r '.OPENAI_API_KEY // empty') +export GEMINI_API_KEY=$(echo "$SECRET_JSON" | jq -r '.GEMINI_API_KEY // empty') + +echo "✅ Secrets loaded (keys present: $(echo "$SECRET_JSON" | jq -r 'keys | join(", ")')" + +echo "=== Setting AWS account env var ===" +export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +echo "✅ AWS_ACCOUNT_ID=$AWS_ACCOUNT_ID AWS_REGION=$AWS_REGION" + +echo "=== Configuring git (required for agentcore create) ===" +git config --global user.email "ci@local" 2>/dev/null || true +git config --global user.name "Local E2E" 2>/dev/null || true + +echo "=== Installing dependencies ===" +npm ci + +echo "=== Building CLI ===" +npm run build + +echo "=== Installing CLI globally ===" +TARBALL=$(npm pack | tail -1) +npm install -g "$TARBALL" +echo "✅ Installed: $(agentcore --version)" + +echo "=== Running E2E tests ===" +if [[ "$RUN_ALL" == "true" ]]; then + echo "Running full e2e suite" + npx vitest run --project e2e +elif [[ ${#TEST_FILES[@]} -gt 0 ]]; then + echo "Running: ${TEST_FILES[*]}" + npx vitest run --project e2e "${TEST_FILES[@]}" +else + echo "Running default: e2e-tests/strands-bedrock.test.ts" + npx vitest run --project e2e e2e-tests/strands-bedrock.test.ts +fi