// `--json` runs write only the JSON to stdout; every log, step, table and warning line goes to stderr (lib/log.ts // withJsonOutput, wrapped around every command by index.ts). STAR DASH, 2026-10-14: `prices ppp --json` printed log // lines on stdout before the JSON, so `JSON.parse(stdout)` failed. This runs every command whose module reads --json // against the fake stores, in a temp project and HOME, and requires stdout to be empty (the command failed or wrote // nothing) or exactly one JSON document. import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test'; import * as rt from '../cli/lib/rt'; import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import type { ShipConfig } from '../cli/lib/config'; import { parseArgs } from '../cli/lib/args'; import { withJsonOutput } from '../cli/lib/log'; import { COMMANDS, PLAIN } from '../cli/commands'; import { ascEnv, BASE, capture, playEnv, project, restore } from './helpers/store-mock'; import { installStores, uninstallStores } from './helpers/fake-stores'; const CLI = resolve(import.meta.dir, '../cli'); /** Never run here: long-lived servers, and the ones that install or open things on the machine. */ const SKIP = new Set(['mcp', 'serve', 'install', 'desktop', 'init', 'flow', 'ws', 'watch']); // watch: a loop that prints JSON lines (tests/watch.test.ts) /** Commands that need a subcommand (or arguments) to reach their --json output. */ const ARGV: Record = { 'prices': ['ppp', '--offline'], 'api': ['list'], 'accounts': ['list'], 'release': ['status'], 'apps': ['folders'], 'ads': ['scan'], 'reviews': ['list'], 'health': ['status'], }; /** Ones that must print JSON here (the report's command and a few plain readers). */ const MUST = ['prices', 'inspect', 'doctor', 'api']; /** command key -> module source (the `import('./x')` of cli/commands.ts), for the commands that read --json. */ function jsonCommands(): string[] { const src = readFileSync(join(CLI, 'commands.ts'), 'utf8'); const out: string[] = []; for (const m of src.matchAll(/^ {2}'([a-z][a-z0-9 -]*)': \[.*?import\('(\.\/[^']+)'\)/gm)) { const key = m[1]!; if (SKIP.has(key)) continue; let file = join(CLI, m[2]!); try { file = readFileSync(file + '.ts', 'utf8'); } catch { try { file = readFileSync(join(file, 'index.ts'), 'utf8'); } catch { continue; } } if (/has\('json'\)|get\('json'\)|'json'\)/.test(file)) out.push(key); } return out; } const CFG: ShipConfig = { ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], products: [{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 4.99, listings: { en: { title: 'Pro', description: 'Every feature.' } } }] }; let home = '', cwd = ''; const saved = { home: process.env.HOME, cwd: process.cwd() }; // retries and store-lag waits must not sleep here: the doubles answer at once let sleep: { mockRestore: () => void } | undefined; beforeAll(() => { sleep = spyOn(rt, 'sleep').mockImplementation(async () => {}); ascEnv(); playEnv(); home = mkdtempSync(join(tmpdir(), 'sb-json-home-')); process.env.HOME = home; }); afterAll(() => { sleep?.mockRestore(); restore(); process.env.HOME = saved.home; process.chdir(saved.cwd); }); describe('--json: stdout is only the JSON', () => { const keys = jsonCommands(); test('finds the commands that read --json', () => { expect(keys.length).toBeGreaterThan(20); for (const k of MUST) expect(keys).toContain(k); }); const printed: string[] = []; for (const key of keys) { test(`storebound ${key} --json`, async () => { const p = project(CFG); cwd = p.root; process.chdir(cwd); installStores(); const argv = [...(ARGV[key] ?? []), '--json']; const args = parseArgs(argv); const r = await capture(() => withJsonOutput(async () => { if (PLAIN[key]) await (await PLAIN[key]![0]()).run(args); else await (await COMMANDS[key]![0]()).run({ loaded: p.loaded, args }); })).catch(e => ({ out: '', err: String(e), exit: 1 })); uninstallStores(); process.chdir(saved.cwd); const out = r.out.trim(); if (!out) return; let ok = true; try { JSON.parse(out); } catch { ok = false; } if (!ok) throw new Error(`storebound ${key} ${argv.join(' ')}: stdout is not one JSON document:\n${out.slice(0, 600)}`); printed.push(key); }, 20_000); } test('the report\'s commands printed JSON', () => { for (const k of MUST) expect(printed).toContain(k); }); }); describe('--json through the real CLI (index.ts wraps every command)', () => { test('prices ppp --offline --json --dry: stdout parses, the log lines are on stderr', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-json-cli-')); writeFileSync(join(dir, 'storebound.config.ts'), `export default ${JSON.stringify(CFG)};\n`); const r = Bun.spawnSync([process.execPath, join(CLI, 'index.ts'), 'prices', 'ppp', '--offline', '--dry', '--json'], { cwd: dir, env: { PATH: process.env.PATH ?? '', HOME: dir, STOREBOUND_NO_NETWORK: '1' }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }); const out = r.stdout.toString(), err = r.stderr.toString(); expect(r.exitCode).toBe(0); const j = JSON.parse(out) as { rows: unknown[] }; expect(j.rows.length).toBeGreaterThan(50); expect(err).toContain('[ppp]'); }); });