storebound/tests/serve.test.ts
CRT c9a7f095c4 console: agent-first mode (console plan / console verify), verified App Privacy and Play Create app facts
- `storebound console plan <form> [--json] [--lookup]`: read-only plan for an AI agent that drives the browser
  itself: start URL (deep links), ordered steps, answer sheet with the console's exact labels (certain / ask owner,
  curly quotes noted), never-press list, verified UI facts, driving guidance (in-page async loop + window.__log),
  verify instructions. All six forms.
- `storebound console verify <form> [--from-text <file|->]`: App Privacy per-type summaries from page text, Play
  Create app from input states, content rating / target audience summaries, App Store record and age rating from
  the API; exit 1 on a mismatch.
- MCP: console_plan, console_verify (read-only); console fill stays off MCP.
- App Privacy (verified 2026-10-12): two information steps, unlabeled purpose checkboxes (nearest parent text),
  "Definitions and Examples" never pressed; fixture and engine follow.
- Play Create app (verified 2026-10-12): /developers/<id>/create-new-app, App package name + read-only Check
  availability, material-radio inner [role=radio] click, app dashboard URL and play.appId, service account
  "Add app" as an owner step.
- console-autofill skill rewritten agent-first; agents-core rule 10; README; pitfalls 12, 18 (confirmed sign-out),
  19; CHANGELOG; play-first-release.
2026-10-12 08:39:12 +08:00

