// Recorded-response harness for command tests: throwaway API keys, a fetch router that answers like App Store Connect, // Google and the iTunes Search API did (sanitized: every id, name and email is a placeholder), a temp project with a // config, and capture of stdout / stderr / process.exit. Nothing here reaches the network. import { generateKeyPairSync } from 'node:crypto'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; import { parseArgs } from '../../cli/lib/args'; import type { Loaded, ShipConfig } from '../../cli/lib/config'; import type { Ctx } from '../../cli/lib/ctx'; export type J = Record; export interface Call { method: string; url: string; path: string; body: unknown } type Answer = Response | J | ((c: Call) => Response | J | Promise); export interface Route { method?: string; match: RegExp; answer: Answer; times?: number } export const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } }); export const notFound = () => json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '404', code: 'NOT_FOUND', title: 'The specified resource does not exist', detail: 'There is no resource' }] }, 404); const saved = { ...process.env }; const realFetch = globalThis.fetch; export function ascEnv(): void { const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); process.env.ASC_KEY_ID = 'ABCDE12345'; process.env.ASC_ISSUER_ID = '00000000-0000-0000-0000-000000000000'; process.env.ASC_P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); } export function playEnv(): void { const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' }); } /** Installs the router; unknown requests fail the test loudly (answer 599 with the path). Returns the call log. */ export function mockFetch(routes: Route[]): Call[] { const calls: Call[] = []; const left = routes.map(r => ({ ...r, left: r.times ?? Infinity })); globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input instanceof Request ? input.url : input); const method = (init?.method ?? 'GET').toUpperCase(); const raw = init?.body; let body: unknown = raw; if (typeof raw === 'string') { try { body = JSON.parse(raw); } catch { body = raw; } } const path = url.replace(/^https:\/\/[^/]+/, ''); const c: Call = { method, url, path, body }; if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'ya29.test', expires_in: 3600 }); calls.push(c); const r = left.find(x => x.left > 0 && (x.method ?? 'GET') === method && x.match.test(decodeURIComponent(path))); if (!r) return new Response(`not recorded: ${method} ${path}`, { status: 599 }); r.left--; const a = typeof r.answer === 'function' ? await r.answer(c) : r.answer; return a instanceof Response ? a : json(a); }) as typeof fetch; return calls; } export function restore(): void { globalThis.fetch = realFetch; for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'PLAY_SERVICE_ACCOUNT', 'ASC_VENDOR_NUMBER']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } } export const BASE: ShipConfig = { app: { name: 'Example Notes', profile: 'app', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' }, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }, { id: 'tr', apple: 'tr', play: 'tr-TR' }], apple: { bundleId: 'com.example.notes', appId: '6400000101' }, play: { packageName: 'com.example.notes', reportsBucket: 'pubsite_prod_rev_01234567890123456789' }, }; /** A temp project folder with optional files; returns a Ctx factory for that config. */ export function project(config: ShipConfig = BASE, files: Record = {}): { root: string; ctx: (argv: string[], booleans?: string[]) => Ctx; loaded: Loaded } { const root = mkdtempSync(join(tmpdir(), 'sb-ops-')); for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } const loaded: Loaded = { config, root, path: join(root, 'storebound.config.ts'), resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) }; return { root, loaded, ctx: (argv, booleans = []) => ({ loaded, args: parseArgs(argv, booleans) }) }; } /** Runs fn with console output captured and process.exit turned into a thrown { exit } (the code is returned). */ export async function capture(fn: () => Promise): Promise<{ out: string; err: string; exit: number | null }> { const out: string[] = [], err: string[] = []; const log = console.log, warn = console.warn, error = console.error, exit = process.exit, write = process.stdout.write; console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ')); }; console.warn = (...a: unknown[]) => { err.push(a.map(String).join(' ')); }; console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ')); }; process.stdout.write = ((s: string) => { out.push(String(s)); return true; }) as typeof process.stdout.write; let code: number | null = null; process.exit = ((c?: number) => { code = c ?? 0; throw new ExitSignal(code); }) as typeof process.exit; const before = process.exitCode; process.exitCode = 0; try { await fn(); } catch (e) { if (!(e instanceof ExitSignal)) { restoreIo(); throw e; } } restoreIo(); if (code === null && process.exitCode) code = Number(process.exitCode); process.exitCode = before ?? 0; return { out: out.join('\n'), err: err.join('\n'), exit: code }; function restoreIo(): void { console.log = log; console.warn = warn; console.error = error; process.exit = exit; process.stdout.write = write; } } class ExitSignal extends Error { constructor(readonly code: number) { super(`exit ${code}`); } } /** The --json object printed by a command (printJson pretty-prints one object; log lines may follow). */ export function jsonOut(out: string): J { const i = out.indexOf('\n{\n') >= 0 ? out.indexOf('\n{\n') + 1 : out.indexOf('{\n'); if (i < 0) throw new Error(`no JSON in output:\n${out.slice(0, 500)}`); // find the matching closing brace at column 0 const end = out.indexOf('\n}', i); return JSON.parse(out.slice(i, end + 2)) as J; }