Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions integ-tests/add-remove-runtime-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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<string, unknown>;
}

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 "<name>" already exists on runtime "<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 "<name>" not found.`
expect(String(json.error)).toMatch(/not found|nonexistent/i);
});
});
56 changes: 56 additions & 0 deletions integ-tests/import.test.ts
Original file line number Diff line number Diff line change
@@ -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: <path>`
// 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);
});
});
77 changes: 76 additions & 1 deletion integ-tests/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
): Promise<void> {
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;

Expand Down Expand Up @@ -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 `<projectRoot>/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);
});
});
});
133 changes: 133 additions & 0 deletions scripts/run-e2e-local.sh
Original file line number Diff line number Diff line change
@@ -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::<account>:role/<role-name>"
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:<region>:<account>:secret:<name>"
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
Loading