--json prints only JSON on stdout (withJsonOutput around every command: logs, tables and prompts to stderr; printJson everywhere; test runs every --json command against the fake stores and parses stdout); prices ppp compares like with like: par = the store's own US-equivalent price (Play convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT) rounded up) scaled by the PPP price level; defaults only lower, only price levels <= 0.5, World Bank high-income at par, CN at par on Apple and skipped on Play, never above par (--only-decreases, --max-ratio, --min-ratio, --max-level, --include-high-income; --floor / --ceiling kept), top-10 revenue market drops > 20 % warned (sales reports, else a static list), unchanged regions out of the sheet; price changes of existing products only with --allow-price-changes, TTY or not (a terminal yes covered them), then their own question listing each old -> new, held section in dry runs (play products, prices import, play / apple subscriptions); pitfalls 61-63; tests
This commit is contained in:
parent
bab3f63227
commit
357319a775
30 changed files with 865 additions and 209 deletions
100
tests/json-stdout.test.ts
Normal file
100
tests/json-stdout.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// `--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']);
|
||||
/** 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]');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue