Skip to content

Commit 7335079

Browse files
committed
feat(tests): add E2E and integ tests for Harness primitive
E2E tests verify create/deploy/invoke/status against real AWS for Bedrock, OpenAI, and Gemini providers using a factory pattern that mirrors the existing agent E2E suite. Integ tests cover the add/remove lifecycle, configuration options (truncation, lifecycle, model provider), validation errors, and project scaffolding. Harness-bedrock is added to the PR E2E baseline alongside strands-bedrock so every PR exercises the harness deploy path.
1 parent 8a4ea58 commit 7335079

6 files changed

Lines changed: 380 additions & 2 deletions

File tree

.github/workflows/e2e-tests.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ jobs:
101101
BASE_SHA=${{ github.event.pull_request.base.sha || 'HEAD~1' }}
102102
CHANGED=$(git diff --name-only "$BASE_SHA"..HEAD -- 'e2e-tests/*.test.ts' \
103103
| grep -v '^e2e-tests/strands-bedrock\.test\.ts$' \
104+
| grep -v '^e2e-tests/harness-bedrock\.test\.ts$' \
104105
| tr '\n' ' ')
105106
echo "extra_tests=$CHANGED" >> "$GITHUB_OUTPUT"
106107
echo "Changed e2e tests: ${CHANGED:-none}"
@@ -113,5 +114,7 @@ jobs:
113114
OPENAI_API_KEY: ${{ env.E2E_OPENAI_API_KEY }}
114115
GEMINI_API_KEY: ${{ env.E2E_GEMINI_API_KEY }}
115116
CDK_TARBALL: ${{ env.CDK_TARBALL }}
116-
# Always run strands-bedrock as baseline, plus any e2e test files changed in the PR
117-
run: npx vitest run --project e2e e2e-tests/strands-bedrock.test.ts ${{ steps.changed.outputs.extra_tests }}
117+
# Always run strands-bedrock and harness-bedrock as baseline, plus any e2e test files changed in the PR
118+
run:
119+
npx vitest run --project e2e e2e-tests/strands-bedrock.test.ts e2e-tests/harness-bedrock.test.ts ${{
120+
steps.changed.outputs.extra_tests }}

e2e-tests/harness-bedrock.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createHarnessE2ESuite } from './harness-e2e-helper.js';
2+
3+
createHarnessE2ESuite({ modelProvider: 'bedrock' });

