release acceptance matrix: tools/acceptance.ts runs every user-facing command from the packed npm tarball (npx --package=<tgz> on Node 20 / 22, bun x --bun after bun add <tgz>) 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
This commit is contained in:
parent
ae681e3ecd
commit
f58e192001
26 changed files with 926 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=<tgz>) / 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_<version>_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 }}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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}`);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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')) {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
* 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<void> {
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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<Response>;
|
||||
|
||||
|
|
@ -209,7 +210,7 @@ export async function run(args: Args): Promise<void> {
|
|||
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(' ');
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ const groupUsage = (g: string, keys: string[]): string =>
|
|||
async function main(): Promise<void> {
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
|||
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)');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ export interface Installed { path: string; how: 'bundle' | 'exe' | 'appimage' |
|
|||
export function findInstalled(platform: NodeJS.Platform, env: Record<string, string | undefined> = 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');
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
32
cli/lib/store-mock-env.ts
Normal file
32
cli/lib/store-mock-env.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// STOREBOUND_STORE_MOCK=http://127.0.0.1:<port>: 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
|
||||
// <mock>/__host/<host>/<path>. 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<string, string | undefined> = 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;
|
||||
}
|
||||
5
cli/lib/tty.ts
Normal file
5
cli/lib/tty.ts
Normal file
|
|
@ -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<string, string | undefined> = process.env): boolean =>
|
||||
env.STOREBOUND_ASSUME_TTY === '1' || !!process.stdin.isTTY;
|
||||
|
|
@ -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<void> {
|
|||
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<string, string> = { language: lang };
|
||||
|
|
@ -173,7 +174,7 @@ async function set(ctx: Ctx): Promise<void> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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<T>(play: PlayClient, url: string): Promise<T | null> {
|
||||
|
|
@ -88,7 +89,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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 {
|
||||
|
|
|
|||
100
tests/acceptance.test.ts
Normal file
100
tests/acceptance.test.ts
Normal file
|
|
@ -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.**');
|
||||
});
|
||||
});
|
||||
2
tests/fixtures/scan/expo/README.md
vendored
Normal file
2
tests/fixtures/scan/expo/README.md
vendored
Normal file
|
|
@ -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.
|
||||
9
tests/fixtures/scan/expo/app.json
vendored
Normal file
9
tests/fixtures/scan/expo/app.json
vendored
Normal file
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
12
tests/fixtures/scan/expo/package.json
vendored
Normal file
12
tests/fixtures/scan/expo/package.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
2
tests/fixtures/scan/react-native/README.md
vendored
Normal file
2
tests/fixtures/scan/react-native/README.md
vendored
Normal file
|
|
@ -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.
|
||||
5
tests/fixtures/scan/react-native/android/app/src/main/AndroidManifest.xml
vendored
Normal file
5
tests/fixtures/scan/react-native/android/app/src/main/AndroidManifest.xml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<application android:label="Example RN" />
|
||||
</manifest>
|
||||
12
tests/fixtures/scan/react-native/ios/Notes/Info.plist
vendored
Normal file
12
tests/fixtures/scan/react-native/ios/Notes/Info.plist
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.example.rn</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scan documents into your notes.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
10
tests/fixtures/scan/react-native/package.json
vendored
Normal file
10
tests/fixtures/scan/react-native/package.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ const LISTS: Record<string, { type: string; one?: boolean }> = {
|
|||
'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, (r: R) => string> = {
|
||||
|
|
|
|||
556
tools/acceptance.ts
Normal file
556
tools/acceptance.ts
Normal file
|
|
@ -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 <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) results.push(await runCell(queue.splice(first, 1)[0]!, rt, mock.base, tpl, home, platform, running));
|
||||
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`));
|
||||
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); }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue