100 lines
5.3 KiB
TypeScript
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]');
|
|
});
|
|
});
|