From f58e1920019f992a6355b4a8030d94ec252301a5 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 08:29:26 +0800 Subject: [PATCH] release acceptance matrix: tools/acceptance.ts runs every user-facing command from the packed npm tarball (npx --package= on Node 20 / 22, bun x --bun after bun add ) in fresh temp folders with a fresh HOME, real piped stdin (n, EOF) and per-cell fake stores over HTTP (STOREBOUND_STORE_MOCK, loopback only); a cell passes only with the expected exit code and no crash text; report -> markdown matrix. Prompts go through stdinIsTTY (STOREBOUND_ASSUME_TTY drives them with piped stdin), STOREBOUND_DESKTOP_APP=none for the install prompt on a machine with the app. release.yml: required acceptance job matrix (Linux + Windows x node20/node22/bun) and report, the matrix attached to the release; ci.yml: the quick subset on Linux and Windows. Scan fixtures for React Native and Expo --- .forgejo/workflows/ci.yml | 42 ++ .forgejo/workflows/release.yml | 115 +++- cli/api.ts | 3 +- cli/apple/appinfo.ts | 3 +- cli/apple/subscriptions.ts | 5 +- cli/desktop.ts | 3 +- cli/index.ts | 2 + cli/lib/ctx.ts | 3 +- cli/lib/desktop-dist.ts | 2 + cli/lib/price-gate.ts | 3 +- cli/lib/store-mock-env.ts | 32 + cli/lib/tty.ts | 5 + cli/listing-store.ts | 5 +- cli/play/listing.ts | 3 +- cli/play/subscriptions.ts | 3 +- cli/prices.ts | 3 +- tests/acceptance.test.ts | 100 ++++ tests/fixtures/scan/expo/README.md | 2 + tests/fixtures/scan/expo/app.json | 9 + tests/fixtures/scan/expo/package.json | 12 + tests/fixtures/scan/react-native/README.md | 2 + .../android/app/src/main/AndroidManifest.xml | 5 + .../scan/react-native/ios/Notes/Info.plist | 12 + tests/fixtures/scan/react-native/package.json | 10 + tests/helpers/fake-stores.ts | 1 + tools/acceptance.ts | 556 ++++++++++++++++++ 26 files changed, 926 insertions(+), 15 deletions(-) create mode 100644 cli/lib/store-mock-env.ts create mode 100644 cli/lib/tty.ts create mode 100644 tests/acceptance.test.ts create mode 100644 tests/fixtures/scan/expo/README.md create mode 100644 tests/fixtures/scan/expo/app.json create mode 100644 tests/fixtures/scan/expo/package.json create mode 100644 tests/fixtures/scan/react-native/README.md create mode 100644 tests/fixtures/scan/react-native/android/app/src/main/AndroidManifest.xml create mode 100644 tests/fixtures/scan/react-native/ios/Notes/Info.plist create mode 100644 tests/fixtures/scan/react-native/package.json create mode 100644 tools/acceptance.ts diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 63d25c3..d816b74 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -3,6 +3,7 @@ # tools/ci.ts runs the steps as a task graph and the tests as parallel shards (CPU cores - 1, max 8) on the one # self-hosted runner, so a job matrix would only queue; CI=full makes it run the Rust steps on every push. # Publishes nothing: site/ is still published by Jenkins; releases come from release.yml (a v* tag). +# acceptance-quick: the quick subset of the acceptance matrix (tools/acceptance.ts) on the Linux and Windows runners. # release.yml's ci job runs the same steps (the setup is shared: .forgejo/actions/linux-setup). name: ci @@ -33,3 +34,44 @@ jobs: cache-key: ci rust-components: clippy - uses: ./.forgejo/actions/ci-run + + # The quick subset of the release acceptance matrix (tools/acceptance.ts --quick: --version, init, check, scan, the + # desktop [Y/n] prompt answered n, mcp, a console plan, store --dry writes and confirm prompts answered n) from the + # packed npm tarball with real piped stdin, on Linux and Windows with Node 20. The full matrix gates the release. + acceptance-quick: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-22.04, platform: Linux } + - { os: windows, platform: Windows } + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Bun (Linux) + if: ${{ matrix.platform == 'Linux' }} + shell: bash + run: | + curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" + - name: Acceptance quick (Linux, Node 20) + if: ${{ matrix.platform == 'Linux' }} + shell: bash + run: bun tools/acceptance.ts run --runtime node --platform Linux --quick --jobs 4 --out acceptance/Linux-node20-quick.json + - name: Acceptance quick (Windows, Node 20) + if: ${{ matrix.platform == 'Windows' }} + shell: pwsh + run: | + bun tools/acceptance.ts run --runtime node --platform Windows --quick --jobs 3 --out acceptance/Windows-node20-quick.json + if ($LASTEXITCODE -ne 0) { throw "acceptance quick: red cells" } + - uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: acceptance-quick-${{ matrix.platform }} + path: acceptance/ + retention-days: 14 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 18d2e05..d5549d2 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -189,6 +189,110 @@ jobs: retention-days: 14 + # The release acceptance matrix (tools/acceptance.ts, docs/acceptance.md): every user-facing command from the packed + # npm tarball, in fresh temp folders with piped stdin and the fake stores, on Linux and Windows x Node 20 / Node 22 + # (npx --package=) / Bun (bun x). One red cell and there is no release. macOS runs on the owner's Mac + # (bun tools/acceptance.ts run ...) before the DMG upload; its result files live in docs/acceptance/. + acceptance: + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-22.04, platform: Linux, runtime: node, node: '20', name: node20 } + - { os: ubuntu-22.04, platform: Linux, runtime: node, node: '22', name: node22 } + - { os: ubuntu-22.04, platform: Linux, runtime: bun, node: '22', name: bun } + - { os: windows, platform: Windows, runtime: node, node: '20', name: node20 } + - { os: windows, platform: Windows, runtime: node, node: '22', name: node22 } + - { os: windows, platform: Windows, runtime: bun, node: '22', name: bun } + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - name: Bun (Linux) + if: ${{ matrix.platform == 'Linux' }} + shell: bash + run: | + curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" + - name: Acceptance matrix (Linux) + if: ${{ matrix.platform == 'Linux' }} + shell: bash + run: | + node --version && bun --version + bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Linux --jobs 4 --out acceptance/Linux-${{ matrix.name }}.json + - name: Acceptance matrix (Windows) + if: ${{ matrix.platform == 'Windows' }} + shell: pwsh + run: | + node --version; bun --version + bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Windows --jobs 3 --out acceptance/Windows-${{ matrix.name }}.json + if ($LASTEXITCODE -ne 0) { throw "acceptance matrix: red cells" } + - uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: acceptance-${{ matrix.platform }}-${{ matrix.name }} + path: acceptance/ + retention-days: 30 + + acceptance-report: + needs: [prepare, acceptance] + if: ${{ always() && needs.prepare.result == 'success' }} + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Linux-node20 + path: acceptance-runs/Linux-node20 + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Linux-node22 + path: acceptance-runs/Linux-node22 + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Linux-bun + path: acceptance-runs/Linux-bun + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Windows-node20 + path: acceptance-runs/Windows-node20 + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Windows-node22 + path: acceptance-runs/Windows-node22 + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Windows-bun + path: acceptance-runs/Windows-bun + - name: Matrix (markdown), red cells fail the job + run: | + curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + export PATH="$HOME/.bun/bin:$PATH" + V="${{ needs.prepare.outputs.version }}" + mkdir -p out + bun tools/acceptance.ts report acceptance-runs docs/acceptance --md "out/storebound_${V}_acceptance.md" --expect 7 --strict + - uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: storebound-acceptance + path: out/ + retention-days: 30 + windows: needs: prepare # a tag push always builds Windows; a manual run unless "windows" is unticked @@ -243,9 +347,9 @@ jobs: retention-days: 14 release: - needs: [prepare, ci, linux, windows] - # a real release needs every platform; a dry run may skip Windows - if: ${{ always() && needs.prepare.result == 'success' && needs.ci.result == 'success' && needs.linux.result == 'success' && (needs.windows.result == 'success' || (needs.windows.result == 'skipped' && needs.prepare.outputs.dry == 'true')) }} + needs: [prepare, ci, acceptance, acceptance-report, linux, windows] + # a real release needs every platform and a green acceptance matrix; a dry run may skip Windows + if: ${{ always() && needs.prepare.result == 'success' && needs.ci.result == 'success' && needs.acceptance.result == 'success' && needs.acceptance-report.result == 'success' && needs.linux.result == 'success' && (needs.windows.result == 'success' || (needs.windows.result == 'skipped' && needs.prepare.outputs.dry == 'true')) }} runs-on: ubuntu-22.04 timeout-minutes: 30 env: @@ -277,6 +381,11 @@ jobs: with: name: storebound-desktop-windows path: dist/windows + # the acceptance matrix goes with the release (storebound__acceptance.md) + - uses: actions/download-artifact@v3 + with: + name: storebound-acceptance + path: dist/acceptance - name: Collect, privacy scan (private terms), checksums env: FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }} diff --git a/cli/api.ts b/cli/api.ts index e6cca83..543ea34 100644 --- a/cli/api.ts +++ b/cli/api.ts @@ -19,6 +19,7 @@ import { PLAY_APIS } from './api/play.gen'; import { WRAPPED } from './api/wrapped.gen'; import type { ParamValue } from './api/types'; import * as rt from './lib/rt'; +import { stdinIsTTY } from './lib/tty'; const STORES: Store[] = ['apple', 'play']; @@ -140,7 +141,7 @@ export async function run(args: Args): Promise { const kind = gateOf(st, op.id, op.method); const decision = decide({ kind, method: op.method, dry: args.has('dry'), yes: args.has('yes'), getOnly, - tty: !!process.stdin.isTTY && !!process.stdout.isTTY, + tty: stdinIsTTY() && !!process.stdout.isTTY, agent: process.env.STOREBOUND_SERVE === '1' || process.env.STOREBOUND_MCP === '1', }); if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`); diff --git a/cli/apple/appinfo.ts b/cli/apple/appinfo.ts index c58d2f0..510c752 100644 --- a/cli/apple/appinfo.ts +++ b/cli/apple/appinfo.ts @@ -15,6 +15,7 @@ import { ascClient } from './asc'; export { AGE_RATING_NONE } from '../lib/store-catalogs'; import { AGE_RATING_NONE } from '../lib/store-catalogs'; +import { stdinIsTTY } from '../lib/tty'; const CATEGORY_RELS = ['primaryCategory', 'primarySubcategoryOne', 'primarySubcategoryTwo', 'secondaryCategory'] as const; const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); @@ -75,7 +76,7 @@ export async function run(ctx: Ctx): Promise { else for (const line of appInfoPlanLines(p)) console.log(line); if (dry) { done('apple', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; } if (!p.changes) { done('apple', 'appinfo already up to date (App Privacy is UI-only: store-declarations skill)'); return; } - if (process.stdin.isTTY && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; } + if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; } const changed = (key: string) => p.items.some(i => i.key === key && i.action !== 'same'); if (info.categories && changed('categories')) { diff --git a/cli/apple/subscriptions.ts b/cli/apple/subscriptions.ts index 81c8d29..0a8f024 100644 --- a/cli/apple/subscriptions.ts +++ b/cli/apple/subscriptions.ts @@ -30,6 +30,7 @@ import { import { pages } from '../prices'; import { createOnce, findOrCreate, getById } from '../lib/upsert'; import { type Asc, ascClient, md5, type J } from './asc'; +import { stdinIsTTY } from '../lib/tty'; const TAG = 'apple'; @@ -84,7 +85,7 @@ export async function run(ctx: Ctx): Promise { const yes = ctx.args.has('yes'); // price changes of existing subscriptions: known per subscription, decided at the first one (lib/price-gate.ts) let liveOk: boolean | undefined; - if (process.stdin.isTTY && !yes && process.env.STOREBOUND_SERVE !== '1') { + if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') { if (!(await confirm(ctx, 'Create / update these subscriptions on App Store Connect? Nothing is public before App Review, but price changes of existing subscriptions apply to new subscribers right away.'))) return; liveOk = true; } else if (!yes) log(TAG, 'not a terminal: creating what is new; price changes of existing subscriptions wait for the owner'); @@ -220,7 +221,7 @@ export async function run(ctx: Ctx): Promise { * the code can redeem it). Existing codes (same customCode) are left alone. */ async function customCodes(c: Asc, ctx: Ctx, subId: string, s: Subscription, oc: OfferCodeDef, ocId: string | undefined): Promise { if (!ctx.args.has('codes')) { log(TAG, ` ${oc.customCodes!.length} custom code(s) of "${oc.name}" not created (pass --codes from a terminal)`); return; } - if (!process.stdin.isTTY) { warn(TAG, ` custom codes of "${oc.name}" need a terminal confirmation`); return; } + if (!stdinIsTTY()) { warn(TAG, ` custom codes of "${oc.name}" need a terminal confirmation`); return; } const id = ocId ?? (await c.all(`/v1/subscriptions/${subId}/offerCodes?limit=200`)).find((x: J) => x.attributes.name === oc.name)?.id; if (!id) throw new Error(`${s.id}: offer code campaign "${oc.name}" not found`); const have = new Set((await c.all(`/v1/subscriptionOfferCodes/${id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode)); diff --git a/cli/desktop.ts b/cli/desktop.ts index 11be003..9cda1b6 100644 --- a/cli/desktop.ts +++ b/cli/desktop.ts @@ -26,6 +26,7 @@ import { verify } from './lib/minisign'; import { parsePlist } from './lib/plist'; import { addFolder } from './lib/registry'; import * as rt from './lib/rt'; +import { stdinIsTTY } from './lib/tty'; type Fetch = (url: string) => Promise; @@ -209,7 +210,7 @@ export async function run(args: Args): Promise { try { const r = rt.spawnSync(chk, { timeout: 10_000 }); running = isRunning(platform, r.exitCode, r.stdout.toString()); } catch { /* no pgrep / tasklist: go on */ } if (running) throw new Error(`storebound desktop ${decision.from} is running: quit it first, then run this again (or update it from the app: Settings > Updates)`); } - const how = consent(args.has('yes'), !!process.stdin.isTTY); + const how = consent(args.has('yes'), stdinIsTTY()); if (how !== 'yes') { if (how === 'declined') { const again = ['storebound', 'desktop', ...process.argv.slice(3).filter(a => a !== '--yes'), '--yes'].join(' '); diff --git a/cli/index.ts b/cli/index.ts index a128ef7..892b478 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -45,6 +45,8 @@ const groupUsage = (g: string, keys: string[]): string => async function main(): Promise { // tests (tests/cli-help.test.ts) run the CLI with STOREBOUND_NO_NETWORK=1: any request fails loudly if (process.env.STOREBOUND_NO_NETWORK === '1') globalThis.fetch = (async (u: unknown) => { throw new Error(`network disabled (STOREBOUND_NO_NETWORK): ${String(u)}`); }) as unknown as typeof fetch; + // the release acceptance matrix: store hosts answered by a local double (loopback only, cli/lib/store-mock-env.ts) + if (process.env.STOREBOUND_STORE_MOCK) (await import('./lib/store-mock-env')).installStoreMock(); // tools (adb, emulator, ffmpeg…) are also looked up where they usually live, for apps started without a shell PATH process.env.PATH = toolPath(process.env); const argv = process.argv.slice(2); diff --git a/cli/lib/ctx.ts b/cli/lib/ctx.ts index 979a217..daabbd0 100644 --- a/cli/lib/ctx.ts +++ b/cli/lib/ctx.ts @@ -2,6 +2,7 @@ import type { Args } from './args'; import type { Language, Loaded } from './config'; import { playCodes } from './config'; import * as rt from './rt'; +import { stdinIsTTY } from './tty'; export interface Ctx { loaded: Loaded; args: Args } @@ -38,7 +39,7 @@ export async function confirm(ctx: Ctx, question: string): Promise { return declined('confirm-required: terminal only'); } if (ctx.args.has('yes')) return true; - if (!process.stdin.isTTY) { + if (!stdinIsTTY()) { console.log(`${question}\n not a terminal: re-run with --yes to confirm`); return declined('not a terminal and no --yes (re-run with --yes to confirm)'); } diff --git a/cli/lib/desktop-dist.ts b/cli/lib/desktop-dist.ts index 29570e1..20f252e 100644 --- a/cli/lib/desktop-dist.ts +++ b/cli/lib/desktop-dist.ts @@ -98,6 +98,8 @@ export interface Installed { path: string; how: 'bundle' | 'exe' | 'appimage' | export function findInstalled(platform: NodeJS.Platform, env: Record = process.env, exists: (p: string) => boolean = existsSync, home = homedir(), list: (dir: string) => string[] = d => { try { return readdirSync(d); } catch { return []; } }): Installed | null { + // the release acceptance matrix drives the install prompt on a machine that has the app: act as if it had none + if (env.STOREBOUND_DESKTOP_APP === 'none') return null; if (platform === 'darwin') { for (const dir of ['/Applications', join(home, 'Applications')]) { const app = join(dir, 'storebound.app'); diff --git a/cli/lib/price-gate.ts b/cli/lib/price-gate.ts index d82e2e4..f070948 100644 --- a/cli/lib/price-gate.ts +++ b/cli/lib/price-gate.ts @@ -2,6 +2,7 @@ // play subscriptions, apple subscriptions and prices import (apple iap never changes the prices of an existing product). // Imported only by those commands, so --allow-price-changes is a flag of theirs alone (tools/gen-flags.ts). import { confirm, type Ctx, declined } from './ctx'; +import { stdinIsTTY } from './tty'; /** The flag that lets a write command change the price of an existing product. `flow run` never adds it and refuses it in steps. */ export const ALLOW_PRICE_CHANGES = 'allow-price-changes'; @@ -18,7 +19,7 @@ export interface HeldPrice { id: string; changes: { region: string; from: string export async function allowPriceChanges(ctx: Ctx, count: number, question: string): Promise { if (!count || ctx.args.has('allow-price-changes')) return true; const yes = ctx.args.has('yes'); - if (process.env.STOREBOUND_SERVE === '1' || (process.stdin.isTTY && !yes)) return confirm(withoutYes(ctx), question); + if (process.env.STOREBOUND_SERVE === '1' || (stdinIsTTY() && !yes)) return confirm(withoutYes(ctx), question); return false; } diff --git a/cli/lib/store-mock-env.ts b/cli/lib/store-mock-env.ts new file mode 100644 index 0000000..ff1c4cb --- /dev/null +++ b/cli/lib/store-mock-env.ts @@ -0,0 +1,32 @@ +// STOREBOUND_STORE_MOCK=http://127.0.0.1:: every request to a store host (App Store Connect, Google APIs, the +// public store pages, the example.com placeholders) goes to that local double instead, as +// /__host//. The release acceptance matrix (tools/acceptance.ts) runs the packed npm tarball this +// way against the fake stores of tests/helpers/fake-stores.ts, on every platform and runtime. Only a loopback address +// (with an optional path: the matrix gives every cell its own store) is accepted, so the variable can never send store keys or tokens to another machine. Unset: nothing changes. + +const STORE_HOST = /^(?:[a-z0-9-]+\.)*(?:apple\.com|googleapis\.com|google\.com|example\.com)$/i; +const LOOPBACK = /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d{1,5}(?:\/[\w.-]+)*$/; + +/** The URL a request goes to under the mock (unchanged for hosts that are not store hosts). */ +export function mockedUrl(url: string, base: string): string { + let u: URL; + try { u = new URL(url); } catch { return url; } + if (u.protocol !== 'https:' || !STORE_HOST.test(u.hostname)) return url; + return `${base}/__host/${u.hostname}${u.pathname}${u.search}`; +} + +/** Installs the redirect when STOREBOUND_STORE_MOCK is set; throws on a non-loopback value. */ +export function installStoreMock(env: Record = process.env): boolean { + const base = env.STOREBOUND_STORE_MOCK?.replace(/\/+$/, ''); + if (!base) return false; + if (!LOOPBACK.test(base)) throw new Error(`STOREBOUND_STORE_MOCK must be a loopback address like http://127.0.0.1:8080 (got ${JSON.stringify(base)})`); + const real = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + if (input instanceof Request) { + const to = mockedUrl(input.url, base); + return real(to === input.url ? input : new Request(to, input), init); + } + return real(mockedUrl(String(input), base), init); + }) as typeof fetch; + return true; +} diff --git a/cli/lib/tty.ts b/cli/lib/tty.ts new file mode 100644 index 0000000..ba27ed1 --- /dev/null +++ b/cli/lib/tty.ts @@ -0,0 +1,5 @@ +// Whether a prompt can ask on stdin. STOREBOUND_ASSUME_TTY=1 treats piped stdin as the terminal, so the release +// acceptance matrix (tools/acceptance.ts) drives the real [y/N] and [Y/n] prompts with piped answers ("n\n", EOF) on +// every platform and runtime: the same reading code a person's terminal goes through. +export const stdinIsTTY = (env: Record = process.env): boolean => + env.STOREBOUND_ASSUME_TTY === '1' || !!process.stdin.isTTY; diff --git a/cli/listing-store.ts b/cli/listing-store.ts index fd2f754..784e6c5 100644 --- a/cli/listing-store.ts +++ b/cli/listing-store.ts @@ -20,6 +20,7 @@ import { confirm, need } from './lib/ctx'; import { done, log, printJson, warn } from './lib/log'; import { ascClient, EDITABLE, type J } from './apple/asc'; import { playClient } from './play/client'; +import { stdinIsTTY } from './lib/tty'; const TAG = 'listing'; @@ -143,7 +144,7 @@ async function set(ctx: Ctx): Promise { if (a.has('json')) printJson({ ...p, dry, written: false }); if (errors) throw new Error(`nothing sent: ${errors} error(s)`); if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on Google Play'); return; } - if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on Google Play (one edit, committed)?`))) { warn(TAG, 'nothing sent'); return; } + if (stdinIsTTY() && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on Google Play (one edit, committed)?`))) { warn(TAG, 'nothing sent'); return; } const { root } = ctx.loaded; const play = await playClient(root, need(config.play?.packageName, 'play.packageName')); const body: Record = { language: lang }; @@ -173,7 +174,7 @@ async function set(ctx: Ctx): Promise { if (p.refused) throw new Error(p.refused); if (errors) throw new Error(`nothing sent: ${errors} error(s)`); if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on the App Store'); return; } - if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on App Store Connect?`))) { warn(TAG, 'nothing sent'); return; } + if (stdinIsTTY() && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on App Store Connect?`))) { warn(TAG, 'nothing sent'); return; } const info = Object.fromEntries(p.changes.filter(c => (APPLE_INFO_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to])); const ver = Object.fromEntries(p.changes.filter(c => (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to])); if (Object.keys(info).length) { diff --git a/cli/play/listing.ts b/cli/play/listing.ts index 3e7645b..521e283 100644 --- a/cli/play/listing.ts +++ b/cli/play/listing.ts @@ -19,6 +19,7 @@ import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, step, warn } from '../lib/log'; import { playClient } from './client'; +import { stdinIsTTY } from '../lib/tty'; export function readPlayCopy(ctx: Ctx) { const { config, resolve } = ctx.loaded; @@ -139,7 +140,7 @@ export async function run(ctx: Ctx): Promise { else for (const line of playPlanLines(p)) console.log(line); if (dry) { done('play', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; } if (!p.changes) { done('play', 'listing already up to date'); return; } - if (process.stdin.isTTY && !(await confirm(ctx, `Apply these ${p.changes} change(s) to Google Play (one edit, committed)?`))) { warn('play', 'nothing written'); return; } + if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to Google Play (one edit, committed)?`))) { warn('play', 'nothing written'); return; } // The edit compares again with what it holds (a Console save or another run since the plan): only differing details // and languages are PUT; nothing left to change = the edit is deleted, not committed. Commit only after every PUT worked. let sent = 0; diff --git a/cli/play/subscriptions.ts b/cli/play/subscriptions.ts index e25dbd8..0ef8a24 100644 --- a/cli/play/subscriptions.ts +++ b/cli/play/subscriptions.ts @@ -26,6 +26,7 @@ import { import { subscriptionCsv } from '../apple/subscriptions'; import { createOnce } from '../lib/upsert'; import { playClient, type PlayClient } from './client'; +import { stdinIsTTY } from '../lib/tty'; /** GET of one Play resource, null on 404. */ export async function getOrNull(play: PlayClient, url: string): Promise { @@ -88,7 +89,7 @@ export async function run(ctx: Ctx): Promise { const yes = ctx.args.has('yes'); const priced = todo.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length); let liveOk: boolean; - if (process.stdin.isTTY && !yes && process.env.STOREBOUND_SERVE !== '1') { + if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') { if (!(await confirm(ctx, `Write ${todo.length} subscription(s) to Google Play? Activated base plans and offers can be bought as soon as the app is published; price changes apply to new subscribers.`))) return; liveOk = true; } else { diff --git a/cli/prices.ts b/cli/prices.ts index 09f6b31..90e465d 100644 --- a/cli/prices.ts +++ b/cli/prices.ts @@ -30,6 +30,7 @@ import { } from './lib/prices'; import { playClient, type PlayClient } from './play/client'; import { allSubscriptions, appleSubPriceRows, basePlanId, type PlaySubscription, playSubPriceRows } from './lib/subscriptions'; +import { stdinIsTTY } from './lib/tty'; const TAG = 'prices'; @@ -154,7 +155,7 @@ export async function run(ctx: Ctx): Promise { if (live.length) log(TAG, `owner: price change of ${live.length} existing product / store pair(s) (--allow-price-changes): ${live.map(k => k.replace('|', ' ')).join(', ')}`); if (dry) { done(TAG, 'dry run: nothing written'); return; } let liveOk: boolean; - if (process.stdin.isTTY && !a.has('yes') && process.env.STOREBOUND_SERVE !== '1') { + if (stdinIsTTY() && !a.has('yes') && process.env.STOREBOUND_SERVE !== '1') { if (!(await confirm(ctx, `Write the new prices for ${changed.size} product / store pair(s)? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`))) return; liveOk = true; } else { diff --git a/tests/acceptance.test.ts b/tests/acceptance.test.ts new file mode 100644 index 0000000..3964f65 --- /dev/null +++ b/tests/acceptance.test.ts @@ -0,0 +1,100 @@ +// 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.**'); + }); +}); diff --git a/tests/fixtures/scan/expo/README.md b/tests/fixtures/scan/expo/README.md new file mode 100644 index 0000000..e310c14 --- /dev/null +++ b/tests/fixtures/scan/expo/README.md @@ -0,0 +1,2 @@ +Scan fixture: a managed Expo app (app.json, no native folders), used by the release acceptance matrix +(tools/acceptance.ts) and tests/scan.test.ts. Placeholder ids only. diff --git a/tests/fixtures/scan/expo/app.json b/tests/fixtures/scan/expo/app.json new file mode 100644 index 0000000..578b203 --- /dev/null +++ b/tests/fixtures/scan/expo/app.json @@ -0,0 +1,9 @@ +{ + "expo": { + "name": "Example Expo", + "slug": "example-expo", + "version": "1.0.0", + "ios": { "bundleIdentifier": "com.example.expo" }, + "android": { "package": "com.example.expo" } + } +} diff --git a/tests/fixtures/scan/expo/package.json b/tests/fixtures/scan/expo/package.json new file mode 100644 index 0000000..b8f11f8 --- /dev/null +++ b/tests/fixtures/scan/expo/package.json @@ -0,0 +1,12 @@ +{ + "name": "example-expo", + "version": "1.0.0", + "private": true, + "main": "expo-router/entry", + "dependencies": { + "expo": "54.0.0", + "expo-location": "19.0.0", + "react": "19.1.0", + "react-native": "0.81.0" + } +} diff --git a/tests/fixtures/scan/react-native/README.md b/tests/fixtures/scan/react-native/README.md new file mode 100644 index 0000000..9e6d8bd --- /dev/null +++ b/tests/fixtures/scan/react-native/README.md @@ -0,0 +1,2 @@ +Scan fixture: a bare React Native app (ios/ and android/ folders, Info.plist and manifest), used by the release +acceptance matrix (tools/acceptance.ts) and tests/scan.test.ts. Placeholder ids only. diff --git a/tests/fixtures/scan/react-native/android/app/src/main/AndroidManifest.xml b/tests/fixtures/scan/react-native/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7143bb5 --- /dev/null +++ b/tests/fixtures/scan/react-native/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/fixtures/scan/react-native/ios/Notes/Info.plist b/tests/fixtures/scan/react-native/ios/Notes/Info.plist new file mode 100644 index 0000000..dfea0af --- /dev/null +++ b/tests/fixtures/scan/react-native/ios/Notes/Info.plist @@ -0,0 +1,12 @@ + + + + + CFBundleIdentifier + com.example.rn + CFBundleShortVersionString + 1.0.0 + NSCameraUsageDescription + Scan documents into your notes. + + diff --git a/tests/fixtures/scan/react-native/package.json b/tests/fixtures/scan/react-native/package.json new file mode 100644 index 0000000..d70004d --- /dev/null +++ b/tests/fixtures/scan/react-native/package.json @@ -0,0 +1,10 @@ +{ + "name": "example-rn", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "19.1.0", + "react-native": "0.81.0", + "react-native-vision-camera": "4.7.0" + } +} diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index 29484db..9cb3e6d 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -70,6 +70,7 @@ const LISTS: Record = { 'apps/promotedPurchases': { type: 'promotedPurchases' }, 'subscriptions/winBackOffers': { type: 'winBackOffers' }, 'betaGroups/betaTesters': { type: 'betaTesters' }, + 'apps/analyticsReportRequests': { type: 'analyticsReportRequests' }, }; /** Stores refuse a second resource with the same key (409 ENTITY_ERROR ... already exists). */ const UNIQUE: Record string> = { diff --git a/tools/acceptance.ts b/tools/acceptance.ts new file mode 100644 index 0000000..0abb5ee --- /dev/null +++ b/tools/acceptance.ts @@ -0,0 +1,556 @@ +#!/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 ] [--tgz ] [--quick] +// [--platform