@amodalai/amodal 0.3.27 → 0.3.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/dist/src/commands/dev.d.ts.map +1 -1
- package/dist/src/commands/dev.js +28 -11
- package/dist/src/commands/dev.js.map +1 -1
- package/dist/src/commands/eval.d.ts.map +1 -1
- package/dist/src/commands/eval.js +4 -2
- package/dist/src/commands/eval.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/src/commands/dev.ts +31 -11
- package/src/commands/eval.ts +4 -2
- package/src/e2e-commands.test.ts +9 -291
- package/src/e2e-subprocess.test.ts +153 -0
- package/dist/src/fixtures/incident-response.d.ts +0 -92
- package/dist/src/fixtures/incident-response.d.ts.map +0 -1
- package/dist/src/fixtures/incident-response.js +0 -209
- package/dist/src/fixtures/incident-response.js.map +0 -1
- package/dist/src/shared/find-free-port.d.ts +0 -21
- package/dist/src/shared/find-free-port.d.ts.map +0 -1
- package/dist/src/shared/find-free-port.js +0 -62
- package/dist/src/shared/find-free-port.js.map +0 -1
- package/src/e2e-automations.test.ts +0 -305
- package/src/e2e-incident-response.test.ts +0 -345
- package/src/e2e-plugin-connections.test.ts +0 -407
- package/src/e2e-plugins.test.ts +0 -491
- package/src/e2e.test.ts +0 -493
- package/src/fixtures/incident-response.ts +0 -233
- package/src/shared/find-free-port.ts +0 -67
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* Copyright 2025 Amodal Labs, Inc.
|
|
4
|
-
* SPDX-License-Identifier: MIT
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* End-to-end test: Automation lifecycle
|
|
9
|
-
*
|
|
10
|
-
* Tests the full automation control flow via the HTTP API:
|
|
11
|
-
* 1. Create repo with cron + webhook automations on disk
|
|
12
|
-
* 2. Boot `createLocalServer` (which integrates ProactiveRunner)
|
|
13
|
-
* 3. List automations — verify both appear, cron is stopped, webhook is running
|
|
14
|
-
* 4. Start a cron automation — verify it becomes running
|
|
15
|
-
* 5. Stop a cron automation — verify it becomes stopped
|
|
16
|
-
* 6. Reject starting a webhook automation (always active)
|
|
17
|
-
* 7. Manually trigger an automation (run)
|
|
18
|
-
* 8. Webhook endpoint accepts events
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import {describe, it, expect, beforeAll, afterAll} from 'vitest';
|
|
22
|
-
import {mkdtempSync, mkdirSync, writeFileSync, rmSync} from 'node:fs';
|
|
23
|
-
import {join} from 'node:path';
|
|
24
|
-
import {tmpdir} from 'node:os';
|
|
25
|
-
import type {AddressInfo} from 'node:net';
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Fixture data
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
const CONFIG = {
|
|
32
|
-
name: 'automation-test-agent',
|
|
33
|
-
version: '1.0.0',
|
|
34
|
-
description: 'Agent with automations for e2e testing',
|
|
35
|
-
models: {
|
|
36
|
-
main: {provider: 'anthropic', model: 'claude-sonnet-4-20250514'},
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const CRON_AUTOMATION = `# Automation: Daily Scan
|
|
41
|
-
|
|
42
|
-
Schedule: */5 * * * *
|
|
43
|
-
|
|
44
|
-
## Check
|
|
45
|
-
Scan all zones for anomalies and report findings.
|
|
46
|
-
|
|
47
|
-
## Output
|
|
48
|
-
Summary of anomalies found.
|
|
49
|
-
|
|
50
|
-
## Delivery
|
|
51
|
-
stdout
|
|
52
|
-
`;
|
|
53
|
-
|
|
54
|
-
const WEBHOOK_AUTOMATION = `# Automation: Alert Handler
|
|
55
|
-
|
|
56
|
-
## Check
|
|
57
|
-
Run on webhook when an alert fires. Triage the alert and determine severity.
|
|
58
|
-
|
|
59
|
-
## Output
|
|
60
|
-
Triage assessment with severity level.
|
|
61
|
-
|
|
62
|
-
## Delivery
|
|
63
|
-
stdout
|
|
64
|
-
`;
|
|
65
|
-
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// Test suite
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
|
|
70
|
-
describe('E2E: Automation Lifecycle', () => {
|
|
71
|
-
let repoDir: string;
|
|
72
|
-
let server: {app: unknown; start: () => Promise<unknown>; stop: () => Promise<void>} | null = null;
|
|
73
|
-
let baseUrl: string;
|
|
74
|
-
|
|
75
|
-
beforeAll(async () => {
|
|
76
|
-
// 1. Create repo directory with automations
|
|
77
|
-
repoDir = mkdtempSync(join(tmpdir(), 'amodal-e2e-automations-'));
|
|
78
|
-
|
|
79
|
-
// Config
|
|
80
|
-
writeFileSync(join(repoDir, 'amodal.json'), JSON.stringify(CONFIG, null, 2));
|
|
81
|
-
|
|
82
|
-
// Automations
|
|
83
|
-
const autoDir = join(repoDir, 'automations');
|
|
84
|
-
mkdirSync(autoDir, {recursive: true});
|
|
85
|
-
writeFileSync(join(autoDir, 'daily-scan.md'), CRON_AUTOMATION);
|
|
86
|
-
writeFileSync(join(autoDir, 'alert-handler.md'), WEBHOOK_AUTOMATION);
|
|
87
|
-
|
|
88
|
-
// 2. Boot repo server
|
|
89
|
-
const {createLocalServer} = await import('@amodalai/runtime');
|
|
90
|
-
const srv = await createLocalServer({
|
|
91
|
-
repoPath: repoDir,
|
|
92
|
-
port: 0, // random port
|
|
93
|
-
host: '127.0.0.1',
|
|
94
|
-
hotReload: false,
|
|
95
|
-
corsOrigin: '*',
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
const httpServer = await srv.start();
|
|
99
|
-
const addr = httpServer.address() as AddressInfo;
|
|
100
|
-
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
101
|
-
server = srv;
|
|
102
|
-
}, 30000);
|
|
103
|
-
|
|
104
|
-
afterAll(async () => {
|
|
105
|
-
if (server) {
|
|
106
|
-
await server.stop();
|
|
107
|
-
server = null;
|
|
108
|
-
}
|
|
109
|
-
rmSync(repoDir, {recursive: true, force: true});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
// =========================================================================
|
|
113
|
-
// Health check — server is up
|
|
114
|
-
// =========================================================================
|
|
115
|
-
|
|
116
|
-
it('should respond to health check', async () => {
|
|
117
|
-
const resp = await fetch(`${baseUrl}/health`);
|
|
118
|
-
expect(resp.status).toBe(200);
|
|
119
|
-
const data = (await resp.json()) as Record<string, unknown>;
|
|
120
|
-
expect(data['status']).toBe('ok');
|
|
121
|
-
expect(data['mode']).toBe('repo');
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// =========================================================================
|
|
125
|
-
// List — both automations appear with correct initial state
|
|
126
|
-
// =========================================================================
|
|
127
|
-
|
|
128
|
-
it('should list automations with correct types and initial state', async () => {
|
|
129
|
-
const resp = await fetch(`${baseUrl}/automations`);
|
|
130
|
-
expect(resp.status).toBe(200);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const data = (await resp.json()) as {
|
|
134
|
-
automations: Array<{
|
|
135
|
-
name: string;
|
|
136
|
-
title: string;
|
|
137
|
-
schedule?: string;
|
|
138
|
-
webhookTriggered: boolean;
|
|
139
|
-
running: boolean;
|
|
140
|
-
}>;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
expect(data.automations).toHaveLength(2);
|
|
144
|
-
|
|
145
|
-
const cronAuto = data.automations.find((a) => a.name === 'daily-scan');
|
|
146
|
-
expect(cronAuto).toBeDefined();
|
|
147
|
-
expect(cronAuto?.title).toBe('Daily Scan');
|
|
148
|
-
expect(cronAuto?.schedule).toBe('*/5 * * * *');
|
|
149
|
-
expect(cronAuto?.webhookTriggered).toBe(false);
|
|
150
|
-
expect(cronAuto?.running).toBe(false); // not started yet
|
|
151
|
-
|
|
152
|
-
const webhookAuto = data.automations.find((a) => a.name === 'alert-handler');
|
|
153
|
-
expect(webhookAuto).toBeDefined();
|
|
154
|
-
expect(webhookAuto?.title).toBe('Alert Handler');
|
|
155
|
-
expect(webhookAuto?.webhookTriggered).toBe(true);
|
|
156
|
-
expect(webhookAuto?.running).toBe(true); // webhooks always active
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// =========================================================================
|
|
160
|
-
// Start — cron automation becomes running
|
|
161
|
-
// =========================================================================
|
|
162
|
-
|
|
163
|
-
it('should start a cron automation', async () => {
|
|
164
|
-
const resp = await fetch(`${baseUrl}/automations/daily-scan/start`, {method: 'POST'});
|
|
165
|
-
expect(resp.status).toBe(200);
|
|
166
|
-
const data = (await resp.json()) as Record<string, unknown>;
|
|
167
|
-
expect(data['status']).toBe('started');
|
|
168
|
-
|
|
169
|
-
// Verify it's now running
|
|
170
|
-
const listResp = await fetch(`${baseUrl}/automations`);
|
|
171
|
-
|
|
172
|
-
const listData = (await listResp.json()) as {
|
|
173
|
-
automations: Array<{name: string; running: boolean}>;
|
|
174
|
-
};
|
|
175
|
-
const cronAuto = listData.automations.find((a) => a.name === 'daily-scan');
|
|
176
|
-
expect(cronAuto?.running).toBe(true);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// =========================================================================
|
|
180
|
-
// Start again — should fail (already running)
|
|
181
|
-
// =========================================================================
|
|
182
|
-
|
|
183
|
-
it('should reject starting an already running automation', async () => {
|
|
184
|
-
const resp = await fetch(`${baseUrl}/automations/daily-scan/start`, {method: 'POST'});
|
|
185
|
-
expect(resp.status).toBe(400);
|
|
186
|
-
const data = (await resp.json()) as Record<string, unknown>;
|
|
187
|
-
expect(data['error']).toContain('already running');
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// =========================================================================
|
|
191
|
-
// Stop — cron automation becomes stopped
|
|
192
|
-
// =========================================================================
|
|
193
|
-
|
|
194
|
-
it('should stop a running cron automation', async () => {
|
|
195
|
-
const resp = await fetch(`${baseUrl}/automations/daily-scan/stop`, {method: 'POST'});
|
|
196
|
-
expect(resp.status).toBe(200);
|
|
197
|
-
const data = (await resp.json()) as Record<string, unknown>;
|
|
198
|
-
expect(data['status']).toBe('stopped');
|
|
199
|
-
|
|
200
|
-
// Verify it's now stopped
|
|
201
|
-
const listResp = await fetch(`${baseUrl}/automations`);
|
|
202
|
-
|
|
203
|
-
const listData = (await listResp.json()) as {
|
|
204
|
-
automations: Array<{name: string; running: boolean}>;
|
|
205
|
-
};
|
|
206
|
-
const cronAuto = listData.automations.find((a) => a.name === 'daily-scan');
|
|
207
|
-
expect(cronAuto?.running).toBe(false);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
// =========================================================================
|
|
211
|
-
// Stop again — should fail (not running)
|
|
212
|
-
// =========================================================================
|
|
213
|
-
|
|
214
|
-
it('should reject stopping a non-running automation', async () => {
|
|
215
|
-
const resp = await fetch(`${baseUrl}/automations/daily-scan/stop`, {method: 'POST'});
|
|
216
|
-
expect(resp.status).toBe(400);
|
|
217
|
-
const data = (await resp.json()) as Record<string, unknown>;
|
|
218
|
-
expect(data['error']).toContain('not running');
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// =========================================================================
|
|
222
|
-
// Start webhook automation — should fail (always active)
|
|
223
|
-
// =========================================================================
|
|
224
|
-
|
|
225
|
-
it('should reject starting a webhook-triggered automation', async () => {
|
|
226
|
-
const resp = await fetch(`${baseUrl}/automations/alert-handler/start`, {method: 'POST'});
|
|
227
|
-
expect(resp.status).toBe(400);
|
|
228
|
-
const data = (await resp.json()) as Record<string, unknown>;
|
|
229
|
-
expect(data['error']).toContain('webhook-triggered');
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
// =========================================================================
|
|
233
|
-
// Start unknown — should fail
|
|
234
|
-
// =========================================================================
|
|
235
|
-
|
|
236
|
-
it('should reject starting unknown automation', async () => {
|
|
237
|
-
const resp = await fetch(`${baseUrl}/automations/nonexistent/start`, {method: 'POST'});
|
|
238
|
-
expect(resp.status).toBe(400);
|
|
239
|
-
const data = (await resp.json()) as Record<string, unknown>;
|
|
240
|
-
expect(data['error']).toContain('not found');
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// =========================================================================
|
|
244
|
-
// Run — manually trigger an automation (fire and forget)
|
|
245
|
-
// =========================================================================
|
|
246
|
-
|
|
247
|
-
it('should reject triggering unknown automation', async () => {
|
|
248
|
-
const resp = await fetch(`${baseUrl}/automations/nonexistent/run`, {
|
|
249
|
-
method: 'POST',
|
|
250
|
-
headers: {'Content-Type': 'application/json'},
|
|
251
|
-
body: JSON.stringify({}),
|
|
252
|
-
});
|
|
253
|
-
expect(resp.status).toBe(404);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
// =========================================================================
|
|
257
|
-
// Webhook endpoint — accepts events
|
|
258
|
-
// =========================================================================
|
|
259
|
-
|
|
260
|
-
it('should accept webhook events for webhook-triggered automations', async () => {
|
|
261
|
-
const resp = await fetch(`${baseUrl}/webhooks/alert-handler`, {
|
|
262
|
-
method: 'POST',
|
|
263
|
-
headers: {'Content-Type': 'application/json'},
|
|
264
|
-
body: JSON.stringify({alert: 'high-cpu', host: 'web-01'}),
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
// May succeed or fail (depends on LLM availability), but route exists
|
|
268
|
-
expect([200, 500]).toContain(resp.status);
|
|
269
|
-
const data = (await resp.json()) as Record<string, unknown>;
|
|
270
|
-
// If 200, it matched and ran
|
|
271
|
-
if (resp.status === 200) {
|
|
272
|
-
expect(data['status']).toBe('accepted');
|
|
273
|
-
}
|
|
274
|
-
// If 500, it matched but execution failed (no LLM configured) — still validates routing
|
|
275
|
-
if (resp.status === 500) {
|
|
276
|
-
expect(data['matched']).toBe(true);
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
// =========================================================================
|
|
281
|
-
// Webhook for non-webhook automation — should 404
|
|
282
|
-
// =========================================================================
|
|
283
|
-
|
|
284
|
-
it('should reject webhook for cron-only automation', async () => {
|
|
285
|
-
const resp = await fetch(`${baseUrl}/webhooks/daily-scan`, {
|
|
286
|
-
method: 'POST',
|
|
287
|
-
headers: {'Content-Type': 'application/json'},
|
|
288
|
-
body: JSON.stringify({}),
|
|
289
|
-
});
|
|
290
|
-
expect(resp.status).toBe(404);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
// =========================================================================
|
|
294
|
-
// Webhook for unknown automation — should 404
|
|
295
|
-
// =========================================================================
|
|
296
|
-
|
|
297
|
-
it('should reject webhook for unknown automation', async () => {
|
|
298
|
-
const resp = await fetch(`${baseUrl}/webhooks/nonexistent`, {
|
|
299
|
-
method: 'POST',
|
|
300
|
-
headers: {'Content-Type': 'application/json'},
|
|
301
|
-
body: JSON.stringify({}),
|
|
302
|
-
});
|
|
303
|
-
expect(resp.status).toBe(404);
|
|
304
|
-
});
|
|
305
|
-
});
|
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* Copyright 2025 Amodal Labs, Inc.
|
|
4
|
-
* SPDX-License-Identifier: MIT
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* End-to-end test: Incident Response Agent
|
|
9
|
-
*
|
|
10
|
-
* Tests the full content pipeline with all four content types:
|
|
11
|
-
* Connection (statuspage API) + Skill (incident triage) +
|
|
12
|
-
* Knowledge (oncall runbook) + Automation (health check)
|
|
13
|
-
*
|
|
14
|
-
* Flow:
|
|
15
|
-
* 1. Create repo with all content types written to disk
|
|
16
|
-
* 2. Build snapshot → verify all content present
|
|
17
|
-
* 3. Start mock StatusPage API
|
|
18
|
-
* 4. Boot runtime from snapshot → send chat → verify agent uses
|
|
19
|
-
* the connection, skill, and knowledge in its response
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import {describe, it, expect, beforeAll, afterAll} from 'vitest';
|
|
23
|
-
import {mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync} from 'node:fs';
|
|
24
|
-
import {join} from 'node:path';
|
|
25
|
-
import {tmpdir} from 'node:os';
|
|
26
|
-
import http from 'node:http';
|
|
27
|
-
|
|
28
|
-
import type {
|
|
29
|
-
MOCK_COMPONENTS} from './fixtures/incident-response.js';
|
|
30
|
-
import {
|
|
31
|
-
CONFIG,
|
|
32
|
-
STATUSPAGE_SPEC,
|
|
33
|
-
STATUSPAGE_ACCESS,
|
|
34
|
-
STATUSPAGE_SURFACE,
|
|
35
|
-
TRIAGE_SKILL,
|
|
36
|
-
ONCALL_RUNBOOK,
|
|
37
|
-
HEALTH_CHECK_AUTOMATION,
|
|
38
|
-
createMockStatusPageApi,
|
|
39
|
-
} from './fixtures/incident-response.js';
|
|
40
|
-
import {runBuild} from './commands/build.js';
|
|
41
|
-
import type {DeploySnapshot} from '@amodalai/core';
|
|
42
|
-
import {loadRepo, loadSnapshotFromFile, snapshotToBundle} from '@amodalai/core';
|
|
43
|
-
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
// Helper: send chat and parse SSE events
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
|
|
48
|
-
async function sendChat(
|
|
49
|
-
port: number,
|
|
50
|
-
message: string,
|
|
51
|
-
appId: string,
|
|
52
|
-
sessionId?: string,
|
|
53
|
-
timeoutMs = 30000,
|
|
54
|
-
): Promise<{events: Array<Record<string, unknown>>; rawBody: string}> {
|
|
55
|
-
return new Promise((resolve, reject) => {
|
|
56
|
-
const payload: Record<string, string> = {message, app_id: appId};
|
|
57
|
-
if (sessionId) payload['session_id'] = sessionId;
|
|
58
|
-
const body = JSON.stringify(payload);
|
|
59
|
-
const req = http.request(
|
|
60
|
-
{
|
|
61
|
-
hostname: '127.0.0.1',
|
|
62
|
-
port,
|
|
63
|
-
path: '/chat',
|
|
64
|
-
method: 'POST',
|
|
65
|
-
headers: {
|
|
66
|
-
'Content-Type': 'application/json',
|
|
67
|
-
'Content-Length': Buffer.byteLength(body),
|
|
68
|
-
},
|
|
69
|
-
timeout: timeoutMs,
|
|
70
|
-
},
|
|
71
|
-
(res) => {
|
|
72
|
-
let rawBody = '';
|
|
73
|
-
res.setEncoding('utf8');
|
|
74
|
-
res.on('data', (chunk: string) => { rawBody += chunk; });
|
|
75
|
-
res.on('end', () => {
|
|
76
|
-
const events: Array<Record<string, unknown>> = [];
|
|
77
|
-
for (const line of rawBody.split('\n')) {
|
|
78
|
-
if (line.startsWith('data: ')) {
|
|
79
|
-
try { events.push(JSON.parse(line.slice(6)) as Record<string, unknown>); } catch { /* skip */ }
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
resolve({events, rawBody});
|
|
83
|
-
});
|
|
84
|
-
},
|
|
85
|
-
);
|
|
86
|
-
req.on('error', reject);
|
|
87
|
-
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
88
|
-
req.write(body);
|
|
89
|
-
req.end();
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// Test suite
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
|
|
97
|
-
describe('E2E: Incident Response Agent', () => {
|
|
98
|
-
let repoDir: string;
|
|
99
|
-
let snapshotPath: string;
|
|
100
|
-
let builtSnapshot: DeploySnapshot;
|
|
101
|
-
let mockApi: ReturnType<typeof createMockStatusPageApi>;
|
|
102
|
-
let runtimeServer: {app: unknown; start: () => Promise<unknown>; stop: () => Promise<void>} | null = null;
|
|
103
|
-
let runtimePort: number;
|
|
104
|
-
|
|
105
|
-
beforeAll(async () => {
|
|
106
|
-
// 1. Create repo directory structure with all content types
|
|
107
|
-
repoDir = mkdtempSync(join(tmpdir(), 'amodal-e2e-incident-'));
|
|
108
|
-
|
|
109
|
-
// Config
|
|
110
|
-
writeFileSync(join(repoDir, 'amodal.json'), JSON.stringify(CONFIG, null, 2));
|
|
111
|
-
|
|
112
|
-
// Connection: statuspage
|
|
113
|
-
const connDir = join(repoDir, 'connections', 'statuspage');
|
|
114
|
-
mkdirSync(connDir, {recursive: true});
|
|
115
|
-
writeFileSync(join(connDir, 'spec.json'), JSON.stringify(STATUSPAGE_SPEC, null, 2));
|
|
116
|
-
writeFileSync(join(connDir, 'access.json'), JSON.stringify(STATUSPAGE_ACCESS, null, 2));
|
|
117
|
-
writeFileSync(join(connDir, 'surface.md'), STATUSPAGE_SURFACE);
|
|
118
|
-
|
|
119
|
-
// Skill: incident-triage
|
|
120
|
-
const skillDir = join(repoDir, 'skills', 'incident-triage');
|
|
121
|
-
mkdirSync(skillDir, {recursive: true});
|
|
122
|
-
writeFileSync(join(skillDir, 'SKILL.md'), TRIAGE_SKILL);
|
|
123
|
-
|
|
124
|
-
// Knowledge: oncall-runbook
|
|
125
|
-
const knowledgeDir = join(repoDir, 'knowledge');
|
|
126
|
-
mkdirSync(knowledgeDir, {recursive: true});
|
|
127
|
-
writeFileSync(join(knowledgeDir, 'oncall-runbook.md'), ONCALL_RUNBOOK);
|
|
128
|
-
|
|
129
|
-
// Automation: health-check
|
|
130
|
-
const autoDir = join(repoDir, 'automations');
|
|
131
|
-
mkdirSync(autoDir, {recursive: true});
|
|
132
|
-
writeFileSync(join(autoDir, 'health-check.md'), HEALTH_CHECK_AUTOMATION);
|
|
133
|
-
|
|
134
|
-
// 2. Build snapshot
|
|
135
|
-
snapshotPath = join(repoDir, 'resolved-config.json');
|
|
136
|
-
const code = await runBuild({cwd: repoDir, output: snapshotPath});
|
|
137
|
-
if (code !== 0) throw new Error('Build failed');
|
|
138
|
-
|
|
139
|
-
builtSnapshot = await loadSnapshotFromFile(snapshotPath);
|
|
140
|
-
|
|
141
|
-
// 3. Start mock StatusPage API
|
|
142
|
-
mockApi = createMockStatusPageApi();
|
|
143
|
-
await mockApi.start();
|
|
144
|
-
|
|
145
|
-
// 4. Update the spec in the snapshot to point to the mock API
|
|
146
|
-
// (In production, the base URL comes from the spec; here we override it)
|
|
147
|
-
// We need to rebuild with the mock URL baked in
|
|
148
|
-
const specWithMockUrl = {
|
|
149
|
-
...STATUSPAGE_SPEC,
|
|
150
|
-
specUrl: `http://127.0.0.1:${mockApi.port}/openapi.json`,
|
|
151
|
-
};
|
|
152
|
-
writeFileSync(join(connDir, 'spec.json'), JSON.stringify(specWithMockUrl, null, 2));
|
|
153
|
-
|
|
154
|
-
// Rebuild snapshot with the mock URL
|
|
155
|
-
const code2 = await runBuild({cwd: repoDir, output: snapshotPath});
|
|
156
|
-
if (code2 !== 0) throw new Error('Rebuild with mock URL failed');
|
|
157
|
-
builtSnapshot = await loadSnapshotFromFile(snapshotPath);
|
|
158
|
-
|
|
159
|
-
// 5. Boot runtime from snapshot
|
|
160
|
-
const {createSnapshotServer} = await import('@amodalai/runtime');
|
|
161
|
-
runtimeServer = await createSnapshotServer({
|
|
162
|
-
snapshotPath,
|
|
163
|
-
port: 0,
|
|
164
|
-
host: '127.0.0.1',
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
const httpServer = await runtimeServer.start();
|
|
168
|
-
const addr = (httpServer as http.Server).address();
|
|
169
|
-
runtimePort = typeof addr === 'object' && addr ? addr.port : 0;
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
afterAll(async () => {
|
|
173
|
-
if (runtimeServer) await runtimeServer.stop();
|
|
174
|
-
if (mockApi) await mockApi.stop();
|
|
175
|
-
if (repoDir && existsSync(repoDir)) rmSync(repoDir, {recursive: true, force: true});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// =========================================================================
|
|
179
|
-
// Phase 1: Repo loading — all content types load from disk
|
|
180
|
-
// =========================================================================
|
|
181
|
-
|
|
182
|
-
describe('repo loading', () => {
|
|
183
|
-
it('should load the connection from disk', async () => {
|
|
184
|
-
const repo = await loadRepo({localPath: repoDir});
|
|
185
|
-
expect(repo.connections.size).toBe(1);
|
|
186
|
-
const conn = repo.connections.get('statuspage');
|
|
187
|
-
expect(conn).toBeDefined();
|
|
188
|
-
expect(conn!.spec.format).toBe('openapi');
|
|
189
|
-
expect(conn!.surface.length).toBeGreaterThanOrEqual(3);
|
|
190
|
-
expect(conn!.surface.find((s) => s.path === '/components')).toBeDefined();
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('should load the skill from disk', async () => {
|
|
194
|
-
const repo = await loadRepo({localPath: repoDir});
|
|
195
|
-
expect(repo.skills.length).toBe(1);
|
|
196
|
-
expect(repo.skills[0].name).toBe('incident-triage');
|
|
197
|
-
expect(repo.skills[0].body).toContain('Check component status');
|
|
198
|
-
expect(repo.skills[0].trigger).toContain('service health');
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('should load the knowledge from disk', async () => {
|
|
202
|
-
const repo = await loadRepo({localPath: repoDir});
|
|
203
|
-
expect(repo.knowledge.length).toBe(1);
|
|
204
|
-
expect(repo.knowledge[0].name).toBe('oncall-runbook');
|
|
205
|
-
expect(repo.knowledge[0].body).toContain('Severity Matrix');
|
|
206
|
-
expect(repo.knowledge[0].body).toContain('alice@example.com');
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('should load the automation from disk', async () => {
|
|
210
|
-
const repo = await loadRepo({localPath: repoDir});
|
|
211
|
-
expect(repo.automations.length).toBe(1);
|
|
212
|
-
expect(repo.automations[0].name).toBe('health-check');
|
|
213
|
-
expect(repo.automations[0].title).toBe('Daily Health Check');
|
|
214
|
-
expect(repo.automations[0].schedule).toBe('0 8 * * *');
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// =========================================================================
|
|
219
|
-
// Phase 2: Snapshot — all content types serialized
|
|
220
|
-
// =========================================================================
|
|
221
|
-
|
|
222
|
-
describe('snapshot content', () => {
|
|
223
|
-
it('should include the connection in the snapshot', () => {
|
|
224
|
-
expect(Object.keys(builtSnapshot.connections)).toContain('statuspage');
|
|
225
|
-
const conn = builtSnapshot.connections['statuspage'];
|
|
226
|
-
expect(conn.spec.format).toBe('openapi');
|
|
227
|
-
// Surface is serialized as checkbox markdown in snapshot
|
|
228
|
-
expect(conn.surface).toContain('/components');
|
|
229
|
-
expect(conn.access.endpoints['GET /components']).toBeDefined();
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('should include the skill in the snapshot', () => {
|
|
233
|
-
expect(builtSnapshot.skills.length).toBe(1);
|
|
234
|
-
expect(builtSnapshot.skills[0].name).toBe('incident-triage');
|
|
235
|
-
expect(builtSnapshot.skills[0].body).toContain('Check component status');
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it('should include the knowledge in the snapshot', () => {
|
|
239
|
-
expect(builtSnapshot.knowledge.length).toBe(1);
|
|
240
|
-
expect(builtSnapshot.knowledge[0].name).toBe('oncall-runbook');
|
|
241
|
-
expect(builtSnapshot.knowledge[0].body).toContain('SEV1');
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('should include the automation in the snapshot', () => {
|
|
245
|
-
expect(builtSnapshot.automations.length).toBe(1);
|
|
246
|
-
expect(builtSnapshot.automations[0].name).toBe('health-check');
|
|
247
|
-
expect(builtSnapshot.automations[0].schedule).toBe('0 8 * * *');
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it('should round-trip all content through snapshot', () => {
|
|
251
|
-
const restored = snapshotToBundle(builtSnapshot, 'test');
|
|
252
|
-
|
|
253
|
-
// Connection
|
|
254
|
-
expect(restored.connections.size).toBe(1);
|
|
255
|
-
const conn = restored.connections.get('statuspage');
|
|
256
|
-
expect(conn).toBeDefined();
|
|
257
|
-
expect(conn!.spec.format).toBe('openapi');
|
|
258
|
-
// Surface endpoints parsed from markdown
|
|
259
|
-
expect(conn!.surface.length).toBeGreaterThanOrEqual(3);
|
|
260
|
-
const getComponents = conn!.surface.find((s) => s.method === 'GET' && s.path === '/components');
|
|
261
|
-
expect(getComponents).toBeDefined();
|
|
262
|
-
expect(getComponents!.included).toBe(true);
|
|
263
|
-
const postIncidents = conn!.surface.find((s) => s.method === 'POST');
|
|
264
|
-
expect(postIncidents).toBeDefined();
|
|
265
|
-
expect(postIncidents!.included).toBe(false);
|
|
266
|
-
|
|
267
|
-
// Skill
|
|
268
|
-
expect(restored.skills[0].name).toBe('incident-triage');
|
|
269
|
-
expect(restored.skills[0].trigger).toContain('service health');
|
|
270
|
-
|
|
271
|
-
// Knowledge
|
|
272
|
-
expect(restored.knowledge[0].body).toContain('alice@example.com');
|
|
273
|
-
|
|
274
|
-
// Automation
|
|
275
|
-
expect(restored.automations[0].schedule).toBe('0 8 * * *');
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
// =========================================================================
|
|
280
|
-
// Phase 3: Runtime — server boots from snapshot and handles chat
|
|
281
|
-
// =========================================================================
|
|
282
|
-
|
|
283
|
-
describe('runtime from snapshot', () => {
|
|
284
|
-
it('should serve health check with full content counts', async () => {
|
|
285
|
-
const resp = await fetch(`http://127.0.0.1:${runtimePort}/health`);
|
|
286
|
-
const data = (await resp.json()) as Record<string, unknown>;
|
|
287
|
-
expect(data['status']).toBe('ok');
|
|
288
|
-
expect(data['mode']).toBe('snapshot');
|
|
289
|
-
expect(data['agent_name']).toBe('incident-response-agent');
|
|
290
|
-
expect(data['connections']).toBe(1);
|
|
291
|
-
expect(data['skills']).toBe(1);
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it('should handle chat and stream SSE events', async () => {
|
|
295
|
-
const {events} = await sendChat(runtimePort, 'Is the API healthy?', 'app-incident-e2e');
|
|
296
|
-
|
|
297
|
-
expect(events.length).toBeGreaterThanOrEqual(2);
|
|
298
|
-
expect(events.find((e) => e['type'] === 'init')).toBeDefined();
|
|
299
|
-
expect(events.find((e) => e['type'] === 'done')).toBeDefined();
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('should use the connection when the agent makes a tool call', async () => {
|
|
303
|
-
// The agent should try to call GET /components via the request tool
|
|
304
|
-
// when asked about API health, because the triage skill says to do that.
|
|
305
|
-
// Whether it actually calls the mock depends on the LLM, but we can
|
|
306
|
-
// check if tool_call_start events reference the statuspage connection.
|
|
307
|
-
const {events} = await sendChat(
|
|
308
|
-
runtimePort,
|
|
309
|
-
'Check the current status of all services using the statuspage connection.',
|
|
310
|
-
'app-incident-tool-e2e',
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
const toolCalls = events.filter((e) => e['type'] === 'tool_call_start');
|
|
314
|
-
const textEvents = events.filter((e) => e['type'] === 'text_delta');
|
|
315
|
-
const fullText = textEvents.map((e) => String(e['content'] ?? '')).join('');
|
|
316
|
-
|
|
317
|
-
// The agent should have either made a tool call or mentioned the
|
|
318
|
-
// components in its text response
|
|
319
|
-
const mentionsConnection = toolCalls.some((tc) => {
|
|
320
|
-
const params = tc['parameters'] as Record<string, unknown> | undefined;
|
|
321
|
-
return params?.['connection'] === 'statuspage';
|
|
322
|
-
});
|
|
323
|
-
const mentionsComponents = fullText.toLowerCase().includes('api-gateway') ||
|
|
324
|
-
fullText.toLowerCase().includes('component') ||
|
|
325
|
-
fullText.toLowerCase().includes('statuspage');
|
|
326
|
-
|
|
327
|
-
// At least one of these should be true
|
|
328
|
-
expect(mentionsConnection || mentionsComponents).toBe(true);
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
// =========================================================================
|
|
333
|
-
// Phase 4: Mock API verification
|
|
334
|
-
// =========================================================================
|
|
335
|
-
|
|
336
|
-
describe('mock API interaction', () => {
|
|
337
|
-
it('should have the mock API running', async () => {
|
|
338
|
-
const resp = await fetch(`http://127.0.0.1:${mockApi.port}/components`);
|
|
339
|
-
expect(resp.status).toBe(200);
|
|
340
|
-
const data = (await resp.json()) as typeof MOCK_COMPONENTS;
|
|
341
|
-
expect(data.length).toBe(5);
|
|
342
|
-
expect(data.find((c) => c.name === 'database-primary')?.status).toBe('degraded_performance');
|
|
343
|
-
});
|
|
344
|
-
});
|
|
345
|
-
});
|