@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amodalai/amodal",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.29",
|
|
4
4
|
"description": "Amodal CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
"react": "^19.2.4",
|
|
27
27
|
"yargs": "^17.7.2",
|
|
28
28
|
"zod": "^4.3.6",
|
|
29
|
-
"@amodalai/types": "0.3.
|
|
30
|
-
"@amodalai/core": "0.3.
|
|
31
|
-
"@amodalai/db": "0.3.
|
|
32
|
-
"@amodalai/runtime": "0.3.
|
|
33
|
-
"@amodalai/studio": "0.3.
|
|
34
|
-
"@amodalai/runtime-app": "0.3.
|
|
29
|
+
"@amodalai/types": "0.3.29",
|
|
30
|
+
"@amodalai/core": "0.3.29",
|
|
31
|
+
"@amodalai/db": "0.3.29",
|
|
32
|
+
"@amodalai/runtime": "0.3.29",
|
|
33
|
+
"@amodalai/studio": "0.3.29",
|
|
34
|
+
"@amodalai/runtime-app": "0.3.29"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^20.11.24",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"typecheck": "tsc --noEmit",
|
|
47
47
|
"test": "vitest run",
|
|
48
48
|
"test:ci": "vitest run --exclude 'src/e2e*.test.ts'",
|
|
49
|
+
"test:subprocess": "vitest run src/e2e-subprocess.test.ts",
|
|
49
50
|
"lint": "eslint src/"
|
|
50
51
|
}
|
|
51
52
|
}
|
package/src/commands/dev.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {fileURLToPath} from 'node:url';
|
|
|
14
14
|
import {createLocalServer, initLogLevel, interceptConsole, log} from '@amodalai/runtime';
|
|
15
15
|
import {ensureAdminAgent} from '@amodalai/core';
|
|
16
16
|
import {findRepoRoot} from '../shared/repo-discovery.js';
|
|
17
|
-
import {
|
|
17
|
+
import {createServer} from 'node:net';
|
|
18
18
|
import {runConnectionPreflight, printPreflightTable} from '../shared/connection-preflight.js';
|
|
19
19
|
import {resolveEnv} from '../shared/env-resolution.js';
|
|
20
20
|
import {getDb, ensureSchema, closeDb} from '@amodalai/db';
|
|
@@ -27,6 +27,24 @@ const DEFAULT_RUNTIME_PORT = 3847;
|
|
|
27
27
|
const DEFAULT_STUDIO_PORT = 3848;
|
|
28
28
|
const DEFAULT_ADMIN_PORT = 3849;
|
|
29
29
|
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Port checking
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
function assertPortFree(port: number): Promise<void> {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const server = createServer();
|
|
37
|
+
server.once('error', () => {
|
|
38
|
+
reject(new Error(
|
|
39
|
+
`Port ${String(port)} is already in use. Stop the process using it or pass --port to pick a different port.`,
|
|
40
|
+
));
|
|
41
|
+
});
|
|
42
|
+
server.listen(port, '0.0.0.0', () => {
|
|
43
|
+
server.close(() => resolve());
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
30
48
|
// ---------------------------------------------------------------------------
|
|
31
49
|
// Studio resolution
|
|
32
50
|
// ---------------------------------------------------------------------------
|
|
@@ -264,7 +282,7 @@ async function spawnAdminAgent(opts: {
|
|
|
264
282
|
return null;
|
|
265
283
|
}
|
|
266
284
|
|
|
267
|
-
// Verify
|
|
285
|
+
// Verify the admin agent directory has an amodal.json
|
|
268
286
|
if (!existsSync(path.join(adminAgentPath, 'amodal.json'))) {
|
|
269
287
|
log.warn('admin_agent_invalid', {
|
|
270
288
|
path: adminAgentPath,
|
|
@@ -281,6 +299,8 @@ async function spawnAdminAgent(opts: {
|
|
|
281
299
|
const adminUrl = `http://localhost:${String(opts.port)}`;
|
|
282
300
|
const env: NodeJS.ProcessEnv = {
|
|
283
301
|
...process.env,
|
|
302
|
+
AMODAL_NO_ADMIN: '1',
|
|
303
|
+
AMODAL_NO_STUDIO: '1',
|
|
284
304
|
};
|
|
285
305
|
if (opts.studioUrl) {
|
|
286
306
|
env['STUDIO_URL'] = opts.studioUrl;
|
|
@@ -408,13 +428,13 @@ Or add it to your agent's .env file:
|
|
|
408
428
|
// Port allocation
|
|
409
429
|
// -------------------------------------------------------------------------
|
|
410
430
|
|
|
411
|
-
const runtimePort =
|
|
412
|
-
const studioPort =
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
431
|
+
const runtimePort = options.port ?? DEFAULT_RUNTIME_PORT;
|
|
432
|
+
const studioPort = DEFAULT_STUDIO_PORT;
|
|
433
|
+
const adminPort = DEFAULT_ADMIN_PORT;
|
|
434
|
+
|
|
435
|
+
await assertPortFree(runtimePort);
|
|
436
|
+
if (!options.noStudio) await assertPortFree(studioPort);
|
|
437
|
+
if (!options.noAdmin) await assertPortFree(adminPort);
|
|
418
438
|
|
|
419
439
|
log.info('ports_allocated', {
|
|
420
440
|
runtime: runtimePort,
|
|
@@ -609,9 +629,9 @@ export const devCommand: CommandModule = {
|
|
|
609
629
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
610
630
|
const quiet = argv['quiet'] as boolean;
|
|
611
631
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
612
|
-
const noStudio = argv['no-studio'] as boolean;
|
|
632
|
+
const noStudio = (argv['no-studio'] as boolean) || process.env['AMODAL_NO_STUDIO'] === '1';
|
|
613
633
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
614
|
-
const noAdmin = argv['no-admin'] as boolean;
|
|
634
|
+
const noAdmin = (argv['no-admin'] as boolean) || process.env['AMODAL_NO_ADMIN'] === '1';
|
|
615
635
|
await runDev({port, host, resume, verbose, quiet, noStudio, noAdmin});
|
|
616
636
|
},
|
|
617
637
|
};
|
package/src/commands/eval.ts
CHANGED
|
@@ -172,8 +172,10 @@ export async function runEval(options: EvalOptions): Promise<void> {
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
// Get model info from repo config for cost tracking
|
|
175
|
-
const modelConfig = repo.config.models
|
|
176
|
-
const model =
|
|
175
|
+
const modelConfig = repo.config.models?.main;
|
|
176
|
+
const model = modelConfig
|
|
177
|
+
? {provider: modelConfig.provider, model: modelConfig.model}
|
|
178
|
+
: {provider: 'unknown', model: 'unknown'};
|
|
177
179
|
|
|
178
180
|
const {runEvalSuite} = await import('@amodalai/core');
|
|
179
181
|
const gen = runEvalSuite(repo, {queryProvider, judgeProvider, filter: options.filter, gitSha, model});
|
package/src/e2e-commands.test.ts
CHANGED
|
@@ -17,12 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
import {describe, it, expect, beforeAll, afterAll} from 'vitest';
|
|
19
19
|
import {mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync} from 'node:fs';
|
|
20
|
-
import {join
|
|
20
|
+
import {join} from 'node:path';
|
|
21
21
|
import {tmpdir} from 'node:os';
|
|
22
22
|
import type http from 'node:http';
|
|
23
|
-
import {spawn} from 'node:child_process';
|
|
24
|
-
import type {ChildProcess} from 'node:child_process';
|
|
25
|
-
import {fileURLToPath} from 'node:url';
|
|
26
23
|
|
|
27
24
|
import {runInit} from './commands/init.js';
|
|
28
25
|
import {runValidate} from './commands/validate.js';
|
|
@@ -30,11 +27,6 @@ import {runInspect} from './commands/inspect.js';
|
|
|
30
27
|
import {runBuild} from './commands/build.js';
|
|
31
28
|
import {runDeploy} from './commands/deploy.js';
|
|
32
29
|
import {runDocker} from './commands/docker.js';
|
|
33
|
-
import {runStatus} from './commands/status.js';
|
|
34
|
-
import {runDeployments} from './commands/deployments.js';
|
|
35
|
-
import {runRollback} from './commands/rollback.js';
|
|
36
|
-
import {runPromote} from './commands/promote.js';
|
|
37
|
-
import {runExperimentCommand} from './commands/experiment.js';
|
|
38
30
|
|
|
39
31
|
// ---------------------------------------------------------------------------
|
|
40
32
|
// Shared: Create a repo on disk for local-repo commands
|
|
@@ -51,29 +43,6 @@ function createTestRepo(): string {
|
|
|
51
43
|
models: {main: {provider: 'anthropic', model: 'claude-sonnet-4-20250514'}},
|
|
52
44
|
}, null, 2));
|
|
53
45
|
|
|
54
|
-
// Connection
|
|
55
|
-
const connDir = join(dir, 'connections', 'test-api');
|
|
56
|
-
mkdirSync(connDir, {recursive: true});
|
|
57
|
-
writeFileSync(join(connDir, 'spec.json'), JSON.stringify({
|
|
58
|
-
baseUrl: 'https://api.example.com',
|
|
59
|
-
specUrl: 'https://api.example.com/openapi.json',
|
|
60
|
-
format: 'openapi',
|
|
61
|
-
auth: {type: 'bearer', header: 'Authorization', prefix: 'Bearer', token: 'env:TEST_API_TOKEN'},
|
|
62
|
-
}, null, 2));
|
|
63
|
-
writeFileSync(join(connDir, 'access.json'), JSON.stringify({
|
|
64
|
-
endpoints: {
|
|
65
|
-
'GET /items': {returns: ['id', 'name', 'status']},
|
|
66
|
-
'GET /items/:id': {returns: ['id', 'name', 'status', 'details']},
|
|
67
|
-
},
|
|
68
|
-
}, null, 2));
|
|
69
|
-
writeFileSync(join(connDir, 'surface.md'), [
|
|
70
|
-
'## Included',
|
|
71
|
-
'- [x] GET /items — List all items',
|
|
72
|
-
'- [x] GET /items/:id — Get item by ID',
|
|
73
|
-
'## Excluded',
|
|
74
|
-
'- [ ] POST /items — Create new item (write)',
|
|
75
|
-
].join('\n'));
|
|
76
|
-
|
|
77
46
|
// Skill
|
|
78
47
|
const skillDir = join(dir, 'skills', 'test-triage');
|
|
79
48
|
mkdirSync(skillDir, {recursive: true});
|
|
@@ -100,85 +69,16 @@ function createTestRepo(): string {
|
|
|
100
69
|
const autoDir = join(dir, 'automations');
|
|
101
70
|
mkdirSync(autoDir, {recursive: true});
|
|
102
71
|
writeFileSync(join(autoDir, 'daily-check.md'), [
|
|
103
|
-
'
|
|
104
|
-
'
|
|
105
|
-
'
|
|
106
|
-
'
|
|
107
|
-
' channel: slack',
|
|
108
|
-
' target: "#ops"',
|
|
109
|
-
'---',
|
|
72
|
+
'# Automation: Daily Health Check',
|
|
73
|
+
'',
|
|
74
|
+
'Schedule: 0 9 * * *',
|
|
75
|
+
'',
|
|
110
76
|
'Check the status of all items and report any issues.',
|
|
111
77
|
].join('\n'));
|
|
112
78
|
|
|
113
79
|
return dir;
|
|
114
80
|
}
|
|
115
81
|
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
// Shared: Start the real @amodalai/platform-api (Next.js) server
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
|
|
120
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
121
|
-
const __dirname = dirname(__filename);
|
|
122
|
-
// In vitest forks, __dirname is the source dir (packages/cli/src).
|
|
123
|
-
// Go up to packages/ then into platform-api/.
|
|
124
|
-
const PLATFORM_API_DIR = resolve(__dirname, '../../platform-api');
|
|
125
|
-
|
|
126
|
-
async function waitForServer(url: string, timeoutMs = 30000): Promise<void> {
|
|
127
|
-
const deadline = Date.now() + timeoutMs;
|
|
128
|
-
while (Date.now() < deadline) {
|
|
129
|
-
try {
|
|
130
|
-
const res = await fetch(url);
|
|
131
|
-
if (res.ok) return;
|
|
132
|
-
} catch {
|
|
133
|
-
// not ready yet
|
|
134
|
-
}
|
|
135
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
136
|
-
}
|
|
137
|
-
throw new Error(`Server at ${url} did not become ready within ${timeoutMs}ms`);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function startPlatformApi(port: number): ChildProcess {
|
|
141
|
-
// Run next dev via node directly — avoids PATH/shell issues in vitest forks
|
|
142
|
-
const nextCli = resolve(PLATFORM_API_DIR, 'node_modules/next/dist/bin/next');
|
|
143
|
-
const child = spawn(process.execPath, [nextCli, 'dev', '--port', String(port)], {
|
|
144
|
-
cwd: PLATFORM_API_DIR,
|
|
145
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
|
-
env: {
|
|
147
|
-
...process.env,
|
|
148
|
-
PORT: String(port),
|
|
149
|
-
// No DATABASE_URL → uses PGlite in-memory (zero external deps)
|
|
150
|
-
DATABASE_URL: '',
|
|
151
|
-
NODE_ENV: 'development',
|
|
152
|
-
},
|
|
153
|
-
});
|
|
154
|
-
return child;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
interface OnboardingResult {
|
|
158
|
-
org: {id: string};
|
|
159
|
-
app: {id: string};
|
|
160
|
-
api_key: {id: string; key: string};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async function onboard(baseUrl: string): Promise<OnboardingResult> {
|
|
164
|
-
const resp = await fetch(`${baseUrl}/api/onboarding`, {
|
|
165
|
-
method: 'POST',
|
|
166
|
-
headers: {'Content-Type': 'application/json'},
|
|
167
|
-
body: JSON.stringify({
|
|
168
|
-
app_name: 'e2e-commands-test',
|
|
169
|
-
agent_context: 'E2E test agent for CLI command testing',
|
|
170
|
-
provider: 'anthropic',
|
|
171
|
-
model: 'claude-sonnet-4-20250514',
|
|
172
|
-
}),
|
|
173
|
-
});
|
|
174
|
-
if (!resp.ok) {
|
|
175
|
-
const text = await resp.text();
|
|
176
|
-
throw new Error(`Onboarding failed (${resp.status}): ${text}`);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return resp.json() as Promise<OnboardingResult>;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
82
|
// ===========================================================================
|
|
183
83
|
// GROUP 1: Local Repo Commands
|
|
184
84
|
// ===========================================================================
|
|
@@ -258,14 +158,6 @@ describe('E2E Commands: Local repo', () => {
|
|
|
258
158
|
expect((snapshot['config'] as Record<string, unknown>)['name']).toBe('e2e-commands-test');
|
|
259
159
|
});
|
|
260
160
|
|
|
261
|
-
// --- list ---
|
|
262
|
-
|
|
263
|
-
it('should list packages (empty lock file)', async () => {
|
|
264
|
-
const code = await runList({cwd: repoDir});
|
|
265
|
-
// No lock file = returns 0 with "no packages" message
|
|
266
|
-
expect(code).toBe(0);
|
|
267
|
-
});
|
|
268
|
-
|
|
269
161
|
// --- docker init ---
|
|
270
162
|
|
|
271
163
|
it('should generate docker files from repo', async () => {
|
|
@@ -296,182 +188,12 @@ describe('E2E Commands: Local repo', () => {
|
|
|
296
188
|
});
|
|
297
189
|
|
|
298
190
|
// ===========================================================================
|
|
299
|
-
// GROUP 2:
|
|
191
|
+
// GROUP 2: Runtime Commands (boots a real @amodalai/runtime server)
|
|
300
192
|
// ===========================================================================
|
|
301
193
|
|
|
302
|
-
|
|
303
|
-
let platformProc: ChildProcess;
|
|
304
|
-
let platformPort: number;
|
|
305
|
-
let apiKey: string;
|
|
306
|
-
let origUrl: string | undefined;
|
|
307
|
-
let origKey: string | undefined;
|
|
308
|
-
let origHome: string | undefined;
|
|
309
|
-
|
|
310
|
-
beforeAll(async () => {
|
|
311
|
-
// Pick a random port in the ephemeral range
|
|
312
|
-
platformPort = 14000 + Math.floor(Math.random() * 1000);
|
|
313
|
-
const baseUrl = `http://127.0.0.1:${platformPort}`;
|
|
314
|
-
|
|
315
|
-
// Start the real platform-api (Next.js dev server with PGlite in-memory)
|
|
316
|
-
platformProc = startPlatformApi(platformPort);
|
|
317
|
-
await waitForServer(`${baseUrl}/api/health`, 60000);
|
|
318
|
-
|
|
319
|
-
// Onboard to create org + app + API key
|
|
320
|
-
const result = await onboard(baseUrl);
|
|
321
|
-
apiKey = result.api_key.key;
|
|
322
|
-
|
|
323
|
-
// Deploy two snapshots so status/deployments/rollback/promote have data
|
|
324
|
-
const repoDir = createTestRepo();
|
|
325
|
-
try {
|
|
326
|
-
// First deploy to production
|
|
327
|
-
const snap1 = join(repoDir, 'snap1.json');
|
|
328
|
-
await runBuild({cwd: repoDir, output: snap1});
|
|
329
|
-
const snap1Data = JSON.parse(readFileSync(snap1, 'utf-8')) as Record<string, unknown>;
|
|
330
|
-
await fetch(`${baseUrl}/api/snapshot-deployments`, {
|
|
331
|
-
method: 'POST',
|
|
332
|
-
headers: {'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`},
|
|
333
|
-
body: JSON.stringify({snapshot: snap1Data, environment: 'production'}),
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
// Second deploy to staging
|
|
337
|
-
const snap2 = join(repoDir, 'snap2.json');
|
|
338
|
-
await runBuild({cwd: repoDir, output: snap2});
|
|
339
|
-
const snap2Data = JSON.parse(readFileSync(snap2, 'utf-8')) as Record<string, unknown>;
|
|
340
|
-
await fetch(`${baseUrl}/api/snapshot-deployments`, {
|
|
341
|
-
method: 'POST',
|
|
342
|
-
headers: {'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`},
|
|
343
|
-
body: JSON.stringify({snapshot: snap2Data, environment: 'staging'}),
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// Third deploy to production (so rollback has a previous version)
|
|
347
|
-
const snap3 = join(repoDir, 'snap3.json');
|
|
348
|
-
await runBuild({cwd: repoDir, output: snap3});
|
|
349
|
-
const snap3Data = JSON.parse(readFileSync(snap3, 'utf-8')) as Record<string, unknown>;
|
|
350
|
-
await fetch(`${baseUrl}/api/snapshot-deployments`, {
|
|
351
|
-
method: 'POST',
|
|
352
|
-
headers: {'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`},
|
|
353
|
-
body: JSON.stringify({snapshot: snap3Data, environment: 'production'}),
|
|
354
|
-
});
|
|
355
|
-
} finally {
|
|
356
|
-
rmSync(repoDir, {recursive: true, force: true});
|
|
357
|
-
}
|
|
194
|
+
const hasDb = !!process.env['DATABASE_URL'];
|
|
358
195
|
|
|
359
|
-
|
|
360
|
-
origUrl = process.env['PLATFORM_API_URL'];
|
|
361
|
-
origKey = process.env['PLATFORM_API_KEY'];
|
|
362
|
-
origHome = process.env['HOME'];
|
|
363
|
-
process.env['PLATFORM_API_URL'] = baseUrl;
|
|
364
|
-
process.env['PLATFORM_API_KEY'] = apiKey;
|
|
365
|
-
// Isolate from real ~/.amodalrc
|
|
366
|
-
process.env['HOME'] = mkdtempSync(join(tmpdir(), 'amodal-e2e-home-'));
|
|
367
|
-
}, 120000);
|
|
368
|
-
|
|
369
|
-
afterAll(async () => {
|
|
370
|
-
if (origUrl !== undefined) process.env['PLATFORM_API_URL'] = origUrl;
|
|
371
|
-
else delete process.env['PLATFORM_API_URL'];
|
|
372
|
-
if (origKey !== undefined) process.env['PLATFORM_API_KEY'] = origKey;
|
|
373
|
-
else delete process.env['PLATFORM_API_KEY'];
|
|
374
|
-
if (origHome !== undefined) process.env['HOME'] = origHome;
|
|
375
|
-
else delete process.env['HOME'];
|
|
376
|
-
|
|
377
|
-
if (platformProc) {
|
|
378
|
-
platformProc.kill('SIGTERM');
|
|
379
|
-
// Give it a moment to shut down gracefully
|
|
380
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
381
|
-
if (!platformProc.killed) platformProc.kill('SIGKILL');
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
// --- status ---
|
|
386
|
-
|
|
387
|
-
it('should show deployment status', async () => {
|
|
388
|
-
const code = await runStatus({});
|
|
389
|
-
expect(code).toBe(0);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it('should show status for specific environment', async () => {
|
|
393
|
-
const code = await runStatus({env: 'staging'});
|
|
394
|
-
expect(code).toBe(0);
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
it('should show status as JSON', async () => {
|
|
398
|
-
const code = await runStatus({json: true});
|
|
399
|
-
expect(code).toBe(0);
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
// --- deployments ---
|
|
403
|
-
|
|
404
|
-
it('should list deployments', async () => {
|
|
405
|
-
const code = await runDeployments({});
|
|
406
|
-
expect(code).toBe(0);
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
it('should list deployments filtered by env', async () => {
|
|
410
|
-
const code = await runDeployments({env: 'production'});
|
|
411
|
-
expect(code).toBe(0);
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it('should list deployments as JSON', async () => {
|
|
415
|
-
const code = await runDeployments({json: true});
|
|
416
|
-
expect(code).toBe(0);
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it('should list deployments with limit', async () => {
|
|
420
|
-
const code = await runDeployments({limit: 1});
|
|
421
|
-
expect(code).toBe(0);
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
// --- rollback ---
|
|
425
|
-
|
|
426
|
-
it('should rollback production deployment', async () => {
|
|
427
|
-
const code = await runRollback({env: 'production'});
|
|
428
|
-
expect(code).toBe(0);
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
// --- promote ---
|
|
432
|
-
|
|
433
|
-
it('should promote staging to production', async () => {
|
|
434
|
-
const code = await runPromote({fromEnv: 'staging', toEnv: 'production'});
|
|
435
|
-
expect(code).toBe(0);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
// --- experiment ---
|
|
439
|
-
|
|
440
|
-
it('should list experiments', async () => {
|
|
441
|
-
await runExperimentCommand({
|
|
442
|
-
action: 'list',
|
|
443
|
-
platformUrl: `http://127.0.0.1:${platformPort}`,
|
|
444
|
-
platformApiKey: apiKey,
|
|
445
|
-
});
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
it('should create an experiment', async () => {
|
|
449
|
-
await runExperimentCommand({
|
|
450
|
-
action: 'create',
|
|
451
|
-
name: 'new-experiment',
|
|
452
|
-
platformUrl: `http://127.0.0.1:${platformPort}`,
|
|
453
|
-
platformApiKey: apiKey,
|
|
454
|
-
});
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
// --- deploy (real upload to real platform) ---
|
|
458
|
-
|
|
459
|
-
it('should deploy snapshot to platform', async () => {
|
|
460
|
-
const repoDir = createTestRepo();
|
|
461
|
-
try {
|
|
462
|
-
const code = await runDeploy({cwd: repoDir, message: 'e2e platform deploy', env: 'staging'});
|
|
463
|
-
expect(code).toBe(0);
|
|
464
|
-
} finally {
|
|
465
|
-
rmSync(repoDir, {recursive: true, force: true});
|
|
466
|
-
}
|
|
467
|
-
});
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
// ===========================================================================
|
|
471
|
-
// GROUP 3: Runtime Commands (boots a real @amodalai/runtime server)
|
|
472
|
-
// ===========================================================================
|
|
473
|
-
|
|
474
|
-
describe('E2E Commands: Runtime', () => {
|
|
196
|
+
describe.skipIf(!hasDb)('E2E Commands: Runtime', () => {
|
|
475
197
|
let repoDir: string;
|
|
476
198
|
let localServer: {app: unknown; start: () => Promise<unknown>; stop: () => Promise<void>} | null = null;
|
|
477
199
|
let localPort: number;
|
|
@@ -540,11 +262,7 @@ describe('E2E Commands: Runtime', () => {
|
|
|
540
262
|
|
|
541
263
|
// --- automations ---
|
|
542
264
|
|
|
543
|
-
|
|
544
|
-
const {runAutomationsList} = await import('./commands/automations.js');
|
|
545
|
-
const code = await runAutomationsList({url: `http://127.0.0.1:${localPort}`});
|
|
546
|
-
expect(code).toBe(0);
|
|
547
|
-
});
|
|
265
|
+
// Automations route tested separately in runtime smoke tests
|
|
548
266
|
|
|
549
267
|
// --- eval (exits early — no evals/ dir in our test repo) ---
|
|
550
268
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Amodal Labs, Inc.
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Subprocess smoke tests — verify that `amodal dev` spawns all three
|
|
9
|
+
* processes (runtime, studio, admin agent) and they respond to health
|
|
10
|
+
* checks.
|
|
11
|
+
*
|
|
12
|
+
* Requires DATABASE_URL and at least one provider API key.
|
|
13
|
+
* Skips cleanly when prerequisites are missing.
|
|
14
|
+
*
|
|
15
|
+
* Run:
|
|
16
|
+
* pnpm --filter @amodalai/amodal vitest run src/e2e-subprocess.test.ts
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {describe, it, expect, beforeAll, afterAll} from 'vitest';
|
|
20
|
+
import {spawn, type ChildProcess} from 'node:child_process';
|
|
21
|
+
import {resolve} from 'node:path';
|
|
22
|
+
import {mkdtempSync, writeFileSync, rmSync,readFileSync, existsSync} from 'node:fs';
|
|
23
|
+
import {tmpdir} from 'node:os';
|
|
24
|
+
import {fileURLToPath} from 'node:url';
|
|
25
|
+
|
|
26
|
+
const __dir = resolve(fileURLToPath(import.meta.url), '..');
|
|
27
|
+
|
|
28
|
+
function loadTestEnv(): void {
|
|
29
|
+
try {
|
|
30
|
+
const envPath = resolve(__dir, '../../../.env.test');
|
|
31
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
32
|
+
for (const line of content.split('\n')) {
|
|
33
|
+
const match = line.match(/^([^#=]+)=(.*)$/);
|
|
34
|
+
if (match) {
|
|
35
|
+
const [, key, value] = match;
|
|
36
|
+
if (key && value && !process.env[key.trim()]) {
|
|
37
|
+
process.env[key.trim()] = value.trim();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch { /* no .env.test */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
loadTestEnv();
|
|
45
|
+
|
|
46
|
+
const RUNTIME_PORT = 19847;
|
|
47
|
+
const STUDIO_PORT = 3848;
|
|
48
|
+
const ADMIN_PORT = 3849;
|
|
49
|
+
|
|
50
|
+
const hasApiKey = !!(
|
|
51
|
+
process.env['GOOGLE_API_KEY'] ||
|
|
52
|
+
process.env['ANTHROPIC_API_KEY'] ||
|
|
53
|
+
process.env['OPENAI_API_KEY']
|
|
54
|
+
);
|
|
55
|
+
const hasDb = !!process.env['DATABASE_URL'];
|
|
56
|
+
const skipReason = !hasApiKey
|
|
57
|
+
? 'No provider API key configured'
|
|
58
|
+
: !hasDb
|
|
59
|
+
? 'DATABASE_URL not set'
|
|
60
|
+
: '';
|
|
61
|
+
|
|
62
|
+
async function waitForHealth(port: number, maxMs = 30_000): Promise<boolean> {
|
|
63
|
+
const deadline = Date.now() + maxMs;
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`http://localhost:${port}/health`, {signal: AbortSignal.timeout(1000)});
|
|
67
|
+
if (res.ok) return true;
|
|
68
|
+
} catch { /* not ready */ }
|
|
69
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe.skipIf(!!skipReason)('subprocess smoke tests', () => {
|
|
75
|
+
let child: ChildProcess | null = null;
|
|
76
|
+
let agentDir: string;
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
agentDir = mkdtempSync(resolve(tmpdir(), 'amodal-subprocess-smoke-'));
|
|
80
|
+
writeFileSync(
|
|
81
|
+
resolve(agentDir, 'amodal.json'),
|
|
82
|
+
JSON.stringify({name: 'subprocess-smoke', version: '1.0.0'}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const cliEntry = resolve(__dir, '../dist/src/main.js');
|
|
86
|
+
if (!existsSync(cliEntry)) {
|
|
87
|
+
throw new Error(`CLI not built — run pnpm --filter @amodalai/amodal run build first`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
child = spawn(
|
|
91
|
+
process.execPath,
|
|
92
|
+
[cliEntry, 'dev', '--port', String(RUNTIME_PORT)],
|
|
93
|
+
{
|
|
94
|
+
cwd: agentDir,
|
|
95
|
+
env: {
|
|
96
|
+
...process.env,
|
|
97
|
+
AMODAL_NO_ADMIN: undefined,
|
|
98
|
+
AMODAL_NO_STUDIO: undefined,
|
|
99
|
+
},
|
|
100
|
+
stdio: 'pipe',
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
105
|
+
process.stderr.write(`[subprocess-smoke] ${chunk.toString()}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const runtimeOk = await waitForHealth(RUNTIME_PORT, 30_000);
|
|
109
|
+
if (!runtimeOk) {
|
|
110
|
+
throw new Error('Runtime did not start within 30s');
|
|
111
|
+
}
|
|
112
|
+
}, 60_000);
|
|
113
|
+
|
|
114
|
+
afterAll(() => {
|
|
115
|
+
if (child) {
|
|
116
|
+
child.kill('SIGTERM');
|
|
117
|
+
child = null;
|
|
118
|
+
}
|
|
119
|
+
if (agentDir) {
|
|
120
|
+
rmSync(agentDir, {recursive: true, force: true});
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('runtime responds to health check', async () => {
|
|
125
|
+
const res = await fetch(`http://localhost:${RUNTIME_PORT}/health`, {signal: AbortSignal.timeout(5000)});
|
|
126
|
+
expect(res.status).toBe(200);
|
|
127
|
+
const body = await res.json() as Record<string, unknown>;
|
|
128
|
+
expect(body['status']).toBe('ok');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('studio responds to health check', async () => {
|
|
132
|
+
const ok = await waitForHealth(STUDIO_PORT, 15_000);
|
|
133
|
+
expect(ok).toBe(true);
|
|
134
|
+
}, 20_000);
|
|
135
|
+
|
|
136
|
+
it('admin agent responds to health check', async () => {
|
|
137
|
+
const ok = await waitForHealth(ADMIN_PORT, 15_000);
|
|
138
|
+
expect(ok).toBe(true);
|
|
139
|
+
}, 20_000);
|
|
140
|
+
|
|
141
|
+
it('admin agent accepts a chat request', async () => {
|
|
142
|
+
const res = await fetch(`http://localhost:${ADMIN_PORT}/chat`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: {'Content-Type': 'application/json'},
|
|
145
|
+
body: JSON.stringify({message: 'Say hello in one word'}),
|
|
146
|
+
signal: AbortSignal.timeout(30_000),
|
|
147
|
+
});
|
|
148
|
+
expect(res.status).toBe(200);
|
|
149
|
+
const text = await res.text();
|
|
150
|
+
expect(text.length).toBeGreaterThan(0);
|
|
151
|
+
expect(text).toContain('data:');
|
|
152
|
+
}, 45_000);
|
|
153
|
+
});
|