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, toArgv, validateInput } from '../cli/mcp-tools'; import { describeMethods, DESKTOP_ONLY, DESKTOP_TOOLS, FORBIDDEN_METHOD_WORDS, JSON_TOOLS, methodName, OWNER_SUBMIT, OWNER_TOOLS, Session } from '../cli/serve'; import { buildOne, hostTarget, leaks } from '../tools/build-sidecars'; type J = Record; 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 } = () => ({})) { 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(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[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('only owner actions submit or release; nothing publishes; MCP has none of them', () => { const names = describeMethods().map(m => m.method); // the owner actions (rollout share, phased release, sending one approved reply, submit / resubmit, the Play // production release: tests/desktop-d3.test.ts) are the only methods with those words, all under "owner." 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); // submitting: exactly these owner actions, nothing else const submitting = [...OWNER_SUBMIT].map(methodName).sort(); expect(submitting).toEqual(['owner.apple_iap_submit', 'owner.apple_resubmit', 'owner.apple_submit', 'owner.play_production']); for (const n of names.filter(x => !submitting.includes(x))) expect(n).not.toMatch(/submit|production/i); for (const n of names) expect(n).not.toMatch(/publish|cancel|delete|remove|invite|manual/i); for (const n of ['apple.submit', 'apple.resubmit', 'apple.iap_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); // the MCP server (AI tools) never submits, resubmits or sends a production release for (const t of TOOLS) { // only the read-only IAP review plan runs iap-submit, and always with --dry if (t.command.some(c => /submit/.test(c))) { expect([t.name, t.write]).toEqual(['apple_iap_review_plan', false]); expect(toArgv(t, { dry_run: false }).argv).toContain('--dry'); } for (const s of [{}, { track: 'production' }, { dry_run: false }]) if (!validateInput(t, s).length) expect(toArgv(t, s).argv).not.toContain('--production'); } expect(TOOLS.some(t => OWNER_SUBMIT.has(t.name) || t.name.startsWith('owner_'))).toBe(false); // 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' }], publicPage: { state: 'public' } } } : {}); 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 { 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); });