Skip to content

Commit 626872f

Browse files
authored
feat(tests): add E2E and integration tests for Harness primitive (#1021)
* 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. * skip memory for OpenAI/Gemini E2E harness tests to reduce deploy time * fix: use shared readProjectConfig from test-utils Address review comment — reuse existing config-reader utility instead of inline helper. * fix: add non-null assertion to fix typecheck failure The Zod-validated return type from readProjectConfig makes the find() result possibly undefined. The preceding expect() guards against it at runtime.
1 parent dd76d17 commit 626872f

6 files changed

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

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', skipMemory: true });

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

0 commit comments

Comments
 (0)