e2e-tests/harness-e2e-helper.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { hasAwsCredentials, parseJsonOutput, prereqs, retry, spawnAndCollect } from '../src/test-utils/index.js';
2+
import {
3+
cleanupStaleCredentialProviders,
4+
installCdkTarball,
5+
runAgentCoreCLI,
6+
teardownE2EProject,
7+
writeAwsTargets,
8+
} from './e2e-helper.js';
9+
import { randomUUID } from 'node:crypto';
10+
import { mkdir, rm } from 'node:fs/promises';
11+
import { tmpdir } from 'node:os';
12+
import { join } from 'node:path';
13+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
14+
15+
const hasAws = hasAwsCredentials();
16+
const baseCanRun = prereqs.npm && prereqs.git && hasAws;
17+
18+
interface HarnessE2EConfig {
19+
modelProvider: 'bedrock' | 'open_ai' | 'gemini';
20+
requiredEnvVar?: string;
21+
}
22+
23+
export function createHarnessE2ESuite(cfg: HarnessE2EConfig) {
24+
const hasRequiredVar = !cfg.requiredEnvVar || !!process.env[cfg.requiredEnvVar];
25+
const canRun = baseCanRun && hasRequiredVar;
26+
27+
const providerLabel =
28+
cfg.modelProvider === 'open_ai' ? 'OpenAI' : cfg.modelProvider === 'gemini' ? 'Gemini' : 'Bedrock';
29+
30+
describe.sequential(`e2e: harness/${providerLabel} — create → deploy → invoke`, () => {
31+
let testDir: string;
32+
let projectPath: string;
33+
let harnessName: string;
34+
35+
beforeAll(async () => {
36+
if (!canRun) return;
37+
38+
await cleanupStaleCredentialProviders();
39+
40+
testDir = join(tmpdir(), `agentcore-e2e-harness-${randomUUID()}`);
41+
await mkdir(testDir, { recursive: true });
42+
43+
const providerSlug = cfg.modelProvider.replace('_', '').slice(0, 4);
44+
harnessName = `E2eHrns${providerSlug}${String(Date.now()).slice(-8)}`;
45+
46+
const createArgs = [
47+
'create',
48+
'--name',
49+
harnessName,
50+
'--model-provider',
51+
cfg.modelProvider,
52+
'--json',
53+
'--skip-git',
54+
];
55+
56+
if (cfg.requiredEnvVar && process.env[cfg.requiredEnvVar]) {
57+
createArgs.push('--api-key-arn', process.env[cfg.requiredEnvVar]!);
58+
}
59+
60+
const result = await runAgentCoreCLI(createArgs, testDir);
61+
62+
expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0);
63+
const json = parseJsonOutput(result.stdout) as { projectPath: string };
64+
projectPath = json.projectPath;
65+
66+
await writeAwsTargets(projectPath);
67+
installCdkTarball(projectPath);
68+
}, 300000);
69+
70+
afterAll(async () => {
71+
if (projectPath && hasAws) {
72+
await teardownE2EProject(projectPath, harnessName, cfg.modelProvider);
73+
}
74+
if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 });
75+
}, 600000);
76+
77+
it.skipIf(!canRun)(
78+
'deploys to AWS successfully',
79+
async () => {
80+
expect(projectPath, 'Project should have been created').toBeTruthy();
81+
82+
await retry(
83+
async () => {
84+
const result = await runAgentCoreCLI(['deploy', '--yes', '--json'], projectPath);
85+
86+
if (result.exitCode !== 0) {
87+
console.log('Deploy stdout:', result.stdout);
88+
console.log('Deploy stderr:', result.stderr);
89+
}
90+
91+
expect(result.exitCode, `Deploy failed (stderr: ${result.stderr}, stdout: ${result.stdout})`).toBe(0);
92+
93+
const json = parseJsonOutput(result.stdout) as { success: boolean };
94+
expect(json.success, 'Deploy should report success').toBe(true);
95+
},
96+
1,
97+
30000
98+
);
99+
},
100+
600000
101+
);
102+
103+
it.skipIf(!canRun)(
104+
'invokes the deployed harness',
105+
async () => {
106+
expect(projectPath, 'Project should have been created').toBeTruthy();
107+
108+
await retry(
109+
async () => {
110+
const result = await runAgentCoreCLI(
111+
['invoke', '--harness', harnessName, '--prompt', 'Say hello', '--json'],
112+
projectPath
113+
);
114+
115+
if (result.exitCode !== 0) {
116+
console.log('Invoke stdout:', result.stdout);
117+
console.log('Invoke stderr:', result.stderr);
118+
}
119+
120+
expect(result.exitCode, `Invoke failed: ${result.stderr}`).toBe(0);
121+
122+
const json = parseJsonOutput(result.stdout) as { success: boolean };
123+
expect(json.success, 'Invoke should report success').toBe(true);
124+
},
125+
3,
126+
15000
127+
);
128+
},
129+
180000
130+
);
131+
132+
it.skipIf(!canRun)(
133+
'status shows the deployed harness',
134+
async () => {
135+
const statusResult = await spawnAndCollect('agentcore', ['status', '--json'], projectPath);
136+
137+
expect(statusResult.exitCode, `Status failed: ${statusResult.stderr}`).toBe(0);
138+
139+
const json = parseJsonOutput(statusResult.stdout) as {
140+
success: boolean;
141+
resources: {
142+
resourceType: string;
143+
name: string;
144+
deploymentState: string;
145+
identifier?: string;
146+
}[];
147+
};
148+
expect(json.success).toBe(true);
149+
150+
const harness = json.resources.find(r => r.resourceType === 'harness' && r.name === harnessName);
151+
expect(harness, `Harness "${harnessName}" should appear in status`).toBeDefined();
152+
expect(harness!.deploymentState).toBe('deployed');
153+
expect(harness!.identifier, 'Deployed harness should have a harnessArn').toBeTruthy();
154+
},
155+
120000
156+
);
157+
});
158+
}

