561 lines
40 KiB
TypeScript
561 lines
40 KiB
TypeScript
#!/usr/bin/env bun
|
|
// Release acceptance matrix: every user-facing command, run the way a user runs it, on this platform and one runtime.
|
|
// bun tools/acceptance.ts run --runtime node|bun [--node <bin>] [--tgz <storebound-x.y.z.tgz>] [--quick]
|
|
// [--platform <label>] [--out <cells.json>] [--only <regex>] [--jobs n] [--budget <min>]
|
|
// bun tools/acceptance.ts report <cells.json>... [--md docs/acceptance.md] merge runs into the markdown matrix
|
|
// node: the packed npm tarball through `npx --package=<tgz> storebound` with that node first on PATH and a fresh npm
|
|
// cache per cell (parallel npx runs on one cache race: ENOTEMPTY); bun: the tarball installed with `bun add` into a temp folder and run with `bun x --bun storebound` from
|
|
// folders below it. No --tgz: `npm pack` of this checkout first.
|
|
// Every cell starts in a fresh temp folder (a fixture copy or an empty one) with a fresh HOME (no real accounts, no
|
|
// real app folders), real piped stdin ("n\n", "y\n", or closed: EOF) and the store hosts answered by the stateful
|
|
// fake stores of tests/helpers/fake-stores.ts (STOREBOUND_STORE_MOCK, loopback only; one fresh store per cell, so a
|
|
// prompt answered n is checked to have written nothing). STOREBOUND_ASSUME_TTY=1 makes the prompts read that piped
|
|
// stdin like a terminal (cli/lib/tty.ts). A cell passes only with the expected exit code and no crash text on stdout
|
|
// or stderr (CRASH below). Each cell has a timeout (the process tree is killed), the run has a budget, and a
|
|
// heartbeat lists the running cells every 20 s.
|
|
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
import { generateKeyPairSync } from 'node:crypto';
|
|
import { cpus, homedir, platform as osPlatform, tmpdir } from 'node:os';
|
|
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
import { FakeAsc, FakePlay } from '../tests/helpers/fake-stores';
|
|
import { makeAab } from '../tests/build-fixtures';
|
|
import { ALPHA3 } from '../cli/lib/money';
|
|
import { CURRENCY } from '../cli/lib/ppp';
|
|
|
|
const ROOT = resolve(import.meta.dir, '..');
|
|
const PKG = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { version: string };
|
|
const WIN = process.platform === 'win32';
|
|
const APP_ID = '6400000101';
|
|
/** --keep: keep every cell's folder and output.txt (not only the failed ones) */
|
|
const KEEP = process.argv.includes('--keep');
|
|
|
|
/** Text that means the CLI crashed instead of reporting a problem. */
|
|
export const CRASH = /Assertion failed|TypeError|undefined is not an object|null is not an object|ReferenceError|SyntaxError|RangeError|unhandled|panic|is not a function|is not async iterable|is not iterable|Cannot read properties|Cannot find module|ERR_MODULE_NOT_FOUND|ERR_UNKNOWN_FILE_EXTENSION|Segmentation fault|out of memory/i;
|
|
|
|
type Project = 'empty' | 'app' | 'demo' | `fixture:${string}`;
|
|
export interface Cell {
|
|
id: string;
|
|
group: string;
|
|
args: string[];
|
|
project?: Project;
|
|
/** piped stdin; undefined = closed at once (EOF) */
|
|
stdin?: string;
|
|
exit: number | number[];
|
|
env?: Record<string, string>;
|
|
quick?: boolean;
|
|
timeoutMs?: number;
|
|
/** the fake stores must see no write during this cell */
|
|
noWrites?: boolean;
|
|
/** stdout + stderr must match */
|
|
expect?: RegExp;
|
|
/** extra check after the run (cell folder, output); returns an error text */
|
|
after?: (dir: string, out: string) => string | void;
|
|
/** per-cell HOME (the desktop download writes to ~/Downloads) */
|
|
ownHome?: boolean;
|
|
}
|
|
export interface Result { id: string; group: string; cmd: string; platform: string; runtime: string; ok: boolean; exit: number | null; want: string; ms: number; error?: string; note?: string }
|
|
|
|
// ------------------------------------------------------------------------------------------------- the cells
|
|
|
|
const SCAN_FIXTURES = ['capacitor', 'flutter', 'react-native', 'expo', 'unity', 'ios-native', 'android'];
|
|
const NO = 'n\n';
|
|
const TTY = { STOREBOUND_ASSUME_TTY: '1' };
|
|
|
|
export function cells(forms: string[]): Cell[] {
|
|
const c: Cell[] = [
|
|
{ id: '--version', group: 'basics', args: ['--version'], exit: 0, quick: true, expect: new RegExp(`^${PKG.version.replace(/\./g, '\\.')}\\s*$`) },
|
|
{ id: 'help', group: 'basics', args: ['help'], exit: 0, quick: true, expect: /usage: storebound <command>/ },
|
|
{ id: 'unknown command', group: 'basics', args: ['nope'], exit: 2, expect: /unknown command: nope/ },
|
|
...['app', 'game', 'subscription-app'].map((p): Cell => ({
|
|
id: `init --profile ${p}`, group: 'basics', args: ['init', '--profile', p], project: 'empty', exit: 0, quick: p === 'app',
|
|
after: d => (existsSync(join(d, 'storebound.config.ts')) ? undefined : 'no storebound.config.ts written'),
|
|
})),
|
|
{ id: 'init --dry', group: 'basics', args: ['init', '--dry'], project: 'empty', exit: 0, after: d => (existsSync(join(d, 'storebound.config.ts')) ? 'init --dry wrote a config' : undefined) },
|
|
{ id: 'check (fresh init: template problems)', group: 'basics', args: ['check'], project: 'app', exit: 1, quick: true, expect: /config ok[\s\S]*error: \d+ problems?/ },
|
|
{ id: 'check (no config)', group: 'basics', args: ['check'], project: 'empty', exit: 1, expect: /no storebound\.config\.ts found/ },
|
|
...SCAN_FIXTURES.map((f): Cell => ({ id: `scan ${f}`, group: 'scan', args: ['scan', '--json'], project: `fixture:${f}`, exit: [0, 1], quick: f === 'capacitor', after: (_d, out) => jsonCheck(out, 'findings') })),
|
|
...SCAN_FIXTURES.map((f): Cell => ({ id: `scan ${f} (text)`, group: 'scan', args: ['scan'], project: `fixture:${f}`, exit: [0, 1] })),
|
|
{ id: 'inspect --json', group: 'local', args: ['inspect', '--json'], project: 'app', exit: 0, quick: true, after: (_d, out) => jsonCheck(out, 'name') },
|
|
{ id: 'doctor', group: 'local', args: ['doctor'], project: 'empty', exit: [0, 1] },
|
|
{ id: 'doctor --json', group: 'local', args: ['doctor', '--json'], project: 'empty', exit: [0, 1], after: (_d, out) => jsonCheck(out) },
|
|
{ id: 'accounts list', group: 'local', args: ['accounts', 'list'], project: 'empty', exit: 0, quick: true },
|
|
{ id: 'accounts list --json', group: 'local', args: ['accounts', 'list', '--json'], project: 'empty', exit: 0, after: (_d, out) => jsonCheck(out) },
|
|
{ id: 'apps folders', group: 'local', args: ['apps', 'folders'], project: 'empty', exit: 0, quick: true },
|
|
{ id: 'desktop --dry', group: 'desktop', args: ['desktop', '--dry'], project: 'empty', exit: 0, quick: true, expect: /dry run|nothing changed/ },
|
|
{ id: 'desktop --dry (no app installed)', group: 'desktop', args: ['desktop', '--dry'], project: 'empty', exit: 0, env: { STOREBOUND_DESKTOP_APP: 'none' }, expect: /dry run: install storebound desktop/ },
|
|
{ id: 'desktop --json', group: 'desktop', args: ['desktop', '--json'], project: 'empty', exit: 0, after: (_d, out) => jsonCheck(out) },
|
|
{ id: 'desktop [Y/n] answered n', group: 'desktop', args: ['desktop'], project: 'empty', stdin: NO, exit: 3, quick: true, env: { ...TTY, STOREBOUND_DESKTOP_APP: 'none' }, expect: /\[Y\/n\][\s\S]*declined: the owner answered no/ },
|
|
{ id: 'desktop [Y/n] stdin closed (EOF)', group: 'desktop', args: ['desktop'], project: 'empty', exit: 3, env: { ...TTY, STOREBOUND_DESKTOP_APP: 'none' }, expect: /declined/ },
|
|
{ id: 'desktop (not a terminal)', group: 'desktop', args: ['desktop'], project: 'empty', exit: 3, env: { STOREBOUND_DESKTOP_APP: 'none' }, expect: /not a terminal/ },
|
|
{
|
|
id: 'desktop --download (temp HOME, deleted after)', group: 'desktop', args: ['desktop', '--download'], project: 'empty', exit: 0, ownHome: true, timeoutMs: 600_000,
|
|
after: d => {
|
|
const dl = join(d, '..', 'home', 'Downloads');
|
|
const files = existsSync(dl) ? readdirSync(dl).filter(f => /^storebound/.test(f)) : [];
|
|
const big = files.find(f => statSync(join(dl, f)).size > 5e6);
|
|
rmSync(dl, { recursive: true, force: true });
|
|
return big ? undefined : `no installer in ~/Downloads (${files.join(', ') || 'empty'})`;
|
|
},
|
|
},
|
|
{ id: 'mcp initialize + tools/list + tools/call check', group: 'mcp', args: ['mcp'], project: 'app', stdin: mcpLines(), exit: 0, quick: true, after: (_d, out) => mcpCheck(out) },
|
|
{ id: 'console plan (list)', group: 'console', args: ['console', 'plan'], project: 'app', exit: 0 },
|
|
...forms.map((f): Cell => ({ id: `console plan ${f}`, group: 'console', args: ['console', 'plan', f, '--json'], project: 'app', exit: 0, quick: f === 'apple-app-privacy', after: (_d, out) => jsonCheck(out) })),
|
|
];
|
|
// writes with --dry against the fake stores: nothing may be written
|
|
const dry = (id: string, args: string[], exit: number | number[] = 0, extra: Partial<Cell> = {}): Cell => ({ id, group: 'store --dry', args, project: 'app', exit, noWrites: true, ...extra });
|
|
c.push(
|
|
dry('play listing --dry', ['play', 'listing', '--dry'], 0, { quick: true }),
|
|
dry('apple listing --dry', ['apple', 'listing', '--version', '1.0.0', '--dry'], 0, { quick: true }),
|
|
dry('play images --dry', ['play', 'images', '--dry'], [0, 1]),
|
|
dry('apple screenshots --dry', ['apple', 'screenshots', '--dry'], [0, 1]),
|
|
dry('apple previews --dry', ['apple', 'previews', '--dry'], [0, 1]),
|
|
dry('play products --dry', ['play', 'products', '--dry']),
|
|
dry('play subscriptions --dry', ['play', 'subscriptions', '--dry']),
|
|
dry('apple iap --dry', ['apple', 'iap', '--dry']),
|
|
dry('apple subscriptions --dry', ['apple', 'subscriptions', '--dry']),
|
|
dry('apple iap-review --dry', ['apple', 'iap-review', '--dry'], [0, 1]),
|
|
dry('apple iap-submit --dry', ['apple', 'iap-submit', '--dry'], [0, 1]),
|
|
dry('prices export --dry', ['prices', 'export', '--dry']),
|
|
dry('prices import --dry', ['prices', 'import', '--csv', 'store/prices.csv', '--dry'], [0, 1]),
|
|
dry('prices ppp --dry', ['prices', 'ppp', '--base', '4.99', '--dry'], [0, 1]),
|
|
dry('play data-safety --dry', ['play', 'data-safety', '--dry'], 0, { quick: true }),
|
|
dry('apple testflight status', ['apple', 'testflight', 'status']),
|
|
dry('apple testflight distribute --dry', ['apple', 'testflight', 'distribute', '--build', '1', '--group', 'Beta', '--dry'], [0, 1]),
|
|
dry('release plan 1.0.0', ['release', 'plan', '1.0.0'], [0, 1], { quick: true }),
|
|
dry('release status', ['release', 'status'], [0, 1]),
|
|
dry('apple submit --dry', ['apple', 'submit', '--build', '1', '--dry'], [0, 1]),
|
|
dry('apple resubmit --dry', ['apple', 'resubmit', '--build', '1', '--dry'], [0, 1]),
|
|
dry('play release --dry', ['play', 'release', 'app-release.aab', '--internal', 'completed', '--dry'], 0, { quick: true }),
|
|
dry('play upload --dry', ['play', 'upload', 'app-release.aab', '--dry']),
|
|
dry('play share --dry', ['play', 'share', '--aab', 'app-release.aab', '--dry']),
|
|
dry('play rollout --dry', ['play', 'rollout', '--fraction', '0.2', '--dry'], [0, 1]),
|
|
dry('art pull --dry', ['art', 'pull', '--dry'], 0, { quick: true }),
|
|
dry('apple appinfo --dry', ['apple', 'appinfo', '--dry']),
|
|
dry('apple review-details --dry', ['apple', 'review-details', '--dry'], [0, 1]),
|
|
dry('apple bundle --dry', ['apple', 'bundle', '--dry']),
|
|
dry('apple encryption --dry', ['apple', 'encryption', '--dry'], [0, 1]),
|
|
dry('apple offer-codes --dry (none configured)', ['apple', 'offer-codes', '--dry'], 1, { expect: /error: no products with offerCodes/ }),
|
|
dry('apple promoted --dry', ['apple', 'promoted', '--dry']),
|
|
dry('apple win-back --dry (none configured)', ['apple', 'win-back', '--dry'], 1, { expect: /error: no subscriptions with winBack offers/ }),
|
|
dry('apple cpp create --dry', ['apple', 'cpp', 'create', '--dry']),
|
|
dry('apple events create --dry', ['apple', 'events', 'create', '--dry']),
|
|
dry('apple ppo create --dry', ['apple', 'ppo', 'create', '--variant', 'b', '--dry'], [0, 1]),
|
|
dry('apple preorder enable --dry', ['apple', 'preorder', 'enable', '--release-date', '2027-01-15', '--dry'], [0, 1]),
|
|
dry('apple release --phased on --dry', ['apple', 'release', '--phased', 'on', '--dry'], [0, 1]),
|
|
dry('apple signing ensure --dry', ['apple', 'signing', 'ensure', '--dry'], [0, 1]),
|
|
dry('analytics setup --dry', ['analytics', 'setup', '--dry']),
|
|
dry('onboard --dry', ['onboard', '--dry', '--no-web'], [0, 1]),
|
|
dry('catalog diff', ['catalog', 'diff'], [0, 1]),
|
|
dry('validate', ['validate'], [0, 1], { quick: true }),
|
|
dry('play status', ['play', 'status']),
|
|
dry('apple status', ['apple', 'status']),
|
|
dry('apps list', ['apps', 'list'], 0),
|
|
dry('apps check', ['apps', 'check'], [0, 1]),
|
|
dry('icons --dry', ['icons', '--dry'], [0, 1]),
|
|
dry('web init --dry', ['web', 'init', '--dry']),
|
|
dry('whats-new --dry', ['whats-new', '--dry'], [0, 1]),
|
|
dry('listing translate --dry', ['listing', 'translate', '--to', 'tr', '--dry'], [0, 1]),
|
|
dry('install --agent claude --dry', ['install', '--agent', 'claude', '--dry']),
|
|
dry('edit --dry', ['edit', '--ops', JSON.stringify([{ op: 'set', path: 'app.name', value: 'Example Two' }]), '--dry'], [0, 1]),
|
|
dry('flow run release --dry', ['flow', 'run', 'release', '--dry'], [0, 1]),
|
|
dry('health --weekly --dry', ['health', '--weekly', '--dry'], [0, 1]),
|
|
dry('triage --dry', ['triage', '--dry'], [0, 1]),
|
|
);
|
|
// confirm prompts answered n (and EOF): exit 3, nothing written
|
|
const no = (id: string, args: string[], stdin: string | undefined = NO, extra: Partial<Cell> = {}): Cell => ({ id, group: 'confirm n', args, project: 'app', stdin, exit: 3, env: TTY, noWrites: true, expect: /declined/, ...extra });
|
|
c.push(
|
|
no('play listing [y/N] n', ['play', 'listing'], NO, { quick: true }),
|
|
no('play listing [y/N] EOF', ['play', 'listing'], undefined),
|
|
// without a terminal and --yes, play listing applies (a listing edit is no owner step): the agent path
|
|
no('play listing (not a terminal: applies)', ['play', 'listing'], undefined, { env: {}, exit: 0, expect: /committed|updated|change/i, noWrites: false }),
|
|
no('apple listing [y/N] n', ['apple', 'listing', '--version', '1.0.0']),
|
|
no('apple appinfo [y/N] n', ['apple', 'appinfo']),
|
|
no('apple subscriptions [y/N] n', ['apple', 'subscriptions']),
|
|
// creating a product is no owner step (only price changes of live products ask): it runs without a question
|
|
no('play products (creates without a question)', ['play', 'products'], NO, { exit: 0, expect: /create/, noWrites: false }),
|
|
no('play subscriptions [y/N] n', ['play', 'subscriptions']),
|
|
no('play release --production completed [y/N] n', ['play', 'release', 'app-release.aab', '--production', 'completed'], NO, { quick: true }),
|
|
no('play rollout [y/N] n', ['play', 'rollout', '--fraction', '0.2'], NO, { exit: [1, 3], expect: undefined }),
|
|
// before the release, the phased release is only a setting of the version: no question
|
|
no('apple release --phased on (version not released yet)', ['apple', 'release', '--phased', 'on'], NO, { exit: 0, expect: /phased release on/, noWrites: false }),
|
|
no('api apple POST without a terminal (dry)', ['api', 'apple', 'bundleIds_createInstance', '--body', 'store/bundle.json', '--yes'], 'wrong\n', { exit: 0, expect: /nothing sent/ }),
|
|
);
|
|
return c;
|
|
}
|
|
|
|
function jsonCheck(out: string, key?: string): string | void {
|
|
const start = out.search(/^[{[]/m);
|
|
if (start < 0) return 'no JSON on stdout';
|
|
try {
|
|
const j = JSON.parse(out.slice(start)) as Record<string, unknown>;
|
|
if (key && !(key in j)) return `JSON has no "${key}"`;
|
|
} catch (e) { return `stdout is not JSON: ${(e as Error).message.slice(0, 120)}`; }
|
|
}
|
|
|
|
function mcpLines(): string {
|
|
return [
|
|
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'acceptance', version: '1' } } },
|
|
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
|
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'check', arguments: {} } },
|
|
].map(m => JSON.stringify(m)).join('\n') + '\n';
|
|
}
|
|
|
|
function mcpCheck(out: string): string | void {
|
|
const msgs = new Map<number, { result?: Record<string, unknown>; error?: unknown }>();
|
|
for (const l of out.split('\n')) { try { const m = JSON.parse(l) as { id?: number }; if (typeof m.id === 'number') msgs.set(m.id, m as never); } catch { /* log line */ } }
|
|
const init = msgs.get(1)?.result as { serverInfo?: { name?: string; version?: string } } | undefined;
|
|
if (init?.serverInfo?.version !== PKG.version) return `initialize: ${JSON.stringify(msgs.get(1) ?? null).slice(0, 200)}`;
|
|
const tools = (msgs.get(2)?.result as { tools?: unknown[] } | undefined)?.tools ?? [];
|
|
if (tools.length < 10) return `tools/list: ${tools.length} tools`;
|
|
const call = msgs.get(3)?.result as { isError?: boolean; content?: { text?: string }[] } | undefined;
|
|
if (!call || !/config ok/.test(call.content?.[0]?.text ?? '')) return `tools/call check: ${JSON.stringify(msgs.get(3) ?? null).slice(0, 300)}`;
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------------------- fake stores over HTTP
|
|
|
|
interface CellStore { asc: FakeAsc; play: FakePlay; writes: string[]; unknown: string[] }
|
|
const stores = new Map<string, CellStore>();
|
|
|
|
function storeFor(id: string): CellStore {
|
|
let s = stores.get(id);
|
|
if (!s) {
|
|
const asc = new FakeAsc(APP_ID);
|
|
asc.res.get(APP_ID)!.attributes = { bundleId: 'com.example.app', name: 'Example App', primaryLocale: 'en-US', sku: 'EXAMPLE' };
|
|
// a new app record as App Store Connect makes it: one app info, version 1.0.0 being prepared
|
|
asc.put({ type: 'appInfos', id: 'info-1', attributes: { state: 'PREPARE_FOR_SUBMISSION', appStoreState: 'PREPARE_FOR_SUBMISSION' }, rels: { app: APP_ID } });
|
|
asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', platform: 'IOS', appVersionState: 'PREPARE_FOR_SUBMISSION', appStoreState: 'PREPARE_FOR_SUBMISSION', createdDate: '2026-10-01T00:00:00Z' }, rels: { app: APP_ID } });
|
|
asc.put({ type: 'appPricePoints', id: 'pp-usa-0', attributes: { customerPrice: '0.0', proceeds: '0.0' }, rels: { app: APP_ID, territory: 'USA' } });
|
|
s = { asc, play: new FakePlay(), writes: [], unknown: [] };
|
|
stores.set(id, s);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/** A store write: anything but a read, except Play's edit bookkeeping and writes inside an edit (they only count when
|
|
* the edit is committed: a dry run validates its edit and deletes it) and the price conversion. */
|
|
const isWrite = (method: string, path: string): boolean => {
|
|
const p = path.split('?')[0]!;
|
|
if (method === 'GET' || method === 'HEAD' || /pricing:convertRegionPrices$|\/token$|:query$|:search$/.test(p)) return false;
|
|
if (/\/edits\/[^/]+:commit$/.test(p)) return true;
|
|
return !/\/edits(\/|$)/.test(p);
|
|
};
|
|
|
|
/** Play's price conversion for every region Play sells in (the App Store's storefronts without mainland China), each in
|
|
* its currency at the USD amount: the values are not checked, the region list is. */
|
|
function convertAll(body: unknown): Record<string, unknown> {
|
|
const p = (body as { price?: { units?: string; nanos?: number } } | undefined)?.price ?? {};
|
|
const price = (c: string) => ({ currencyCode: c, units: p.units ?? '0', nanos: p.nanos ?? 0 });
|
|
const regions = Object.keys(ALPHA3).filter(r => r !== 'CN');
|
|
return { convertedRegionPrices: Object.fromEntries(regions.map(r => [r, { regionCode: r, price: price(CURRENCY[r] ?? 'USD') }])), convertedOtherRegionsPrice: { usdPrice: price('USD'), eurPrice: price('EUR') }, regionVersion: { version: '2022/02' } };
|
|
}
|
|
|
|
function startMock(): { base: string; stop: () => void } {
|
|
const server = Bun.serve({
|
|
hostname: '127.0.0.1', port: 0,
|
|
async fetch(req) {
|
|
const u = new URL(req.url);
|
|
const m = /^\/c\/([^/]+)\/__host\/([^/]+)(\/.*)?$/.exec(u.pathname);
|
|
if (!m) return new Response('acceptance mock: bad path', { status: 400 });
|
|
const s = storeFor(m[1]!);
|
|
const host = m[2]!, path = (m[3] ?? '/') + u.search;
|
|
const method = req.method.toUpperCase();
|
|
const ct = req.headers.get('content-type') ?? '';
|
|
const raw = new Uint8Array(await req.arrayBuffer());
|
|
let body: unknown = raw.length ? raw : undefined;
|
|
if (raw.length && /json|text|x-www-form-urlencoded/.test(ct)) { const t = new TextDecoder().decode(raw); try { body = JSON.parse(t); } catch { body = t; } }
|
|
if (isWrite(method, path)) s.writes.push(`${method} ${host}${path}`);
|
|
const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
|
|
if (host === 'oauth2.googleapis.com') return json({ access_token: 'ya29.acceptance', expires_in: 3600 });
|
|
// a 599 is a route the fakes do not know: noted on the cell (it makes the CLI retry and wait)
|
|
const noted = (r: Response): Response => { if (r.status === 599) s.unknown.push(`${method} ${host}${path.split('?')[0]}`); return r; };
|
|
if (host === 'api.appstoreconnect.apple.com' && /\/perfPowerMetrics$|\/diagnosticSignatures$/.test(path.split('?')[0]!)) return json({ productData: [], data: [] });
|
|
if (host === 'api.appstoreconnect.apple.com' && /\/betaFeedback(Crash|Screenshot)Submissions$/.test(path.split('?')[0]!)) return json({ data: [], links: {}, meta: { paging: { total: 0, limit: 50 } } });
|
|
if (host === 'androidpublisher.googleapis.com' && /\/countryAvailability\/[^/]+$/.test(path.split('?')[0]!)) return json({ countries: [], restOfWorld: false, syncWithProduction: false });
|
|
if (host === 'api.appstoreconnect.apple.com') return noted(s.asc.handle(method, path, body));
|
|
if (host === 'androidpublisher.googleapis.com' && /pricing:convertRegionPrices$/.test(path.split('?')[0]!)) return json(convertAll(body));
|
|
if (host === 'androidpublisher.googleapis.com') return noted(s.play.handle(method, `https://${host}${path}`, body));
|
|
if (host === 'upload.example.com') return new Response(null, { status: 200 });
|
|
if (/(^|\.)example\.com$/.test(host)) return new Response('<html><body>ok</body></html>', { status: 200, headers: { 'content-type': 'text/html' } });
|
|
if (host === 'itunes.apple.com') return json({ resultCount: 0, results: [] });
|
|
// Play Developer Reporting for an app without data yet: no apps, no rows, no issues
|
|
if (host === 'playdeveloperreporting.googleapis.com') {
|
|
const p = path.split('?')[0]!;
|
|
if (/apps:search$/.test(p)) return json({ apps: [] });
|
|
if (/:query$/.test(p)) return json({ rows: [] });
|
|
if (/errorIssues:search$|errorReports:search$/.test(p)) return json({});
|
|
if (/MetricSet$/.test(p)) return json({ name: p.slice(1), freshnessInfo: { freshnesses: [] } });
|
|
}
|
|
s.unknown.push(`${method} ${host}${path.split('?')[0]}`);
|
|
return json({ error: { code: 404, message: `acceptance mock: no route for ${host}` } }, 404);
|
|
},
|
|
});
|
|
return { base: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true) };
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------------------- projects
|
|
|
|
/** The write project: `init --profile subscription-app` with placeholder store ids, throwaway keys, an AAB, a CSV. */
|
|
function makeAppTemplate(dir: string): void {
|
|
mkdirSync(dir, { recursive: true });
|
|
const r = Bun.spawnSync(['bun', join(ROOT, 'cli/index.ts'), 'init', '--profile', 'subscription-app'], { cwd: dir, env: { ...process.env, NO_COLOR: '1' }, stdout: 'pipe', stderr: 'pipe' });
|
|
if (r.exitCode !== 0) throw new Error(`init of the app template failed: ${r.stderr.toString()}`);
|
|
const cfg = join(dir, 'storebound.config.ts');
|
|
writeFileSync(cfg, readFileSync(cfg, 'utf8').replace(/bundleId: 'com\.example\.app',/, `bundleId: 'com.example.app',\n appId: '${APP_ID}',`)
|
|
.replace(/\n subscriptions: \[/, `\n products: [{ id: 'com.example.app.lifetime', type: 'non_consumable', usd: 49.99,\n listings: { en: { title: 'Lifetime', description: 'Pro forever.' }, tr: { title: 'Ömür boyu', description: 'Sonsuza dek Pro.' } } }],\n subscriptions: [`));
|
|
const ec = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
const credPath = join(dir, '.storebound/credentials.json');
|
|
const cred = JSON.parse(readFileSync(credPath, 'utf8')) as Record<string, unknown>;
|
|
Object.assign(cred, {
|
|
ASC_KEY_ID: 'ABCDE12345', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: ec,
|
|
PLAY_SERVICE_ACCOUNT: JSON.stringify({ type: 'service_account', client_email: 'sb@example.com', private_key: rsa, token_uri: 'https://oauth2.googleapis.com/token' }),
|
|
});
|
|
writeFileSync(credPath, JSON.stringify(cred, null, 2));
|
|
writeFileSync(join(dir, 'app-release.aab'), makeAab('com.example.app', 43, '1.0.0'));
|
|
writeFileSync(join(dir, 'store/prices.csv'), 'product,store,region,price\n');
|
|
writeFileSync(join(dir, 'store/bundle.json'), JSON.stringify({ data: { type: 'bundleIds', attributes: { identifier: 'com.example.other', name: 'Other', platform: 'IOS' } } }));
|
|
}
|
|
|
|
function prepare(cell: Cell, dir: string, tpl: { app: string }): void {
|
|
mkdirSync(dir, { recursive: true });
|
|
const p = cell.project ?? 'empty';
|
|
if (p === 'app') cpSync(tpl.app, dir, { recursive: true });
|
|
else if (p === 'demo') cpSync(join(ROOT, 'examples/demo-game'), dir, { recursive: true });
|
|
else if (p.startsWith('fixture:')) cpSync(join(ROOT, 'tests/fixtures/scan', p.slice(8)), dir, { recursive: true });
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------------------- running
|
|
|
|
const SECRET_ENV = /^(ASC_|PLAY_|APPLE_ADS_|REVENUECAT_|ANDROID_KEY|REVIEW_DEMO_|STOREBOUND_|GOOGLE_APPLICATION_CREDENTIALS)/;
|
|
|
|
interface Runtime { name: string; base: (args: string[]) => string[]; env: Record<string, string>; cellsRoot: string; label: string }
|
|
|
|
function findNpxCli(node: string): string | null {
|
|
const d = dirname(node);
|
|
for (const p of [join(d, 'node_modules/npm/bin/npx-cli.js'), join(d, '../lib/node_modules/npm/bin/npx-cli.js')]) if (existsSync(p)) return p;
|
|
return null;
|
|
}
|
|
|
|
function runtimeFor(kind: string, tgz: string, work: string, nodeArg?: string): Runtime {
|
|
if (kind === 'node') {
|
|
const node = resolve(nodeArg ?? Bun.which('node') ?? 'node');
|
|
const npx = findNpxCli(node);
|
|
if (!npx) throw new Error(`no npx-cli.js next to ${node}`);
|
|
const ver = Bun.spawnSync([node, '--version'], { stdout: 'pipe' }).stdout.toString().trim();
|
|
const spec = WIN ? `file:${tgz.replace(/\\/g, '/')}` : tgz;
|
|
return {
|
|
name: `node ${ver.replace(/^v/, '').split('.')[0]}`, label: `node ${ver} (npx --package=<tgz>)`, cellsRoot: join(work, 'cells'),
|
|
base: a => [node, npx, '--yes', `--package=${spec}`, 'storebound', ...a],
|
|
env: { PATH: dirname(node) + delimiter + (process.env.PATH ?? ''), npm_config_cache: join(work, 'npm-cache'), npm_config_update_notifier: 'false', npm_config_fund: 'false', npm_config_audit: 'false' },
|
|
};
|
|
}
|
|
if (kind === 'bun') {
|
|
const bun = process.execPath;
|
|
const root = join(work, 'bun');
|
|
mkdirSync(join(root, 'cells'), { recursive: true });
|
|
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'sb-acceptance', private: true }));
|
|
const r = Bun.spawnSync([bun, 'add', tgz], { cwd: root, stdout: 'pipe', stderr: 'pipe', timeout: 180_000 });
|
|
if (r.exitCode !== 0) throw new Error(`bun add <tgz> failed: ${r.stderr.toString()}`);
|
|
return { name: 'bun', label: `bun ${Bun.version} (bun add <tgz>, bun x --bun storebound)`, cellsRoot: join(root, 'cells'), base: a => [bun, 'x', '--bun', 'storebound', ...a], env: {} };
|
|
}
|
|
if (kind === 'src') return { name: 'src', label: `bun ${Bun.version} (sources)`, cellsRoot: join(work, 'cells'), base: a => [process.execPath, join(ROOT, 'cli/index.ts'), ...a], env: {} };
|
|
throw new Error(`--runtime node|bun|src (got ${kind})`);
|
|
}
|
|
|
|
function killTree(pid: number): void {
|
|
try {
|
|
if (WIN) Bun.spawnSync(['taskkill', '/T', '/F', '/PID', String(pid)], { stdout: 'ignore', stderr: 'ignore' });
|
|
else { Bun.spawnSync(['pkill', '-KILL', '-P', String(pid)], { stdout: 'ignore', stderr: 'ignore' }); process.kill(pid, 'SIGKILL'); }
|
|
} catch { /* gone */ }
|
|
}
|
|
|
|
/** Results are published (docs/acceptance.md, the release): machine paths out of error texts. */
|
|
export function scrub(s: string, dirs: string[] = [tmpdir(), homedir()]): string {
|
|
let out = s;
|
|
for (const d of dirs.filter(Boolean).sort((a, b) => b.length - a.length)) out = out.split(d).join(d === homedir() ? '~' : '<tmp>');
|
|
return out.replace(/\/(?:private\/)?var\/folders\/[^\s'"]+/g, '<tmp>').replace(/[A-Z]:\\Users\\[^\\\s]+/gi, '~').replace(/\/(?:Users|home)\/[^/\s]+/g, '~');
|
|
}
|
|
|
|
const firstLine = (s: string): string => s.split('\n').map(l => l.trim()).find(Boolean) ?? '';
|
|
|
|
async function runCell(cell: Cell, rt: Runtime, mock: string, tpl: { app: string }, home: string, platform: string, running: Map<string, number>): Promise<Result> {
|
|
const t = Date.now();
|
|
const slug = cell.id.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 60) + '-' + Math.random().toString(36).slice(2, 7);
|
|
const box = join(rt.cellsRoot, slug);
|
|
const dir = join(box, 'p');
|
|
prepare(cell, dir, tpl);
|
|
const h = cell.ownHome ? join(box, 'home') : home;
|
|
mkdirSync(h, { recursive: true });
|
|
const env: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !SECRET_ENV.test(k)) env[k] = v;
|
|
Object.assign(env, rt.env, rt.name.startsWith('node') ? { npm_config_cache: join(box, 'npm-cache') } : {}, {
|
|
HOME: h, USERPROFILE: h, NO_COLOR: '1', FORCE_COLOR: '0', STOREBOUND_NO_UPDATE_CHECK: '1', STOREBOUND_UPSERT_WAIT_MS: '0', STOREBOUND_LIST_SETTLE_MS: '0',
|
|
STOREBOUND_STORE_MOCK: `${mock}/c/${slug}`, ...(cell.project === 'app' && cell.group === 'mcp' ? { STOREBOUND_PROJECT: dir } : {}), ...cell.env,
|
|
});
|
|
const cmd = rt.base(cell.args);
|
|
const shown = `storebound ${cell.args.map(a => (/[\s"{}]/.test(a) ? `'${a}'` : a)).join(' ')}`;
|
|
const base: Omit<Result, 'ok' | 'exit' | 'ms'> = { id: cell.id, group: cell.group, cmd: shown, platform, runtime: rt.name, want: [cell.exit].flat().join('|') };
|
|
let proc: ReturnType<typeof Bun.spawn>;
|
|
try {
|
|
proc = Bun.spawn(cmd, { cwd: dir, env, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
|
|
} catch (e) { return { ...base, ok: false, exit: null, ms: Date.now() - t, error: `spawn failed: ${(e as Error).message}` }; }
|
|
running.set(cell.id, t);
|
|
const sin = proc.stdin as import('bun').FileSink;
|
|
if (cell.stdin) sin.write(cell.stdin);
|
|
await sin.end();
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => { timedOut = true; killTree(proc.pid); }, cell.timeoutMs ?? 120_000);
|
|
const [out, err, code] = await Promise.all([new Response(proc.stdout as ReadableStream).text(), new Response(proc.stderr as ReadableStream).text(), proc.exited]);
|
|
clearTimeout(timer);
|
|
running.delete(cell.id);
|
|
const all = `${out}\n${err}`;
|
|
const want = [cell.exit].flat();
|
|
const st = stores.get(slug);
|
|
let error: string | undefined;
|
|
const crash = all.split('\n').find(l => CRASH.test(l) && !/^\s*"/.test(l));
|
|
if (timedOut) error = `timed out after ${(cell.timeoutMs ?? 120_000) / 1000} s; last output: ${firstLine(all.split('\n').filter(Boolean).slice(-3).join('\n'))}`;
|
|
else if (crash) error = `crash text: ${crash.trim().slice(0, 200)}`;
|
|
else if (!want.includes(code)) error = `exit ${code}, want ${want.join('|')}: ${firstLine(err.match(/^error: .*$/m)?.[0] ?? '') || firstLine(err) || firstLine(out)}`;
|
|
else if (cell.expect && !cell.expect.test(all)) error = `output does not match ${cell.expect}: ${firstLine(all).slice(0, 160)}`;
|
|
else if (cell.noWrites && st?.writes.length) error = `wrote to the store: ${st.writes.slice(0, 3).join(', ')}`;
|
|
else { const e = cell.after?.(dir, out); if (e) error = e; }
|
|
const note = st?.unknown.length ? `unmocked: ${[...new Set(st.unknown)].slice(0, 3).join(', ')}` : undefined;
|
|
stores.delete(slug);
|
|
if (!error && !KEEP) rmSync(box, { recursive: true, force: true });
|
|
else writeFileSync(join(box, 'output.txt'), `$ ${shown}\nexit ${code}\n--- stdout\n${out}\n--- stderr\n${err}\n`);
|
|
return { ...base, ok: !error, exit: code, ms: Date.now() - t, ...(error ? { error: scrub(error).replace(/\s+/g, ' ').slice(0, 300) } : {}), ...(note ? { note: scrub(note) } : {}) };
|
|
}
|
|
|
|
async function run(argv: string[]): Promise<void> {
|
|
const arg = (n: string): string | undefined => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; };
|
|
const kind = arg('--runtime') ?? 'node';
|
|
const quick = argv.includes('--quick');
|
|
const platform = arg('--platform') ?? ({ darwin: 'macOS', linux: 'Linux', win32: 'Windows' } as Record<string, string>)[osPlatform()] ?? osPlatform();
|
|
const only = arg('--only') ? new RegExp(arg('--only')!, 'i') : null;
|
|
const jobs = Number(arg('--jobs') ?? Math.max(2, Math.min(6, cpus().length - 1)));
|
|
const budgetMs = Number(arg('--budget') ?? 40) * 60_000;
|
|
const work = mkdtempSync(join(tmpdir(), 'sb-accept-'));
|
|
const t0 = Date.now();
|
|
console.log(`acceptance: ${platform}, runtime ${kind}${quick ? ' (quick)' : ''}, work folder ${work}`);
|
|
let tgz = arg('--tgz') ? resolve(arg('--tgz')!) : '';
|
|
if (!tgz) {
|
|
const npm = Bun.which('npm');
|
|
if (!npm) throw new Error('no npm on PATH to pack the tarball (or pass --tgz)');
|
|
const r = Bun.spawnSync([npm, 'pack', '--pack-destination', work], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 300_000 });
|
|
if (r.exitCode !== 0) throw new Error(`npm pack failed: ${r.stderr.toString().slice(-2000)}`);
|
|
tgz = join(work, readdirSync(work).find(f => f.endsWith('.tgz'))!);
|
|
}
|
|
console.log(`acceptance: tarball ${tgz} (${(statSync(tgz).size / 1e6).toFixed(1)} MB)`);
|
|
const rt = runtimeFor(kind, tgz, work, arg('--node'));
|
|
mkdirSync(rt.cellsRoot, { recursive: true });
|
|
console.log(`acceptance: ${rt.label}`);
|
|
const tpl = { app: join(work, 'tpl-app') };
|
|
makeAppTemplate(tpl.app);
|
|
const forms = (JSON.parse(Bun.spawnSync(['bun', join(ROOT, 'cli/index.ts'), 'console', 'plan', '--json'], { cwd: tpl.app, stdout: 'pipe' }).stdout.toString()) as { forms: { id: string }[] }).forms.map(f => f.id);
|
|
const list = cells(forms).filter(c => (!quick || c.quick) && (!only || only.test(c.id)));
|
|
const home = join(work, 'home');
|
|
mkdirSync(home, { recursive: true });
|
|
const mock = startMock();
|
|
const running = new Map<string, number>();
|
|
const results: Result[] = [];
|
|
const beat = setInterval(() => {
|
|
const now = Date.now();
|
|
console.log(`acceptance: ${results.length}/${list.length} done, ${(Math.round((now - t0) / 1000))} s; running: ${[...running].map(([id, s]) => `${id} (${Math.round((now - s) / 1000)} s)`).join(', ') || '-'}`);
|
|
}, 20_000);
|
|
// warm up: the first npx / bun x call installs the tarball; run it alone so parallel cells do not race the cache
|
|
const queue = [...list];
|
|
const first = queue.findIndex(c => c.id === '--version');
|
|
if (first >= 0) {
|
|
const r = await runCell(queue.splice(first, 1)[0]!, rt, mock.base, tpl, home, platform, running);
|
|
console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.id} (${r.ms} ms, installs the tarball)${r.error ? `: ${r.error}` : ''}`);
|
|
results.push(r);
|
|
}
|
|
let stopped = false;
|
|
const budget = setTimeout(() => { stopped = true; console.log(`acceptance: budget of ${budgetMs / 60_000} min used up; killing the running cells`); }, budgetMs);
|
|
await Promise.all(Array.from({ length: jobs }, async () => {
|
|
for (let c = queue.shift(); c; c = queue.shift()) {
|
|
if (stopped) { results.push({ id: c.id, group: c.group, cmd: `storebound ${c.args.join(' ')}`, platform, runtime: rt.name, ok: false, exit: null, want: String(c.exit), ms: 0, error: 'not run: the run budget was used up' }); continue; }
|
|
const r = await runCell(c, rt, mock.base, tpl, home, platform, running);
|
|
console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.id} (${r.ms} ms)${r.error ? `: ${r.error}` : ''}${r.note ? ` [${r.note}]` : ''}`);
|
|
results.push(r);
|
|
}
|
|
}));
|
|
clearTimeout(budget);
|
|
clearInterval(beat);
|
|
mock.stop();
|
|
const order = new Map(list.map((c, i) => [c.id, i]));
|
|
results.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
|
const failed = results.filter(r => !r.ok);
|
|
const out = resolve(arg('--out') ?? join(ROOT, `acceptance-${platform}-${rt.name.replace(/\s+/g, '')}.json`));
|
|
mkdirSync(dirname(out), { recursive: true });
|
|
writeFileSync(out, JSON.stringify({ platform, runtime: rt.name, label: rt.label, version: PKG.version, quick, at: new Date().toISOString(), results }, null, 2));
|
|
console.log(`acceptance: ${results.length - failed.length}/${results.length} cells passed in ${Math.round((Date.now() - t0) / 1000)} s -> ${out}`);
|
|
if (failed.length) {
|
|
console.log(`acceptance: failed cells keep their folder and output.txt under ${work}`);
|
|
for (const f of failed) console.log(` ✗ ${f.id}: ${f.error}`);
|
|
process.exit(1);
|
|
}
|
|
if (!KEEP) rmSync(work, { recursive: true, force: true }); else console.log(`acceptance: kept ${work}`);
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------------------- report
|
|
|
|
interface RunFile { platform: string; runtime: string; label: string; version: string; quick: boolean; at: string; results: Result[] }
|
|
|
|
export function renderReport(runs: RunFile[]): string {
|
|
const cols = runs.map(r => `${r.platform} · ${r.runtime}`);
|
|
const ids: { id: string; cmd: string; group: string }[] = [];
|
|
const seen = new Set<string>();
|
|
for (const r of runs) for (const x of r.results) if (!seen.has(x.id)) { seen.add(x.id); ids.push({ id: x.id, cmd: x.cmd, group: x.group }); }
|
|
const esc = (s: string) => s.replace(/\|/g, '\\|').replace(/`/g, "'").replace(/\n/g, ' ');
|
|
const total = runs.reduce((n, r) => n + r.results.length, 0);
|
|
const bad = runs.reduce((n, r) => n + r.results.filter(x => !x.ok).length, 0);
|
|
const lines = [
|
|
'# Release acceptance matrix',
|
|
'',
|
|
'<!-- generated by `bun tools/acceptance.ts report`; do not edit -->',
|
|
'',
|
|
`storebound ${runs[0]?.version ?? '?'}: every user-facing command run from the packed npm tarball, in a fresh temp folder with piped stdin and the fake stores (tools/acceptance.ts). ✓ = expected exit code and no crash text; ✗ = the first error.`,
|
|
'',
|
|
`**${total - bad} / ${total} cells passed.**`,
|
|
'',
|
|
...runs.map(r => `- ${r.platform} · ${r.runtime}: ${r.label}, ${r.results.filter(x => x.ok).length}/${r.results.length}${r.quick ? ' (quick subset)' : ''}, ${r.at.slice(0, 16).replace('T', ' ')} UTC`),
|
|
'',
|
|
`| command | ${cols.join(' | ')} |`,
|
|
`|---|${cols.map(() => '---').join('|')}|`,
|
|
];
|
|
let group = '';
|
|
for (const { id, cmd, group: g } of ids) {
|
|
if (g !== group) { group = g; lines.push(`| **${esc(g)}** |${cols.map(() => ' ').join('|')}|`); }
|
|
const cells = runs.map(r => { const x = r.results.find(y => y.id === id); return !x ? '–' : x.ok ? '✓' : `✗ ${esc(x.error ?? '')}`; });
|
|
lines.push(`| ${esc(id)}<br>\`${esc(cmd).slice(0, 90)}\` | ${cells.join(' | ')} |`);
|
|
}
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
function report(argv: string[]): void {
|
|
const val = (n: string): string | undefined => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; };
|
|
const md = val('--md') ?? join(ROOT, 'docs/acceptance.md');
|
|
const expect = Number(val('--expect') ?? 0);
|
|
const files = argv.filter((a, i) => !a.startsWith('--') && !['--md', '--expect'].includes(argv[i - 1] ?? '')).filter(f => existsSync(f));
|
|
const runs = files.flatMap(f => (statSync(f).isDirectory() ? readdirSync(f, { recursive: true }).map(String).filter(x => x.endsWith('.json')).map(x => join(f, x)) : [f]))
|
|
.map(f => JSON.parse(readFileSync(f, 'utf8')) as RunFile).filter(r => Array.isArray(r.results));
|
|
const rank = (r: RunFile) => ['macOS', 'Linux', 'Windows'].indexOf(r.platform) * 10 + ['node 20', 'node 22', 'bun'].indexOf(r.runtime);
|
|
runs.sort((a, b) => rank(a) - rank(b));
|
|
writeFileSync(md, renderReport(runs));
|
|
const bad = runs.flatMap(r => r.results.filter(x => !x.ok));
|
|
const stale = runs.filter(r => r.version !== PKG.version || r.quick);
|
|
console.log(`acceptance report: ${runs.length} runs, ${bad.length} red cells -> ${md}`);
|
|
for (const r of stale) console.log(` ${r.platform} · ${r.runtime}: ${r.quick ? 'a quick run' : `version ${r.version}, not ${PKG.version}`}`);
|
|
const missing = expect && runs.length < expect ? `${expect - runs.length} of ${expect} runs missing` : '';
|
|
if (missing) console.log(` ${missing}`);
|
|
if (argv.includes('--strict') && (bad.length || stale.length || missing)) process.exit(1);
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
if (cmd === 'run') await run(rest);
|
|
else if (cmd === 'report') report(rest);
|
|
else { console.error('usage: bun tools/acceptance.ts run --runtime node|bun [...] | report <cells.json|dir>... [--md docs/acceptance.md] [--strict]'); process.exit(2); }
|
|
}
|