-
Notifications
You must be signed in to change notification settings - Fork 30
Expand file tree
/
Copy pathe2e-helper.ts
More file actions
349 lines (297 loc) · 12.1 KB
/
e2e-helper.ts
File metadata and controls
349 lines (297 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
import {
type RunResult,
hasAwsCredentials,
parseJsonOutput,
prereqs,
retry,
spawnAndCollect,
} from '../src/test-utils/index.js';
import {
BedrockAgentCoreControlClient,
DeleteApiKeyCredentialProviderCommand,
GetAgentRuntimeCommand,
} from '@aws-sdk/client-bedrock-agentcore-control';
import { execSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const hasAws = hasAwsCredentials();
const baseCanRun = prereqs.npm && prereqs.git && prereqs.uv && hasAws;
interface E2EConfig {
framework: string;
modelProvider: string;
requiredEnvVar?: string;
build?: string;
memory?: string;
/** Lifecycle configuration to pass via --idle-timeout / --max-lifetime flags. */
lifecycleConfig?: {
idleTimeout?: number;
maxLifetime?: number;
};
}
export function createE2ESuite(cfg: E2EConfig) {
const hasApiKey = !cfg.requiredEnvVar || !!process.env[cfg.requiredEnvVar];
const canRun = baseCanRun && hasApiKey;
describe.sequential(`e2e: ${cfg.framework}/${cfg.modelProvider} — create → deploy → invoke`, () => {
let testDir: string;
let projectPath: string;
let agentName: string;
beforeAll(async () => {
if (!canRun) return;
testDir = join(tmpdir(), `agentcore-e2e-${randomUUID()}`);
await mkdir(testDir, { recursive: true });
agentName = `E2e${cfg.framework.slice(0, 4)}${cfg.modelProvider.slice(0, 4)}${String(Date.now()).slice(-8)}`;
const createArgs = [
'create',
'--name',
agentName,
'--language',
'Python',
'--framework',
cfg.framework,
'--model-provider',
cfg.modelProvider,
'--memory',
cfg.memory ?? 'none',
'--json',
];
if (cfg.build) {
createArgs.push('--build', cfg.build);
}
if (cfg.lifecycleConfig?.idleTimeout !== undefined) {
createArgs.push('--idle-timeout', String(cfg.lifecycleConfig.idleTimeout));
}
if (cfg.lifecycleConfig?.maxLifetime !== undefined) {
createArgs.push('--max-lifetime', String(cfg.lifecycleConfig.maxLifetime));
}
// Pass API key so the credential is registered in the project and .env.local
const apiKey = cfg.requiredEnvVar ? process.env[cfg.requiredEnvVar] : undefined;
if (apiKey) {
createArgs.push('--api-key', apiKey);
}
const result = await runAgentCoreCLI(createArgs, testDir);
expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0);
const json = parseJsonOutput(result.stdout) as { projectPath: string };
projectPath = json.projectPath;
await writeAwsTargets(projectPath);
installCdkTarball(projectPath);
}, 300000);
afterAll(async () => {
if (projectPath && hasAws) {
await teardownE2EProject(projectPath, agentName, cfg.modelProvider);
}
if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 });
}, 600000);
// Container builds go through CodeBuild which is slower and more prone to transient failures.
const isContainerBuild = cfg.build === 'Container';
const deployRetries = isContainerBuild ? 3 : 1;
const deployTimeout = isContainerBuild ? 900000 : 600000;
it.skipIf(!canRun)(
'deploys to AWS successfully',
async () => {
expect(projectPath, 'Project should have been created').toBeTruthy();
await retry(
async () => {
const result = await runAgentCoreCLI(['deploy', '--yes', '--json'], projectPath);
if (result.exitCode !== 0) {
console.log('Deploy stdout:', result.stdout);
console.log('Deploy stderr:', result.stderr);
}
expect(result.exitCode, `Deploy failed (stderr: ${result.stderr}, stdout: ${result.stdout})`).toBe(0);
const json = parseJsonOutput(result.stdout) as { success: boolean };
expect(json.success, 'Deploy should report success').toBe(true);
},
deployRetries,
30000
);
},
deployTimeout
);
it.skipIf(!canRun)(
'invokes the deployed agent',
async () => {
expect(projectPath, 'Project should have been created').toBeTruthy();
// Retry invoke to handle cold-start / runtime initialization delays
await retry(
async () => {
const result = await runAgentCoreCLI(
['invoke', '--prompt', 'Say hello', '--agent', agentName, '--json'],
projectPath
);
if (result.exitCode !== 0) {
console.log('Invoke stdout:', result.stdout);
console.log('Invoke stderr:', result.stderr);
}
expect(result.exitCode, `Invoke failed: ${result.stderr}`).toBe(0);
const json = parseJsonOutput(result.stdout) as { success: boolean };
expect(json.success, 'Invoke should report success').toBe(true);
},
3,
15000
);
},
180000
);
// ── Post-deploy observability tests ──────────────────────────────
// Use spawnAndCollect directly to avoid TypeScript inference depth limits
// in the describe.sequential callback.
const run = (args: string[]) => spawnAndCollect('agentcore', args, projectPath);
// Track the runtime ID across status tests
let runtimeId: string;
it.skipIf(!canRun)(
'status shows the deployed agent',
async () => {
const result = await run(['status', '--json']);
expect(result.exitCode, `Status failed: ${result.stderr}`).toBe(0);
const json = parseJsonOutput(result.stdout) as {
success: boolean;
resources: {
resourceType: string;
name: string;
deploymentState: string;
identifier?: string;
}[];
};
expect(json.success).toBe(true);
const agent = json.resources.find(r => r.resourceType === 'agent' && r.name === agentName);
expect(agent, `Agent "${agentName}" should appear in status`).toBeDefined();
expect(agent!.deploymentState).toBe('deployed');
expect(agent!.identifier, 'Deployed agent should have a runtime ARN').toBeTruthy();
// Extract runtime ID from ARN (e.g. arn:aws:...:agent-runtime/XXXXX → XXXXX)
runtimeId = agent!.identifier!.split('/').pop()!;
},
120000
);
it.skipIf(!canRun)(
'status looks up agent runtime by ID',
async () => {
expect(runtimeId, 'Runtime ID should have been extracted from status').toBeTruthy();
const result = await run(['status', '--agent-runtime-id', runtimeId, '--json']);
expect(result.exitCode, `Runtime lookup failed: ${result.stderr}`).toBe(0);
const json = parseJsonOutput(result.stdout) as {
success: boolean;
runtimeId?: string;
runtimeStatus?: string;
};
expect(json.success).toBe(true);
expect(json.runtimeId).toBe(runtimeId);
expect(json.runtimeStatus).toBeTruthy();
},
120000
);
it.skipIf(!canRun)(
'logs returns entries from the invocation',
async () => {
await retry(
async () => {
// --since 1h triggers search mode (avoids live tail)
const result = await run(['logs', '--agent', agentName, '--since', '1h', '--json']);
expect(result.exitCode, `Logs failed: ${result.stderr}`).toBe(0);
// logs --json outputs JSON Lines (one {timestamp, message} per line)
const lines: { timestamp: string; message: string }[] = result.stdout
.split('\n')
.filter((l: string) => l.trim())
.map((l: string) => JSON.parse(l) as { timestamp: string; message: string });
expect(lines.length, 'Should have at least one log entry').toBeGreaterThan(0);
for (const line of lines) {
expect(line.timestamp, 'Each log entry should have a timestamp').toBeTruthy();
expect(line.message, 'Each log entry should have a message').toBeTruthy();
}
},
3,
15000
);
},
120000
);
it.skipIf(!canRun)(
'logs supports level filtering',
async () => {
// --level error should succeed even if no error-level logs exist
const result = await run(['logs', '--agent', agentName, '--since', '1h', '--level', 'error', '--json']);
expect(result.exitCode, `Logs --level failed: ${result.stderr}`).toBe(0);
},
120000
);
it.skipIf(!canRun)(
'traces list succeeds after invocation',
async () => {
// traces list has no --json flag — verify exit code and non-empty output
await retry(
async () => {
const result = await run(['traces', 'list', '--agent', agentName, '--since', '1h']);
expect(result.exitCode, `Traces list failed (stderr: ${result.stderr})`).toBe(0);
expect(result.stdout.length, 'Traces list should produce output').toBeGreaterThan(0);
},
3,
15000
);
},
120000
);
// ── Lifecycle configuration verification ─────────────────────────
if (cfg.lifecycleConfig) {
it.skipIf(!canRun)(
'runtime has lifecycle configuration set via AWS API',
async () => {
expect(runtimeId, 'Runtime ID should have been extracted from status').toBeTruthy();
// Query the runtime via AWS API to verify lifecycle config
const region = process.env.AWS_REGION ?? 'us-east-1';
const client = new BedrockAgentCoreControlClient({ region });
const response = await client.send(new GetAgentRuntimeCommand({ agentRuntimeId: runtimeId }));
expect(response.lifecycleConfiguration).toBeDefined();
if (cfg.lifecycleConfig!.idleTimeout !== undefined) {
expect(response.lifecycleConfiguration!.idleRuntimeSessionTimeout).toBe(cfg.lifecycleConfig!.idleTimeout);
}
if (cfg.lifecycleConfig!.maxLifetime !== undefined) {
expect(response.lifecycleConfiguration!.maxLifetime).toBe(cfg.lifecycleConfig!.maxLifetime);
}
},
180000
);
}
});
}
export { hasAws, baseCanRun };
export function runAgentCoreCLI(args: string[], cwd: string): Promise<RunResult> {
return spawnAndCollect('agentcore', args, cwd);
}
// TODO: Replace with `agentcore add target` once the CLI command is re-introduced
export async function writeAwsTargets(projectPath: string): Promise<void> {
const account =
process.env.AWS_ACCOUNT_ID ??
execSync('aws sts get-caller-identity --query Account --output text').toString().trim();
const region = process.env.AWS_REGION ?? 'us-east-1';
await writeFile(
join(projectPath, 'agentcore', 'aws-targets.json'),
JSON.stringify([{ name: 'default', account, region }])
);
}
export function installCdkTarball(projectPath: string): void {
if (process.env.CDK_TARBALL) {
execSync(`npm install -f ${process.env.CDK_TARBALL}`, {
cwd: join(projectPath, 'agentcore', 'cdk'),
stdio: 'pipe',
});
}
}
export async function teardownE2EProject(projectPath: string, agentName: string, modelProvider: string): Promise<void> {
await spawnAndCollect('agentcore', ['remove', 'all', '--json'], projectPath);
const result = await spawnAndCollect('agentcore', ['deploy', '--yes', '--json'], projectPath);
if (result.exitCode !== 0) {
console.log('Teardown stdout:', result.stdout);
console.log('Teardown stderr:', result.stderr);
}
if (modelProvider !== 'Bedrock' && agentName) {
const providerName = `${agentName}${modelProvider}`;
const region = process.env.AWS_REGION ?? 'us-east-1';
try {
const client = new BedrockAgentCoreControlClient({ region });
await client.send(new DeleteApiKeyCredentialProviderCommand({ name: providerName }));
} catch {
// Best-effort cleanup
}
}
}