e2e-tests/harness-gemini.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createHarnessE2ESuite } from './harness-e2e-helper.js';
2+
3+
createHarnessE2ESuite({ modelProvider: 'gemini', requiredEnvVar: 'GEMINI_API_KEY_ARN' });

e2e-tests/harness-openai.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createHarnessE2ESuite } from './harness-e2e-helper.js';
2+
3+
createHarnessE2ESuite({ modelProvider: 'open_ai', requiredEnvVar: 'OPENAI_API_KEY_ARN' });
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { createTestProject, exists, runCLI } from '../src/test-utils/index.js';
2+
import type { TestProject } from '../src/test-utils/index.js';
3+
import { readFile } from 'node:fs/promises';
4+
import { join } from 'node:path';
5+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
6+
7+
async function readProjectConfig(projectPath: string) {
8+
return JSON.parse(await readFile(join(projectPath, 'agentcore/agentcore.json'), 'utf-8'));
9+
}
10+
11+
async function readHarnessSpec(projectPath: string, harnessName: string) {
12+
return JSON.parse(await readFile(join(projectPath, `app/${harnessName}/harness.json`), 'utf-8'));
13+
}
14+
15+
describe('integration: harness add/remove lifecycle', () => {
16+
let project: TestProject;
17+
const harnessName = 'TestHarness';
18+
19+
beforeAll(async () => {
20+
project = await createTestProject({ noAgent: true });
21+
});
22+
23+
afterAll(async () => {
24+
await project.cleanup();
25+
});
26+
27+
it('adds a harness with defaults', async () => {
28+
const result = await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath);
29+
30+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
31+
const json = JSON.parse(result.stdout);
32+
expect(json.success).toBe(true);
33+
34+
const config = await readProjectConfig(project.projectPath);
35+
const harness = config.harnesses?.find((h: { name: string }) => h.name === harnessName);
36+
expect(harness, `Harness "${harnessName}" should be in agentcore.json`).toBeTruthy();
37+
expect(harness.path).toBe(`app/${harnessName}`);
38+
});
39+
40+
it('creates harness.json with correct model config', async () => {
41+
const spec = await readHarnessSpec(project.projectPath, harnessName);
42+
expect(spec.model).toBeDefined();
43+
expect(spec.model.provider).toBe('bedrock');
44+
expect(spec.model.modelId).toBeTruthy();
45+
});
46+
47+
it('creates system-prompt.md', async () => {
48+
const promptPath = join(project.projectPath, `app/${harnessName}/system-prompt.md`);
49+
expect(await exists(promptPath), 'system-prompt.md should exist').toBe(true);
50+
});
51+
52+
it('auto-creates memory resource', async () => {
53+
const config = await readProjectConfig(project.projectPath);
54+
const memories = config.memories ?? [];
55+
expect(memories.length, 'Should have auto-created memory').toBeGreaterThan(0);
56+
});
57+
58+
it('rejects duplicate harness name', async () => {
59+
const result = await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath);
60+
expect(result.exitCode).not.toBe(0);
61+
});
62+
63+
it('removes the harness', async () => {
64+
const result = await runCLI(['remove', 'harness', '--name', harnessName, '--json'], project.projectPath);
65+
66+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
67+
const json = JSON.parse(result.stdout);
68+
expect(json.success).toBe(true);
69+
70+
const config = await readProjectConfig(project.projectPath);
71+
const found = config.harnesses?.find((h: { name: string }) => h.name === harnessName);
72+
expect(found, `Harness "${harnessName}" should be removed`).toBeFalsy();
73+
});
74+
});
75+
76+
describe('integration: harness configuration options', () => {
77+
let project: TestProject;
78+
79+
beforeAll(async () => {
80+
project = await createTestProject({ noAgent: true });
81+
});
82+
83+
afterAll(async () => {
84+
await project.cleanup();
85+
});
86+
87+
it('adds harness with truncation strategy', async () => {
88+
const name = 'TruncHarness';
89+
const result = await runCLI(
90+
['add', 'harness', '--name', name, '--truncation-strategy', 'sliding_window', '--json'],
91+
project.projectPath
92+
);
93+
94+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
95+
96+
const spec = await readHarnessSpec(project.projectPath, name);
97+
expect(spec.truncation?.strategy).toBe('sliding_window');
98+
});
99+
100+
it('adds harness with lifecycle config', async () => {
101+
const name = 'LifecycleHarness';
102+
const result = await runCLI(
103+
['add', 'harness', '--name', name, '--idle-timeout', '300', '--max-lifetime', '3600', '--json'],
104+
project.projectPath
105+
);
106+
107+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
108+
109+
const spec = await readHarnessSpec(project.projectPath, name);
110+
expect(spec.lifecycleConfig?.idleRuntimeSessionTimeout).toBe(300);
111+
expect(spec.lifecycleConfig?.maxLifetime).toBe(3600);
112+
});
113+
114+
it('adds harness without memory when --no-memory is set', async () => {
115+
const name = 'NoMemHarness';
116+
const configBefore = await readProjectConfig(project.projectPath);
117+
const memoriesBefore = (configBefore.memories ?? []).length;
118+
119+
const result = await runCLI(['add', 'harness', '--name', name, '--no-memory', '--json'], project.projectPath);
120+
121+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
122+
123+
const configAfter = await readProjectConfig(project.projectPath);
124+
const memoriesAfter = (configAfter.memories ?? []).length;
125+
expect(memoriesAfter).toBe(memoriesBefore);
126+
});
127+
128+
it('adds harness with non-bedrock model provider', async () => {
129+
const name = 'OpenAIHarness';
130+
const result = await runCLI(
131+
[
132+
'add',
133+
'harness',
134+
'--name',
135+
name,
136+
'--model-provider',
137+
'open_ai',
138+
'--model-id',
139+
'gpt-5',
140+
'--api-key-arn',
141+
'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key',
142+
'--json',
143+
],
144+
project.projectPath
145+
);
146+
147+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
148+
149+
const spec = await readHarnessSpec(project.projectPath, name);
150+
expect(spec.model.provider).toBe('open_ai');
151+
expect(spec.model.modelId).toBe('gpt-5');
152+
expect(spec.model.apiKeyArn).toBe('arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key');
153+
});
154+
});
155+
156+
describe('integration: harness validation errors', () => {
157+
let project: TestProject;
158+
159+
beforeAll(async () => {
160+
project = await createTestProject({ noAgent: true });
161+
});
162+
163+
afterAll(async () => {
164+
await project.cleanup();
165+
});
166+
167+
it('rejects invalid harness name with special characters', async () => {
168+
const result = await runCLI(['add', 'harness', '--name', 'bad-name!', '--json'], project.projectPath);
169+
expect(result.exitCode).not.toBe(0);
170+
});
171+
172+
it('rejects harness name starting with a number', async () => {
173+
const result = await runCLI(['add', 'harness', '--name', '1BadName', '--json'], project.projectPath);
174+
expect(result.exitCode).not.toBe(0);
175+
});
176+
177+
it('rejects add harness without --name when --json is passed', async () => {
178+
const result = await runCLI(['add', 'harness', '--json'], project.projectPath);
179+
expect(result.exitCode).not.toBe(0);
180+
});
181+
});
182+
183+
describe('integration: create project with harness', () => {
184+
let project: TestProject;
185+
const harnessName = 'CreateHarness';
186+
187+
beforeAll(async () => {
188+
project = await createTestProject({ name: harnessName, noAgent: true });
189+
await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath);
190+
});
191+
192+
afterAll(async () => {
193+
await project.cleanup();
194+
});
195+
196+
it('has correct project scaffolding', async () => {
197+
expect(await exists(join(project.projectPath, 'agentcore/agentcore.json'))).toBe(true);
198+
expect(await exists(join(project.projectPath, 'agentcore/cdk'))).toBe(true);
199+
expect(await exists(join(project.projectPath, `app/${harnessName}/harness.json`))).toBe(true);
200+
expect(await exists(join(project.projectPath, `app/${harnessName}/system-prompt.md`))).toBe(true);
201+
});
202+
203+
it('has harness registered in project config', async () => {
204+
const config = await readProjectConfig(project.projectPath);
205+
const harness = config.harnesses?.find((h: { name: string }) => h.name === harnessName);
206+
expect(harness).toBeTruthy();
207+
});
208+
});

0 commit comments

Comments
 (0)