Skip to content

Commit 2e4e1ff

Browse files
committed
test(cli): the install-local handler pin spawns the tsx source entry, signs in as the dev admin, and attributes every exchange to the child
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
1 parent a6dec5b commit 2e4e1ff

1 file changed

Lines changed: 96 additions & 58 deletions

File tree

‎packages/cli/test/package-install-local-handlers.integration.test.ts‎

Lines changed: 96 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -38,42 +38,50 @@
3838
*
3939
* ## Spawn shape
4040
*
41-
* `bin/run.js` with `NODE_ENV` unset (hence `requireBuiltCli`) — the operator's
42-
* entrypoint, and the one whose bind is deterministic (no development
43-
* auto-shift). Every workspace package the child loads, `@objectstack/runtime`
44-
* and `@objectstack/cloud-connection` included, resolves through its `exports`
45-
* to `dist/`: an ablation of either package's source reaches this file only
46-
* after that package is rebuilt. Each `os start` gets its own process group and
47-
* is stopped by signalling the group (`os start` supervises a `serve`
48-
* grandchild).
41+
* The tsx source entry (`bin/run-dev.js`), as every `runServe()` caller spawns
42+
* it, for `os start` and for `os package install` alike. It pins
43+
* `NODE_ENV=development`, which lets `serve` auto-shift off a port taken
44+
* between the probe and the bind, so the ready banner is read back
45+
* (`portDriftError`) before any request is addressed to the port. The CLI runs
46+
* from `src/`, but every workspace package it loads — `@objectstack/runtime`
47+
* and `@objectstack/cloud-connection` included — resolves through its
48+
* `exports` to `dist/`: an ablation of either package's source reaches this
49+
* file only after that package is rebuilt. Each `os start` gets its own
50+
* process group and is stopped by signalling the group (`os start` supervises
51+
* a `serve` grandchild).
4952
*/
5053

5154
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
52-
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
55+
import { spawn, type ChildProcess } from 'node:child_process';
5356
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5457
import { tmpdir } from 'node:os';
55-
import { dirname, join, resolve } from 'node:path';
56-
import { fileURLToPath } from 'node:url';
58+
import { join } from 'node:path';
5759
import {
60+
CLI,
5861
childEnv,
5962
E2E_SECRET_KEY,
6063
portContentionError,
64+
portDriftError,
65+
probeThroughChild,
6166
randomPort,
62-
requireBuiltCli,
63-
RUN_JS_RESOLVES_FROM_DIST,
67+
TSX,
6468
} from './helpers/serve-process.js';
6569

66-
const HERE = dirname(fileURLToPath(import.meta.url));
67-
const RUN_JS = resolve(HERE, '../bin/run.js');
68-
6970
/** The banner's tail — every row above it has printed. */
7071
const READY = /Press Ctrl\+C to stop/;
7172
const BOOT_TIMEOUT_MS = 180_000;
7273

7374
const APP_ID = 'com.example.tasksapp';
7475
const OBJECT = 'tasks_app_task';
75-
const EMAIL = 'owner@example.com';
76-
const PASSWORD = 'Passw0rd!Passw0rd';
76+
/**
77+
* The development dev-admin seed (`objectstack dev`'s documented, loginable
78+
* admin — `admin@objectos.ai` / `admin123`, promoted to platform admin): the
79+
* tsx entry runs `os start` in development, which seeds it on every boot that
80+
* finds no login, so it is the operator on all three boots and holds the
81+
* `manage_metadata` capability the install route demands.
82+
*/
83+
const EMAIL = 'admin@objectos.ai';
84+
const PASSWORD = 'admin123';
7785

7886
const COMPLETE_TASK = {
7987
name: 'complete_task',
@@ -136,11 +144,10 @@ interface LiveStart {
136144

137145
function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise<LiveStart> {
138146
return new Promise((resolveBoot, rejectBoot) => {
139-
const child = spawn(process.execPath, [RUN_JS, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], {
147+
const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], {
140148
cwd,
141-
// `childEnv`, never a bare `...process.env` — see its header. `NODE_ENV`
142-
// unset: the built entrypoint resolves commands from dist/ (#11464).
143-
env: childEnv({ NODE_ENV: undefined, NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }),
149+
// `childEnv`, never a bare `...process.env` — see its header.
150+
env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }),
144151
stdio: ['ignore', 'pipe', 'pipe'],
145152
// Own process group: `os start` supervises a `serve` grandchild.
146153
detached: true,
@@ -161,7 +168,8 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = []
161168
);
162169
const onData = (d: unknown) => {
163170
out += String(d);
164-
if (READY.test(out)) settle(null);
171+
// The child is the authority on the port it bound.
172+
if (READY.test(out)) settle(portDriftError(out, 'os start', port));
165173
};
166174
child.stdout?.on('data', onData);
167175
child.stderr?.on('data', onData);
@@ -186,52 +194,83 @@ async function stopGroup(child: ChildProcess): Promise<void> {
186194

187195
interface Answer { status: number; body: any }
188196

189-
async function http(live: LiveStart, method: string, path: string, token: string, body?: unknown): Promise<Answer> {
190-
const r = await fetch(`${live.base}${path}`, {
191-
method,
192-
headers: {
193-
origin: live.base,
194-
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
195-
...(token ? { authorization: `Bearer ${token}` } : {}),
197+
/**
198+
* One exchange against the running `os start`, attributed to the child if the
199+
* transport fails (`probeThroughChild`: a dead child is named with its
200+
* transcript; a socket the live server dropped — the keep-alive connection an
201+
* idle stretch outlives — is absorbed and retried, loudly, a bounded number of
202+
* times). ⛔ No assertion inside it.
203+
*/
204+
function exchange<T>(live: LiveStart, what: string, run: () => Promise<T>): Promise<T> {
205+
return probeThroughChild(
206+
{
207+
child: live.child,
208+
transcript: () => `\n--- child output ---\n${live.output().slice(-4000)}`,
209+
label: 'package-install-local-handlers',
210+
what,
196211
},
197-
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
212+
run,
213+
);
214+
}
215+
216+
function http(live: LiveStart, method: string, path: string, token: string, body?: unknown): Promise<Answer> {
217+
return exchange(live, `${method} ${path}`, async () => {
218+
const r = await fetch(`${live.base}${path}`, {
219+
method,
220+
headers: {
221+
origin: live.base,
222+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
223+
...(token ? { authorization: `Bearer ${token}` } : {}),
224+
},
225+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
226+
});
227+
const text = await r.text();
228+
let parsed: any = text;
229+
try { parsed = JSON.parse(text); } catch { /* keep the text */ }
230+
return { status: r.status, body: parsed };
198231
});
199-
const text = await r.text();
200-
let parsed: any = text;
201-
try { parsed = JSON.parse(text); } catch { /* keep the text */ }
202-
return { status: r.status, body: parsed };
203232
}
204233

205-
async function authenticate(live: LiveStart, firstUser: boolean): Promise<string> {
206-
const res = await http(
207-
live, 'POST', firstUser ? '/api/v1/auth/sign-up/email' : '/api/v1/auth/sign-in/email', '',
208-
firstUser ? { email: EMAIL, password: PASSWORD, name: 'Owner' } : { email: EMAIL, password: PASSWORD },
209-
);
234+
async function authenticate(live: LiveStart): Promise<string> {
235+
const res = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD });
210236
const token = res.body?.token;
211237
if (res.status !== 200 || typeof token !== 'string') {
212238
throw new Error(`auth answered ${res.status}: ${JSON.stringify(res.body)}\n--- output ---\n${live.output().slice(-3000)}`);
213239
}
214240
return token;
215241
}
216242

217-
function packageInstall(appDir: string, live: LiveStart): { exit: number | null; output: string } {
218-
const r = spawnSync(process.execPath, [RUN_JS, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
219-
cwd: appDir,
220-
encoding: 'utf8',
221-
env: childEnv({ NODE_ENV: undefined, NO_COLOR: '1' }),
222-
timeout: 120_000,
243+
/**
244+
* `os package install ./dist/objectstack.json` against the running runtime.
245+
* ⛔ Asynchronous on purpose: a `spawnSync` would stop this process draining
246+
* the server's stdout/stderr pipes for the whole install, and a server that
247+
* fills its pipe while the install waits on it blocks until the timeout.
248+
*/
249+
function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> {
250+
return new Promise((done) => {
251+
const child = spawn(TSX, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
252+
cwd: appDir,
253+
env: childEnv({ NO_COLOR: '1' }),
254+
stdio: ['ignore', 'pipe', 'pipe'],
255+
});
256+
let output = '';
257+
child.stdout?.on('data', (d) => { output += String(d); });
258+
child.stderr?.on('data', (d) => { output += String(d); });
259+
const timer = setTimeout(() => child.kill('SIGKILL'), 120_000);
260+
child.on('close', (code) => { clearTimeout(timer); done({ exit: code, output }); });
223261
});
224-
return { exit: r.status, output: `${r.stdout}\n${r.stderr}` };
225262
}
226263

227264
/** One MCP JSON-RPC call over Streamable HTTP; the tool's JSON text, parsed. */
228265
async function mcpTool(live: LiveStart, apiKey: string, name: string, args: Record<string, unknown>) {
229-
const r = await fetch(`${live.base}/api/v1/mcp`, {
230-
method: 'POST',
231-
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-api-key': apiKey },
232-
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }),
266+
const text = await exchange(live, `MCP tools/call ${name}`, async () => {
267+
const r = await fetch(`${live.base}/api/v1/mcp`, {
268+
method: 'POST',
269+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-api-key': apiKey },
270+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }),
271+
});
272+
return r.text();
233273
});
234-
const text = await r.text();
235274
const data = text.split('\n').find((l) => l.startsWith('data:'));
236275
const envelope = JSON.parse(data ? data.slice(5) : text);
237276
const content = envelope?.result?.content?.[0]?.text;
@@ -279,7 +318,6 @@ const phases: Record<'install' | 'reinstall' | 'restart' | 'control', Phase | un
279318
const installs: Array<{ exit: number | null; output: string }> = [];
280319

281320
beforeAll(async () => {
282-
requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST);
283321
const root = mkdtempSync(join(tmpdir(), 'install-local-handlers-'));
284322
dirs.push(root);
285323
const appDir = join(root, 'app');
@@ -293,23 +331,23 @@ beforeAll(async () => {
293331

294332
// ── boot 1: empty `os start`, install, probe, reinstall, probe ─────────
295333
const first = await bootStart(runtimeDir, home, port);
296-
const token = await authenticate(first, true);
297-
installs.push(packageInstall(appDir, first));
334+
const token = await authenticate(first);
335+
installs.push(await packageInstall(appDir, first));
298336
phases.install = await probe(first, token);
299-
installs.push(packageInstall(appDir, first));
337+
installs.push(await packageInstall(appDir, first));
300338
phases.reinstall = await probe(first, token);
301339
await stopGroup(first.child);
302340

303341
// ── boot 2: same home and cwd — the ledger rehydrates on kernel:ready ──
304342
const second = await bootStart(runtimeDir, home, port);
305-
phases.restart = await probe(second, await authenticate(second, false));
343+
phases.restart = await probe(second, await authenticate(second));
306344
await stopGroup(second.child);
307345

308346
// ── boot 3: the CONTROL — the same file as the boot artifact ───────────
309347
const controlDir = join(root, 'control');
310348
mkdirSync(controlDir, { recursive: true });
311349
const third = await bootStart(controlDir, join(controlDir, 'home'), port, ['--artifact', join(appDir, 'dist', 'objectstack.json')]);
312-
phases.control = await probe(third, await authenticate(third, true));
350+
phases.control = await probe(third, await authenticate(third));
313351
await stopGroup(third.child);
314352
}, 4 * BOOT_TIMEOUT_MS);
315353

0 commit comments

Comments
 (0)