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..1974518c1 --- /dev/null +++ b/integ-tests/import.test.ts @@ -0,0 +1,56 @@ +/* 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'; + +/** + * 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: `agentcore import --source ...` does not expose a `--json` flag, so + * assertions read from the combined stdout/stderr stream. + */ +describe('integration: import command', () => { + 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/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); + }); + }); }); 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