323 lines
23 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { canonical, mac, MAX_TTL_MS, mint, parseSecret, TokenGuard } from '../cli/lib/confirm-token';
import { ERR } from '../cli/lib/desktop-protocol';
import { childEnv, pickCallEnv, type RunOptions, type Running } from '../cli/lib/runner';
import { FORBIDDEN_ARGS, TOOLS } from '../cli/mcp-tools';
import { describeMethods, DESKTOP_ONLY, DESKTOP_TOOLS, FORBIDDEN_METHOD_WORDS, JSON_TOOLS, methodName, OWNER_TOOLS, Session } from '../cli/serve';
import { buildOne, hostTarget, leaks } from '../tools/build-sidecars';
type J = Record<string, unknown>;
const ROOT = join(import.meta.dir, '..');
const DEMO = join(ROOT, 'examples/demo-game');
const SECRET_HEX = Array.from({ length: 32 }, (_, i) => i.toString(16).padStart(2, '0')).join('');
const SECRET = Buffer.from(SECRET_HEX, 'hex');
interface Call { argv: string[]; o: RunOptions }
function fakeRunner(reply: (argv: string[]) => { code?: number; out?: string; data?: unknown; lines?: string[]; wait?: Promise<void> } = () => ({})) {
const calls: Call[] = [];
const run = (argv: string[], o: RunOptions): Running => {
calls.push({ argv, o });
const r = reply(argv);
let cancelled = false;
let release: () => void = () => {};
const gate = new Promise<void>(res => { release = res; });
const result = (async () => {
for (const l of r.lines ?? []) o.onLine?.(l, 'stdout');
if (r.wait) await Promise.race([r.wait, gate]);
return { code: cancelled ? 143 : r.code ?? 0, out: r.out ?? '', ...(r.data === undefined ? {} : { data: r.data }), cancelled };
})();
return { result, cancel: () => { cancelled = true; release(); } };
};
return { calls, run };
}
/** Resolves once `cond` holds (the child was started), instead of a fixed sleep. */
async function until(cond: () => boolean, ms = 5000) {
const end = Date.now() + ms;
while (!cond()) { if (Date.now() > end) throw new Error('until: timed out'); await new Promise(r => setImmediate(r)); }
}
async function session(reply?: Parameters<typeof fakeRunner>[0], clock: () => number = Date.now) {
const sent: J[] = [];
const f = fakeRunner(reply);
const s = new Session(m => sent.push(m), f.run, clock);
let id = 0;
const call = async (method: string, params: unknown = {}) => (await s.handle({ jsonrpc: '2.0', id: ++id, method, params }))!;
const hello = await call('session.hello', { secret: SECRET_HEX, client: { name: 'test', version: '1' } });
return { s, sent, calls: f.calls, call, hello, nextId: () => id + 1 };
}
const errOf = (r: J) => r.error as { code: number; message: string; data?: J };
const resOf = (r: J) => r.result as J;
describe('confirm tokens', () => {
test('canonical JSON: sorted keys, no whitespace, safe integers only', () => {
expect(canonical({ b: 1, a: [true, null, 'x'], c: { z: '', y: undefined } })).toBe('{"a":[true,null,"x"],"b":1,"c":{"z":""}}');
expect(() => canonical({ f: 0.5 })).toThrow();
expect(() => canonical({ f: 2 ** 60 })).toThrow();
});
test('shared vector (the Rust host tests the same one in src-tauri/tests/confirm.rs)', () => {
const p = { project: '/tmp/game', args: { only: 'en-US,tr', b: [1, true, null, 'ü"\n'] } };
expect(canonical(p)).toBe('{"args":{"b":[1,true,null,"ü\\"\\n"],"only":"en-US,tr"},"project":"/tmp/game"}');
expect(mac(SECRET, 'play.listing', p, 1700000060000, 'AAAAAAAAAAAAAAAAAAAAAA')).toBe('JhSxUL2ej4cm4yrmlcA23rTCPq3YMKJthbB7n6aixxw');
});
test('binding, expiry, max lifetime, replay', () => {
let now = 1_000_000;
const g = new TokenGuard(SECRET, () => now);
const p = { project: '/p', args: { only: 'en-US' } };
const t = mint(SECRET, 'play.listing', p, { now });
expect(g.verify(t, 'play.listing', { project: '/p', args: { only: 'tr' } })).toEqual({ ok: false, error: 'mismatch' });
expect(g.verify(t, 'play.listing', { project: '/q', args: { only: 'en-US' } })).toEqual({ ok: false, error: 'mismatch' });
expect(g.verify(t, 'play.images', p)).toEqual({ ok: false, error: 'mismatch' });
expect(g.verify(mint(Buffer.alloc(32, 7), 'play.listing', p, { now }), 'play.listing', p)).toEqual({ ok: false, error: 'mismatch' });
expect(g.verify(t, 'play.listing', p)).toEqual({ ok: true });
expect(g.verify(t, 'play.listing', p)).toEqual({ ok: false, error: 'replayed' });
const old = mint(SECRET, 'play.listing', p, { now, ttlMs: 1000 });
now += 1000;
expect(g.verify(old, 'play.listing', p)).toEqual({ ok: false, error: 'expired' });
expect(g.verify(mint(SECRET, 'play.listing', p, { now, ttlMs: MAX_TTL_MS + 1 }), 'play.listing', p)).toEqual({ ok: false, error: 'too-long' });
for (const bad of [undefined, '', 'sbt1', 'sbt2.1.AAAAAAAAAAAAAAAAAAAAAA.x', `sbt1.${now + 5000}.short.${'A'.repeat(43)}`, t + 'x'])
expect(g.verify(bad, 'play.listing', p).ok).toBe(false);
});
test('secrets: hex, at least 32 bytes', () => {
expect(parseSecret(SECRET_HEX).length).toBe(32);
expect(() => parseSecret('abcd')).toThrow();
expect(() => parseSecret('zz'.repeat(32))).toThrow();
expect(() => parseSecret(42)).toThrow();
});
});
describe('serve --stdio: methods', () => {
test('nothing submits, releases or publishes', () => {
const names = describeMethods().map(m => m.method);
// the owner actions (rollout share, phased release, sending one approved reply: tests/desktop-d3.test.ts) are the
// only methods with those words, all under "owner.", and none submits, publishes, cancels, deletes or invites
const owner = describeMethods().filter(m => m.kind === 'owner').map(m => m.method);
expect(owner.sort()).toEqual(OWNER_TOOLS.map(t => methodName(t.name)).sort());
for (const n of owner) expect(n.startsWith('owner.')).toBe(true);
for (const n of names.filter(x => !owner.includes(x))) expect(n).not.toMatch(FORBIDDEN_METHOD_WORDS);
for (const n of names) expect(n).not.toMatch(/submit|publish|cancel|delete|remove|invite|manual/i);
for (const n of ['apple.submit', 'apple.release', 'play.release', 'play.release_testing', 'apple.phased_release', 'play.rollout', 'reviews.send', 'apple.ppo_start', 'prices.import'])
expect(names).not.toContain(n);
// every desktop tool still goes through the MCP rules: no forbidden flag can come out of any of them
for (const t of DESKTOP_TOOLS) expect(t.command.some(c => FORBIDDEN_ARGS.includes(c))).toBe(false);
expect(new Set(names).size).toBe(names.length);
});
test('typed descriptors for every tool; json flags only on read tools', () => {
const ms = describeMethods();
expect(ms.filter(m => m.kind === 'read' || m.kind === 'write').length).toBe(DESKTOP_TOOLS.length + 1); // + project.inspect
for (const m of ms) { expect(m.args.type).toBe('object'); expect(m.args.additionalProperties).toBe(false); expect(m.args.properties.dry_run).toBeUndefined(); }
for (const t of JSON_TOOLS) expect(DESKTOP_TOOLS.find(x => x.name === t)?.write).toBe(false);
// desktop-only methods are not MCP tools (approving a reply is the owner's decision)
for (const t of DESKTOP_ONLY) expect(TOOLS.some(x => x.name === t.name)).toBe(false);
expect(ms.map(m => m.method)).toEqual(expect.arrayContaining(['project.icon', 'project.listings', 'reviews.drafts', 'reviews.approve', 'project.init']));
expect(ms.map(m => m.method)).toEqual(expect.arrayContaining(['portfolio.status', 'project.inspect', 'check', 'validate', 'apple.status', 'play.status', 'reviews.list', 'apple.testflight_status']));
expect(methodName('apple_testflight_status')).toBe('apple.testflight_status');
});
});
describe('serve --stdio: session', () => {
test('hello first, once, with a real secret', async () => {
const s = new Session(() => {}, fakeRunner().run);
expect(errOf((await s.handle({ jsonrpc: '2.0', id: 1, method: 'system.version' }))!).code).toBe(ERR.notInitialized);
expect(errOf((await s.handle({ jsonrpc: '2.0', id: 2, method: 'session.hello', params: { secret: 'ab' } }))!).code).toBe(ERR.invalidParams);
const ok = resOf((await s.handle({ jsonrpc: '2.0', id: 3, method: 'session.hello', params: { secret: SECRET_HEX } }))!);
expect(ok.protocol).toBe('storebound-desktop/1');
expect((ok.methods as unknown[]).length).toBe(describeMethods().length);
expect(errOf((await s.handle({ jsonrpc: '2.0', id: 4, method: 'session.hello', params: { secret: SECRET_HEX } }))!).code).toBe(ERR.invalidRequest);
expect(resOf((await s.handle({ jsonrpc: '2.0', id: 5, method: 'system.version' }))!).version).toBeString();
expect(errOf((await s.handle({ jsonrpc: '2.0', id: 6, method: 'apple.submit', params: { project: DEMO } }))!).code).toBe(ERR.methodNotFound);
});
test('read method: --json appended, data and progress passed through, credentials only as per-call env', async () => {
const { call, calls, sent } = await session(() => ({ data: { blockers: 0, findings: [] }, out: 'log line', lines: ['12:00:00 [validate] apple'] }));
const r = resOf(await call('validate', { project: DEMO, args: { store: 'apple' }, credentials: { ASC_KEY_ID: 'K', HOME: '/evil', ASC_P8: '' } }));
expect(calls[0]!.argv).toEqual(['validate', '--apple', '--json']);
expect(calls[0]!.o).toMatchObject({ cwd: DEMO, machine: true, env: { ASC_KEY_ID: 'K' } });
expect(Object.keys(calls[0]!.o.env!)).toEqual(['ASC_KEY_ID']);
expect(r).toMatchObject({ ok: true, exitCode: 0, dryRun: false, data: { blockers: 0 }, command: 'storebound validate --apple --json' });
expect(sent).toContainEqual({ jsonrpc: '2.0', method: 'progress', params: { requestId: 2, line: '12:00:00 [validate] apple', stream: 'stdout' } });
});
test('bad params: relative or config-less project, unknown args, dry_run inside args', async () => {
const { call } = await session();
expect(errOf(await call('check', { project: 'examples/demo-game' })).code).toBe(ERR.invalidParams);
expect(errOf(await call('check', { project: tmpdir() })).message).toContain('no storebound.config.ts');
expect(errOf(await call('check', { project: DEMO, args: { nope: 1 } })).code).toBe(ERR.invalidParams);
expect(errOf(await call('play.listing', { project: DEMO, args: { dry_run: false } })).code).toBe(ERR.invalidParams);
expect(errOf(await call('check', { project: DEMO, extra: true })).code).toBe(ERR.invalidParams);
expect(errOf(await call('play.listing', { project: DEMO, args: { only: '--yes' } })).code).toBe(ERR.invalidParams);
});
test('writes: dry run by default; real run only with a matching, fresh, unused host token', async () => {
let now = 5_000_000;
const { call, calls } = await session(undefined, () => now);
const p = { project: DEMO, args: { only: 'en-US' } };
expect(resOf(await call('play.listing', p))).toMatchObject({ dryRun: true });
expect(calls[0]!.argv).toEqual(['play', 'listing', '--only', 'en-US', '--dry']);
expect(errOf(await call('play.listing', { ...p, dryRun: false })).code).toBe(ERR.confirmRequired);
const token = mint(SECRET, 'play.listing', p, { now });
expect(errOf(await call('play.listing', { ...p, args: { only: 'tr-TR' }, dryRun: false, confirmToken: token })).code).toBe(ERR.confirmInvalid);
expect(errOf(await call('play.images', { ...p, dryRun: false, confirmToken: token })).code).toBe(ERR.confirmInvalid);
const real = resOf(await call('play.listing', { ...p, dryRun: false, confirmToken: token, credentials: { PLAY_SERVICE_ACCOUNT: '{}' } }));
expect(real).toMatchObject({ dryRun: false });
expect(calls.at(-1)!.argv).toEqual(['play', 'listing', '--only', 'en-US']);
const replay = errOf(await call('play.listing', { ...p, dryRun: false, confirmToken: token }));
expect(replay).toMatchObject({ code: ERR.confirmInvalid, data: { reason: 'replayed' } });
const soon = mint(SECRET, 'play.listing', p, { now, ttlMs: 10 });
now += 11;
expect(errOf(await call('play.listing', { ...p, dryRun: false, confirmToken: soon })).data).toEqual({ reason: 'expired' });
// a read method never needs a token, and dryRun false on it is just a normal run
expect(resOf(await call('check', { project: DEMO, dryRun: false })).dryRun).toBe(false);
});
test('desktop-only methods: offline reads, reply approval and project.init are dry runs without a host token', async () => {
let now = 7_000_000;
const { call, calls } = await session(argv => (argv[0] === 'inspect' ? { data: { path: null, dataUrl: null } } : {}), () => now);
expect(resOf(await call('project.icon', { project: DEMO }))).toMatchObject({ ok: true, data: { dataUrl: null } });
expect(calls.at(-1)!.argv).toEqual(['inspect', '--icon', '--json']);
await call('project.listings', { project: DEMO });
expect(calls.at(-1)!.argv).toEqual(['inspect', '--listings', '--json']);
await call('reviews.drafts', { project: DEMO });
expect(calls.at(-1)!.argv).toEqual(['reviews', 'drafts', '--json']);
// approve: file names only, dry run unless the token matches
expect(errOf(await call('reviews.approve', { project: DEMO, args: { file: '../x.md' } })).code).toBe(ERR.invalidParams);
const a = { project: DEMO, args: { file: 'play-abc.md' } };
expect(resOf(await call('reviews.approve', a))).toMatchObject({ dryRun: true });
expect(calls.at(-1)!.argv).toEqual(['reviews', 'approve', 'play-abc.md', '--dry']);
expect(errOf(await call('reviews.approve', { ...a, dryRun: false })).code).toBe(ERR.confirmRequired);
await call('reviews.approve', { ...a, dryRun: false, confirmToken: mint(SECRET, 'reviews.approve', a, { now }) });
expect(calls.at(-1)!.argv).toEqual(['reviews', 'approve', 'play-abc.md']);
// init: an existing folder without a config; never one that has a config
const dir = mkdtempSync(join(tmpdir(), 'sb-init-'));
try {
expect(errOf(await call('project.init', { project: DEMO, args: { profile: 'game' } })).message).toContain('already has a storebound config');
const i = { project: dir, args: { profile: 'game' } };
expect(resOf(await call('project.init', i))).toMatchObject({ dryRun: true });
expect(calls.at(-1)).toMatchObject({ argv: ['init', '--profile', 'game', '--dry'], o: { cwd: dir } });
expect(errOf(await call('project.init', { ...i, dryRun: false, confirmToken: mint(SECRET, 'project.init', { ...i, args: { profile: 'app' } }, { now }) })).code).toBe(ERR.confirmInvalid);
await call('project.init', { ...i, dryRun: false, confirmToken: mint(SECRET, 'project.init', i, { now }) });
expect(calls.at(-1)!.argv).toEqual(['init', '--profile', 'game']);
// project.init is not a way to run other tools in a config-less folder
expect(errOf(await call('check', { project: dir })).message).toContain('no storebound.config.ts');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('cancellation answers -32800 and kills the child', async () => {
const { s, call, calls, nextId } = await session(() => ({ wait: new Promise(() => {}) }));
const id = nextId();
const pending = call('validate', { project: DEMO });
await until(() => calls.length > 0);
await s.handle({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id } });
expect(errOf(await pending).code).toBe(ERR.cancelled);
});
test('console.fill: desktop only (never MCP), answers as data in the dry run, browser only with a host token, cancel kills it', async () => {
expect(describeMethods().find(m => m.method === 'console.fill')).toMatchObject({ kind: 'write', json: true, network: true, destructive: false });
expect(DESKTOP_ONLY.some(t => t.name === 'console_fill')).toBe(true);
// MCP has only the read-only agent-first console tools (plan, verify); the scripted fill never
expect(TOOLS.filter(t => t.name.startsWith('console') || t.command[0] === 'console').map(t => [t.name, t.command.join(' '), t.write])).toEqual([['console_plan', 'console plan', false], ['console_verify', 'console verify', false]]);
expect('console.fill').not.toMatch(FORBIDDEN_METHOD_WORDS);
let now = 9_000_000;
const answers = { form: 'apple-age-rating', store: 'apple', title: 'Age rating', url: 'https://appstoreconnect.apple.com/', answers: [{ id: 'violence', label: 'Cartoon violence', value: 'None', certain: true, source: 'apple.ageRating' }], uncertain: 0, guidance: [] };
const { s, call, calls, nextId } = await session(argv => (argv.includes('--dry') ? { data: answers } : { wait: new Promise(() => {}) }), () => now);
const p = { project: DEMO, args: { form: 'apple-age-rating' } };
expect(resOf(await call('console.fill', p))).toMatchObject({ dryRun: true, data: { answers: [{ id: 'violence' }] } });
expect(calls.at(-1)!.argv).toEqual(['console', 'fill', 'apple-age-rating', '--dry', '--json']);
expect(calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBeUndefined();
await call('console.fill', { project: DEMO, args: { form: 'play-content-rating', store: 'play' } });
expect(calls.at(-1)!.argv).toEqual(['console', 'fill', 'play-content-rating', '--store', 'play', '--dry', '--json']);
for (const args of [{ form: 'apple-submit' }, { form: 'apple-age-rating', store: 'web' }, {}, { form: '--yes' }])
expect(errOf(await call('console.fill', { project: DEMO, args })).code).toBe(ERR.invalidParams);
expect(errOf(await call('console.fill', { ...p, dryRun: false })).code).toBe(ERR.confirmRequired);
const n = calls.length;
// real run: waits for the owner (no timeout), the token answers the command's own question, a cancel stops it
const id = nextId();
const pending = call('console.fill', { ...p, dryRun: false, confirmToken: mint(SECRET, 'console.fill', p, { now }) });
await until(() => calls.length > n);
expect(calls.length).toBe(n + 1);
expect(calls.at(-1)!.argv).toEqual(['console', 'fill', 'apple-age-rating', '--json']);
expect(calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBe('1');
now += 600_000;
await s.handle({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id } });
expect(errOf(await pending).code).toBe(ERR.cancelled);
});
test('portfolio.status: apps across platforms, release trains, catalog parity, per-app errors, progress n/total', async () => {
const summary = { dir: DEMO, name: 'Demo', apple: { bundleId: 'com.example.demo', appId: null }, play: { packageName: 'com.example.demo' }, platforms: ['ios', 'android'], products: 2, productIds: ['coins', 'no_ads'], subscriptions: 0, subscriptionIds: [], catalog: { ios: ['coins', 'no_ads'], android: ['coins', 'no_ads'] } };
const { call, sent } = await session(argv => argv[0] === 'inspect' ? { data: summary }
: argv[0] === 'check' ? { data: { ok: true, problems: [], warnings: ['w'] } }
: argv[0] === 'apple' ? { code: 1, out: 'error: ASC_KEY_ID and ASC_ISSUER_ID are not set' }
: argv[0] === 'play' ? { data: { tracks: [{ track: 'production', releases: [{ name: '1.2.0 (12)', versionCodes: ['12'], status: 'completed' }] }, { track: 'beta', releases: [{ name: '1.3.0', versionCodes: ['13'], status: 'completed' }] }], products: [{ productId: 'coins' }] } } : {});
const r = resOf(await call('portfolio.status', { projects: [DEMO, '/nope/game'], live: true })) as { apps: J[] };
expect(r.apps[0]).toMatchObject({ project: DEMO, app: { id: DEMO, name: 'Demo', platforms: { ios: { bundleId: 'com.example.demo' }, android: { packageName: 'com.example.demo' } } },
check: { ok: true, warnings: ['w'] }, storeErrors: { ios: 'ASC_KEY_ID and ASC_ISSUER_ID are not set' }, ok: false });
expect(r.apps[0]!.release).toMatchObject({ source: 'live', current: { version: '1.2.0', ios: null, android: { phase: 'live', build: '12' } }, next: { version: '1.3.0', android: { phase: 'testing', track: 'beta' } } });
expect(r.apps[0]!.catalog).toMatchObject({ checked: 'live', missingAndroid: ['no_ads'], missingIos: [], ok: false });
expect(r.apps[1]).toMatchObject({ ok: false, summary: null, app: null });
expect((r.apps[1]!.errors as string[])[0]).toContain('not a folder');
const prog = sent.filter(m => m.method === 'progress').map(m => (m.params as J).n);
expect(prog.sort()).toEqual([1, 2]);
expect(errOf(await call('portfolio.status', { projects: [] })).code).toBe(ERR.invalidParams);
});
test('children never inherit session secrets; only credential names pass', () => {
process.env.STOREBOUND_SESSION_SECRET = 'x';
try { expect(childEnv({}, true).STOREBOUND_SESSION_SECRET).toBeUndefined(); } finally { delete process.env.STOREBOUND_SESSION_SECRET; }
expect(childEnv({}, true)).toMatchObject({ STOREBOUND_MACHINE: '1', STOREBOUND_SERVE: '1', NO_COLOR: '1' });
expect(pickCallEnv({ PATH: '/x', ASC_ISSUER_ID: 'i', ASC_P8: 3 })).toEqual({ ASC_ISSUER_ID: 'i' });
});
});
/** Talks to a real `storebound serve --stdio` process (source or compiled). */
async function stdioRoundTrip(cmd: string[]): Promise<J[]> {
const p = Bun.spawn([...cmd, 'serve', '--stdio'], { cwd: ROOT, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
const lines = [
{ jsonrpc: '2.0', id: 1, method: 'system.version' },
{ jsonrpc: '2.0', id: 2, method: 'session.hello', params: { secret: SECRET_HEX } },
{ jsonrpc: '2.0', id: 3, method: 'project.inspect', params: { project: DEMO } },
{ jsonrpc: '2.0', id: 4, method: 'shots.check', params: { project: DEMO } },
{ jsonrpc: '2.0', id: 5, method: 'play.listing', params: { project: DEMO, dryRun: false, confirmToken: 'sbt1.1.AAAAAAAAAAAAAAAAAAAAAA.' + 'A'.repeat(43) } },
];
// hello must be answered before the next request is read: send the version probe, then the rest
p.stdin.write(lines.map(l => JSON.stringify(l)).join('\n') + '\n');
await p.stdin.flush();
const out: J[] = [];
const reader = p.stdout.getReader();
const dec = new TextDecoder();
let buf = '';
const deadline = Date.now() + 25_000;
while (out.filter(m => m.id !== undefined).length < lines.length && Date.now() < deadline) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf('\n')) >= 0) { out.push(JSON.parse(buf.slice(0, nl)) as J); buf = buf.slice(nl + 1); }
}
p.stdin.end();
await p.exited;
return out;
}
function checkRoundTrip(out: J[]): void {
const byId = (id: number) => out.find(m => m.id === id)!;
expect(errOf(byId(1)).code).toBe(ERR.notInitialized);
expect(resOf(byId(2)).protocol).toBe('storebound-desktop/1');
expect(resOf(byId(3))).toMatchObject({ name: 'Prism Puddle', play: { packageName: 'com.example.prismpuddle' } });
const shots = resOf(byId(4));
expect(shots.command).toBe('storebound shots check --json');
expect((shots.data as J).issues).toBeArray();
expect(errOf(byId(5)).code).toBe(ERR.confirmInvalid);
expect(out.some(m => m.method === 'progress')).toBe(true);
}
describe('serve --stdio over a real pipe', () => {
test('from source', async () => { checkRoundTrip(await stdioRoundTrip([process.execPath, join(ROOT, 'cli/index.ts')])); }, 30_000);
test('compiled sidecar for this machine: version + handshake, no private paths inside', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-sidecar-'));
try {
const bin = await buildOne(hostTarget(), dir);
expect(existsSync(bin)).toBe(true);
const v = Bun.spawnSync([bin, 'version'], { cwd: ROOT, stdout: 'pipe' });
expect(v.exitCode).toBe(0);
expect(v.stdout.toString().trim()).toMatch(/^\d+\.\d+\.\d+/);
expect(leaks(bin)).toEqual([]);
checkRoundTrip(await stdioRoundTrip([bin]));
} finally { rmSync(dir, { recursive: true, force: true }); }
}, 60_000);
});