Root cause: the per-character font probes were inline spans side by side, so Chrome shaped f + i into the fi ligature across them and the lone i probe reported no glyph of ours. Each probe is now its own block. The Google Fonts text= subsets keep the GSUB ligatures; regression test with a real Montserrat text= subset. --help / -h prints the command's usage and exits 0 before any config, render or network; unknown flags exit 2 with the usage (cli/data/flags.json from tools/gen-flags.ts). Command tables moved to cli/commands.ts; ads apple reachable again; shots render --scenes rejects unknown ids at once.
111 lines
5.9 KiB
TypeScript
111 lines
5.9 KiB
TypeScript
// `--help` / `-h` on every command and command group prints the usage and exits 0 before anything runs (no config
|
|
// load, no render, no browser, no store call); an unknown --flag exits 2 with the usage, also before anything runs.
|
|
// Each command is spawned for real with an empty working folder, a temp HOME, no credentials and
|
|
// STOREBOUND_NO_NETWORK=1 (cli/index.ts makes fetch throw), and the folders must stay empty.
|
|
// Also: cli/data/flags.json is fresh (tools/gen-flags.ts --check), and every option the MCP / desktop tool lists pass
|
|
// to a command is one that command accepts (else the strict flag check would break those tools).
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { COMMANDS, PLAIN } from '../cli/commands';
|
|
import FLAGS from '../cli/data/flags.json';
|
|
import { TOOLS } from '../cli/mcp-tools';
|
|
import { DESKTOP_ONLY, OWNER_TOOLS } from '../cli/serve';
|
|
|
|
const ROOT = join(import.meta.dir, '..');
|
|
const CLI = join(ROOT, 'cli', 'index.ts');
|
|
const tmp: string[] = [];
|
|
const mk = (p: string): string => { const d = mkdtempSync(join(tmpdir(), p)); tmp.push(d); return d; };
|
|
afterAll(() => { for (const d of tmp) rmSync(d, { recursive: true, force: true }); });
|
|
|
|
const env = (home: string): Record<string, string> => {
|
|
const e: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !/^(ASC_|PLAY_|ANDROID_KEY|REVIEW_DEMO|STOREBOUND_|APPLE_ADS|REVENUECAT|GOOGLE_)/.test(k)) e[k] = v;
|
|
// no transpiler cache: Bun would write it under the temp HOME (~/Library/Caches/bun)
|
|
return { ...e, HOME: home, STOREBOUND_NO_NETWORK: '1', NO_COLOR: '1', BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' };
|
|
};
|
|
|
|
async function run(args: string[]): Promise<{ code: number; out: string; err: string; ms: number; cwd: string[]; home: string[] }> {
|
|
const cwd = mk('sb-help-cwd-'), home = mk('sb-help-home-');
|
|
const t = Date.now();
|
|
const p = Bun.spawn([process.execPath, CLI, ...args], { cwd, env: env(home), stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
|
const kill = setTimeout(() => p.kill(), 15_000);
|
|
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
|
|
clearTimeout(kill);
|
|
return { code, out, err, ms: Date.now() - t, cwd: readdirSync(cwd), home: readdirSync(home) };
|
|
}
|
|
|
|
const keys = [...Object.keys(PLAIN), ...Object.keys(COMMANDS)];
|
|
const groups = [...new Set(keys.filter(k => k.includes(' ')).map(k => k.split(' ')[0]!))];
|
|
|
|
describe('cli --help', () => {
|
|
test('cli/data/flags.json is fresh', () => {
|
|
const r = Bun.spawnSync([process.execPath, join(ROOT, 'tools', 'gen-flags.ts'), '--check'], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
|
expect(r.stderr.toString()).toBe('');
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
test('every command and group: usage on stdout, exit 0, fast, nothing written, no network', async () => {
|
|
const cases = [...keys.map(k => [...k.split(' '), '--help']), ...groups.map(g => [g, '--help']), ['shots', 'render', '-h'], ['shots', 'render', '--lang', 'en', '--help'], ['help'], ['--help']];
|
|
const bad: string[] = [];
|
|
for (let i = 0; i < cases.length; i += 8) {
|
|
const rs = await Promise.all(cases.slice(i, i + 8).map(async c => ({ c, r: await run(c) })));
|
|
for (const { c, r } of rs) {
|
|
const why = [
|
|
r.code !== 0 && `exit ${r.code}`,
|
|
!/^(usage: |storebound )/.test(r.out) && `stdout ${JSON.stringify(r.out.slice(0, 80))}`,
|
|
r.err.trim() && `stderr ${JSON.stringify(r.err.slice(0, 200))}`,
|
|
r.ms > 5000 && `${r.ms} ms`,
|
|
r.cwd.length && `wrote ${r.cwd.join(', ')}`,
|
|
r.home.length && `wrote ~/${r.home.join(', ~/')}`,
|
|
].filter(Boolean);
|
|
if (why.length) bad.push(`storebound ${c.join(' ')}: ${why.join('; ')}`);
|
|
}
|
|
}
|
|
expect(bad).toEqual([]);
|
|
}, 120_000);
|
|
|
|
test('a command --help names its options; a group lists its commands', async () => {
|
|
const r = await run(['shots', 'render', '--help']);
|
|
expect(r.out).toContain('usage: storebound shots render');
|
|
expect(r.out).toContain('--scenes');
|
|
const g = await run(['shots', '--help']);
|
|
for (const k of keys.filter(k => k.startsWith('shots '))) expect(g.out).toContain(k);
|
|
}, 30_000);
|
|
|
|
test('an unknown flag: exit 2 with the usage, before anything runs', async () => {
|
|
for (const c of [['shots', 'render', '--dyr'], ['doctor', '--jsno'], ['play', 'status', '--x=1']]) {
|
|
const r = await run(c);
|
|
expect(r.code).toBe(2);
|
|
expect(r.err).toContain(`unknown option for storebound ${c.slice(0, -1).join(' ')}: ${c.at(-1)!.split('=')[0]}`);
|
|
expect(r.err).toContain('usage: storebound');
|
|
expect(r.cwd).toEqual([]);
|
|
expect(r.home).toEqual([]);
|
|
}
|
|
const g = await run(['shots']);
|
|
expect(g.code).toBe(2);
|
|
expect(g.err).toContain('storebound shots: which command?');
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('flag table vs the tool lists', () => {
|
|
test('every option an MCP or desktop tool passes is one its command accepts', () => {
|
|
const table = FLAGS.commands as Record<string, string[]>;
|
|
const bad: string[] = [];
|
|
for (const t of [...TOOLS, ...DESKTOP_ONLY, ...OWNER_TOOLS]) {
|
|
const words = t.command.filter(w => !w.startsWith('--'));
|
|
const key = table[words.slice(0, 2).join(' ')] ? words.slice(0, 2).join(' ') : words[0]!;
|
|
const ok = new Set([...FLAGS.global, ...(table[key] ?? [])]);
|
|
if (!table[key]) { bad.push(`${t.name}: no command ${key}`); continue; }
|
|
const src = t.args?.toString() ?? '';
|
|
const used = [
|
|
...t.command.filter(w => w.startsWith('--')).map(w => w.slice(2)),
|
|
...[...src.matchAll(/'--([a-z][a-z0-9-]*)'/g)].map(m => m[1]!),
|
|
...[...src.matchAll(/\b(?:opt|flag)\('([a-z][a-z0-9-]*)'/g)].map(m => m[1]!),
|
|
];
|
|
for (const f of used) if (!ok.has(f)) bad.push(`${t.name}: storebound ${key} --${f}`);
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
});
|