// The release acceptance matrix (tools/acceptance.ts) and the seams it drives the packed CLI through: the store mock // (loopback only), piped stdin read as a terminal, the desktop app treated as not installed; no prompt checks // process.stdin.isTTY on its own (the matrix could not reach it). The bugs it found: tests/matrix-bugs.test.ts. import { afterEach, describe, expect, test } from 'bun:test'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { installStoreMock, mockedUrl } from '../cli/lib/store-mock-env'; import { stdinIsTTY } from '../cli/lib/tty'; import { findInstalled } from '../cli/lib/desktop-dist'; import { CRASH, cells, renderReport, type Result } from '../tools/acceptance'; import { restore } from './helpers/store-mock'; const ROOT = join(import.meta.dir, '..'); afterEach(restore); describe('store mock (STOREBOUND_STORE_MOCK)', () => { test('store hosts go to the mock, other hosts stay', () => { const b = 'http://127.0.0.1:4100/c/cell-1'; expect(mockedUrl('https://api.appstoreconnect.apple.com/v1/apps?limit=1', b)).toBe(`${b}/__host/api.appstoreconnect.apple.com/v1/apps?limit=1`); expect(mockedUrl('https://androidpublisher.googleapis.com/androidpublisher/v3/applications/x/edits', b)).toBe(`${b}/__host/androidpublisher.googleapis.com/androidpublisher/v3/applications/x/edits`); expect(mockedUrl('https://oauth2.googleapis.com/token', b)).toBe(`${b}/__host/oauth2.googleapis.com/token`); expect(mockedUrl('https://example.com/privacy', b)).toBe(`${b}/__host/example.com/privacy`); expect(mockedUrl('https://storebound.dev/desktop/latest.json', b)).toBe('https://storebound.dev/desktop/latest.json'); expect(mockedUrl('https://evilapple.com/x', b)).toBe('https://evilapple.com/x'); expect(mockedUrl('http://api.appstoreconnect.apple.com/x', b)).toBe('http://api.appstoreconnect.apple.com/x'); }); test('only a loopback address is accepted (store keys never leave the machine)', () => { const real = globalThis.fetch; try { for (const bad of ['http://192.0.2.' + '5:80', 'https://127.0.0.1:80', 'http://example.com:80', 'http://127.0.0.1.example.com:80', 'http://127.0.0.1:80' + '@evil.example']) expect(() => installStoreMock({ STOREBOUND_STORE_MOCK: bad })).toThrow(/loopback/); expect(installStoreMock({})).toBe(false); expect(installStoreMock({ STOREBOUND_STORE_MOCK: 'http://127.0.0.1:4100/c/x' })).toBe(true); } finally { globalThis.fetch = real; } }); }); describe('prompt and desktop seams', () => { test('STOREBOUND_ASSUME_TTY=1 lets piped stdin answer the prompts', () => { expect(stdinIsTTY({ STOREBOUND_ASSUME_TTY: '1' })).toBe(true); expect(stdinIsTTY({})).toBe(!!process.stdin.isTTY); }); test('STOREBOUND_DESKTOP_APP=none: the app counts as not installed', () => { const exists = () => true; expect(findInstalled('darwin', {}, exists, '/tmp/h')).not.toBeNull(); for (const p of ['darwin', 'win32', 'linux'] as const) expect(findInstalled(p, { STOREBOUND_DESKTOP_APP: 'none', LOCALAPPDATA: 'C:\\x' }, exists, '/tmp/h', () => ['storebound.AppImage'])).toBeNull(); }); test('no prompt checks process.stdin.isTTY directly (cli/lib/tty.ts stdinIsTTY, so the matrix can drive it)', () => { const files = (readdirSync(join(ROOT, 'cli'), { recursive: true }) as string[]).filter(f => f.endsWith('.ts') && !f.endsWith('lib/tty.ts')); const hits = files.filter(f => statSync(join(ROOT, 'cli', f)).isFile() && /process\.stdin\.isTTY/.test(readFileSync(join(ROOT, 'cli', f), 'utf8'))); expect(hits).toEqual([]); }); }); describe('matrix', () => { const forms = ['apple-app-privacy', 'play-ads']; const list = cells(forms); test('crash text is recognised in every runtime\'s wording', () => { for (const s of ['TypeError: x is not a function', "undefined is not an object (evaluating 'info.id')", "Cannot read properties of undefined (reading 'id')", 'error: console is not async iterable', 'Assertion failed: (x)', 'thread panicked at']) expect(s).toMatch(CRASH); for (const s of ['error: 5 problems', 'declined: the owner answered no', 'dry run: nothing changed']) expect(s).not.toMatch(CRASH); }); test('every console form, every scan fixture, the headline prompts and a quick subset are covered', () => { const ids = list.map(c => c.id); for (const f of forms) expect(ids).toContain(`console plan ${f}`); for (const f of ['capacitor', 'flutter', 'react-native', 'expo', 'unity', 'ios-native', 'android']) expect(ids).toContain(`scan ${f}`); expect(ids).toContain('desktop [Y/n] answered n'); expect(ids).toContain('mcp initialize + tools/list + tools/call check'); expect(new Set(ids).size).toBe(ids.length); const quick = list.filter(c => c.quick).map(c => c.id); expect(quick).toContain('desktop [Y/n] answered n'); expect(quick.length).toBeGreaterThan(10); expect(quick.length).toBeLessThan(list.length / 2); }); test('store cells are dry runs or answered prompts: none may write', () => { for (const c of list.filter(x => x.group === 'store --dry')) { expect(c.noWrites, c.id).toBe(true); const reads = /^(apple testflight status|release (plan|status)|catalog diff|validate|play status|apple status|apps (list|check))/; if (!reads.test(c.id)) expect(c.args, c.id).toContain('--dry'); } for (const c of list.filter(x => x.group === 'confirm n' && x.noWrites && x.stdin !== 'wrong\n')) expect([c.exit].flat(), c.id).toContain(3); }); test('report: one column per platform and runtime, ✓ / ✗ with the first error', () => { const r = (ok: boolean, rt: string, error?: string): Result => ({ id: 'help', group: 'basics', cmd: 'storebound help', platform: 'Linux', runtime: rt, ok, exit: ok ? 0 : 1, want: '0', ms: 1, ...(error ? { error } : {}) }); const md = renderReport([ { platform: 'Linux', runtime: 'node 20', label: 'node v20', version: '9.9.9', quick: false, at: '2026-10-14T00:00:00Z', results: [r(true, 'node 20')] }, { platform: 'Linux', runtime: 'bun', label: 'bun', version: '9.9.9', quick: false, at: '2026-10-14T00:00:00Z', results: [r(false, 'bun', 'crash text: a | b')] }, ]); expect(md).toContain('| command | Linux · node 20 | Linux · bun |'); expect(md).toContain('| ✓ | ✗ crash text: a \\| b |'); expect(md).toContain('**1 / 2 cells passed.**'); }); });