storebound/tests/json-stdout.test.ts

100 lines
5.3 KiB
TypeScript

// `--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<string, string[]> = {
'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]');
});
});