@amodalai/amodal 0.3.26 → 0.3.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amodalai/amodal",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
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.26",
30
- "@amodalai/core": "0.3.26",
31
- "@amodalai/db": "0.3.26",
32
- "@amodalai/runtime": "0.3.26",
33
- "@amodalai/studio": "0.3.26",
34
- "@amodalai/runtime-app": "0.3.26"
29
+ "@amodalai/types": "0.3.28",
30
+ "@amodalai/core": "0.3.28",
31
+ "@amodalai/db": "0.3.28",
32
+ "@amodalai/runtime": "0.3.28",
33
+ "@amodalai/studio": "0.3.28",
34
+ "@amodalai/runtime-app": "0.3.28"
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
  }
@@ -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 {findFreePort} from '../shared/find-free-port.js';
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 it has an amodal.json (is a valid agent directory)
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 = await findFreePort(options.port ?? DEFAULT_RUNTIME_PORT);
412
- const studioPort = options.noStudio
413
- ? DEFAULT_STUDIO_PORT
414
- : await findFreePort(DEFAULT_STUDIO_PORT);
415
- const adminPort = options.noAdmin
416
- ? DEFAULT_ADMIN_PORT
417
- : await findFreePort(DEFAULT_ADMIN_PORT);
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
  };
@@ -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.main;
176
- const model = {provider: modelConfig.provider, model: modelConfig.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});
@@ -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, resolve,dirname} from 'node:path';
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
- 'title: Daily Health Check',
105
- 'schedule: "0 9 * * *"',
106
- 'output:',
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: Platform API Commands (real @amodalai/platform-api server)
191
+ // GROUP 2: Runtime Commands (boots a real @amodalai/runtime server)
300
192
  // ===========================================================================
301
193
 
302
- describe('E2E Commands: Platform API', () => {
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
- // Set env vars so PlatformClient and resolvePlatformConfig use the real server
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
- it('should list automations on the running server', async () => {
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
+ });