storebound/tests/acceptance.test.ts

100 lines
6.3 KiB
TypeScript

// 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.**');
});
});