storebound/tests/desktop-d3.test.ts
CRT 77aa09235e storebound api: every App Store Connect and Google Play operation
- specs/: Apple's App Store Connect OpenAPI 4.5.1 and the Google discovery docs for androidpublisher v3 and
  playdeveloperreporting v1beta1, pinned with URL, date and SHA-256 (specs/SOURCES.json)
- tools/gen-api.ts: one row per operation (cli/api/apple.gen.ts 1303, cli/api/play.gen.ts 170), a scan of which
  operations the commands already call (cli/api/wrapped.gen.ts) and docs/api-coverage.md; CI checks freshness
- cli/api/client.ts + types.ts: generic URL / parameter building, retries with Retry-After, pagination, types derived
  from the rows; auth reuses ascJwt and the Play service account
- cli/api/gate.ts: GET runs, writes dry unless --yes in a TTY, owner-only operations need the id typed, nothing
  written under MCP or serve; MCP gets the read-only api_get
- storebound api list | coverage | apple|play <op|METHOD path>; skill store-api, README, CHANGELOG, tests
2026-10-11 23:37:09 +08:00

201 lines
15 KiB
TypeScript

// Desktop D3 write actions: every write the window offers is a dry run unless the host's confirm token for exactly
// that method, project and arguments comes with it (single use, short-lived); owner actions (rollout share, phased
// release, sending one approved reply) additionally answer the command's own confirmation, and only with such a token.
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mint } from '../cli/lib/confirm-token';
import { confirm } from '../cli/lib/ctx';
import { ERR } from '../cli/lib/desktop-protocol';
import { parseDraft } from '../cli/lib/reviews';
import { setReplyText } from '../cli/lib/project-files';
import { childEnv, type RunOptions, type Running } from '../cli/lib/runner';
import { TOOLS } from '../cli/mcp-tools';
import { describeMethods, methodName, OWNER_FORBIDDEN_ARGS, OWNER_TOOLS, Session } from '../cli/serve';
import { toArgv } from '../cli/mcp-tools';
type J = Record<string, unknown>;
const DEMO = join(import.meta.dir, '..', 'examples', 'demo-game');
const SECRET_HEX = Array.from({ length: 32 }, (_, i) => (i + 7).toString(16).padStart(2, '0')).join('');
const SECRET = Buffer.from(SECRET_HEX, 'hex');
async function session(now = () => Date.now()) {
const calls: { argv: string[]; o: RunOptions }[] = [];
const run = (argv: string[], o: RunOptions): Running => { calls.push({ argv, o }); return { result: Promise.resolve({ code: 0, out: 'done', cancelled: false }), cancel: () => {} }; };
const s = new Session(() => {}, run, now);
let id = 0;
const call = async (method: string, params: unknown = {}) => (await s.handle({ jsonrpc: '2.0', id: ++id, method, params }))! as J;
await call('session.hello', { secret: SECRET_HEX });
return { calls, call };
}
const err = (r: J) => r.error as { code: number; data?: J };
/** The writes the D3 screens offer, with arguments as the window sends them. */
const D3_WRITES: [string, J][] = [
['play.listing', {}], ['apple.listing', { version: '2.4.0' }], ['play.images', { only: 'en-US' }], ['apple.screenshots', {}], ['apple.previews', {}],
['play.products', {}], ['apple.iap', {}], ['play.subscriptions', {}], ['apple.subscriptions', {}],
['apple.testflight_distribute', { build: '52', groups: 'Studio team' }], ['apple.testflight_what_to_test', { build: '52', text: 'New combo meter' }],
['reviews.reply', { file: 'play-r-p1.md', text: 'Thanks! The fix is in 2.4.0.' }], ['reviews.approve', { file: 'play-r-p1.md' }],
['shots.render', {}], ['shots.video', {}], ['shots.gallery', {}], ['icons', {}],
['owner.play_rollout', { action: 'percent', percent: 50 }], ['owner.play_rollout', { action: 'halt' }], ['owner.ios_phased', { action: 'pause', version: '2.3.0' }],
['owner.ios_phased', { action: 'resume' }], ['owner.reply_send', { file: 'play-r-p1.md' }], ['owner.testflight_testers', { groups: 'Friends and family' }],
// full CLI coverage: price sheet, Data safety, app info, PPO, analytics
['owner.prices_import', {}], ['owner.prices_import', { csv: '/tmp/sheets/prices autumn.csv', store: 'play' }], ['prices.export', {}],
['play.data_safety', {}], ['apple.appinfo', {}], ['apple.review_details', {}], ['apple.encryption', {}],
['apple.ppo_create', { variants: 'b', traffic: 30 }], ['apple.ppo_upload', { variant: 'b', experiment: 'storebound b' }],
['owner.ppo_start', { experiment: 'storebound b' }], ['owner.ppo_stop', {}], ['analytics.snapshot', {}], ['analytics.setup', {}],
];
describe('D3 writes: token binding for every method the window offers', () => {
for (const [method, args] of D3_WRITES) {
test(`${method} ${JSON.stringify(args)}`, async () => {
let now = 1_000_000;
const s = await session(() => now);
const p = { project: DEMO, args };
// dry run by default, and without a token
const dry = await s.call(method, p);
expect(err(dry)).toBeUndefined();
expect(s.calls.at(-1)!.argv).toContain('--dry');
expect(s.calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBeUndefined();
expect(err(await s.call(method, { ...p, dryRun: false })).code).toBe(ERR.confirmRequired);
// a token for other arguments, another method or another project is refused
const other = method === 'play.listing' ? 'play.images' : 'play.listing';
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(SECRET, other, p, { now }) })).code).toBe(ERR.confirmInvalid);
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(SECRET, method, { project: '/elsewhere', args }, { now }) })).code).toBe(ERR.confirmInvalid);
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(SECRET, method, { project: DEMO, args: { ...args, x: 1 } }, { now }) })).code).toBe(ERR.confirmInvalid);
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: mint(Buffer.alloc(32, 1), method, p, { now }) })).code).toBe(ERR.confirmInvalid);
// the right token runs it for real, once
const n = s.calls.length;
const token = mint(SECRET, method, p, { now });
const real = await s.call(method, { ...p, dryRun: false, confirmToken: token, credentials: { PLAY_SERVICE_ACCOUNT: '{}' } });
expect(err(real)).toBeUndefined();
expect((real.result as J).dryRun).toBe(false);
const c = s.calls.at(-1)!;
expect(s.calls.length).toBe(n + 1);
expect(c.argv).not.toContain('--dry');
expect(c.o.env?.PLAY_SERVICE_ACCOUNT).toBe('{}');
expect(c.o.env?.STOREBOUND_OWNER_CONFIRMED).toBe(method.startsWith('owner.') ? '1' : undefined);
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: token })).data).toEqual({ reason: 'replayed' });
// and expires
const late = mint(SECRET, method, p, { now, ttlMs: 1000 });
now += 1000;
expect(err(await s.call(method, { ...p, dryRun: false, confirmToken: late })).data).toEqual({ reason: 'expired' });
});
}
});
describe('owner actions', () => {
test('exactly these, desktop only, kind owner, never a submit / release request / cancel / external TestFlight flag', () => {
expect(OWNER_TOOLS.map(t => methodName(t.name)).sort()).toEqual(['owner.ios_phased', 'owner.play_rollout', 'owner.ppo_start', 'owner.ppo_stop', 'owner.prices_import', 'owner.reply_send', 'owner.testflight_testers']);
for (const t of OWNER_TOOLS) expect(TOOLS.some(x => x.name === t.name)).toBe(false);
const ds = describeMethods().filter(m => m.method.startsWith('owner.'));
for (const d of ds) expect(d.kind).toBe('owner');
const samples: J[] = [
{ action: 'percent', percent: 5 }, { action: 'halt' }, { action: 'resume', percent: 10 }, { action: 'complete', track: 'beta' },
{ action: 'on' }, { action: 'pause', version: '1.2.3' }, { action: 'resume' }, { action: 'complete' }, { file: 'apple-123.md' }, {}, { groups: 'Studio team,Friends' },
];
for (const t of OWNER_TOOLS) for (const s of samples) {
let argv: string[];
try { argv = toArgv(t, s).argv; } catch { continue; }
for (const a of argv) expect(OWNER_FORBIDDEN_ARGS).not.toContain(a);
}
});
test('argv: rollout share in percent -> fraction, halt / resume / complete, track; phased actions; one reply', () => {
const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!;
expect(toArgv(t('owner_play_rollout'), { action: 'percent', percent: 20, dry_run: false }).argv).toEqual(['play', 'rollout', '--fraction', '0.2', '--track', 'production']);
expect(toArgv(t('owner_play_rollout'), { action: 'halt' }).argv).toEqual(['play', 'rollout', '--halt', '--track', 'production', '--dry']);
expect(toArgv(t('owner_play_rollout'), { action: 'resume', percent: 50, dry_run: false }).argv).toEqual(['play', 'rollout', '--resume', '--fraction', '0.5', '--track', 'production']);
expect(toArgv(t('owner_play_rollout'), { action: 'complete', percent: 50, track: 'beta', dry_run: false }).argv).toEqual(['play', 'rollout', '--complete', '--track', 'beta']);
expect(toArgv(t('owner_ios_phased'), { action: 'resume', version: '2.3.0', dry_run: false }).argv).toEqual(['apple', 'release', '--phased', 'resume', '--version', '2.3.0']);
expect(toArgv(t('owner_reply_send'), { file: 'play-r1.md', dry_run: false }).argv).toEqual(['reviews', 'send', '--file', 'play-r1.md']);
expect(toArgv(t('owner_testflight_testers'), { groups: 'Studio team', dry_run: false }).argv).toEqual(['apple', 'testflight', 'testers', '--group', 'Studio team']);
expect(toArgv(t('owner_testflight_testers'), {}).argv).toEqual(['apple', 'testflight', 'testers', '--dry']);
});
test('bad arguments never reach a command', async () => {
const s = await session();
for (const [m, args] of [['owner.play_rollout', { action: 'manual' }], ['owner.play_rollout', { action: 'percent', percent: 100 }], ['owner.play_rollout', { action: 'percent', percent: 0.5 }],
['owner.ios_phased', { action: 'release' }], ['owner.ios_phased', { action: 'on', version: '--manual-release' }], ['owner.reply_send', { file: '../../etc/passwd' }],
['owner.reply_send', {}], ['reviews.reply', { file: 'play-1.md', text: '--yes' }],
['owner.prices_import', { csv: '--yes.csv' }], ['owner.prices_import', { csv: 'prices.txt' }], ['owner.prices_import', { only: 'a,--yes' }], ['owner.ppo_stop', { experiment: '--yes' }],
['project.render', { file: '../../etc/passwd.png' }], ['project.render', { file: '/etc/x.png' }], ['project.render', { file: 'en/ios69/01.txt' }], ['reviews.reply', { file: 'play-1.md', text: 'x'.repeat(6001) }]] as [string, J][])
expect(err(await s.call(m, { project: DEMO, args })).code).toBe(ERR.invalidParams);
expect(s.calls.length).toBe(0);
});
});
describe('full CLI coverage: new methods', () => {
test('argv of the price sheet, PPO start / stop and the offline reads', async () => {
const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!;
expect(toArgv(t('owner_prices_import'), { csv: '/tmp/p.csv', dry_run: false }).argv).toEqual(['prices', 'import', '--csv', '/tmp/p.csv']);
expect(toArgv(t('owner_ppo_start'), { experiment: 'storebound b' }).argv).toEqual(['apple', 'ppo', 'start', '--experiment', 'storebound b', '--dry']);
expect(toArgv(t('owner_ppo_stop'), { dry_run: false }).argv).toEqual(['apple', 'ppo', 'stop']);
const s = await session();
for (const [m, args, argv] of [
['project.declarations', {}, ['inspect', '--declarations', '--json']], ['project.listing_text', {}, ['inspect', '--listing-text', '--json']],
['project.renders', { lang: 'en' }, ['shots', 'gallery', '--lang', 'en', '--json']], ['project.render', { file: 'b/en/ios69/01.png' }, ['inspect', '--render', 'b/en/ios69/01.png', '--json']],
['prices.current', { with_automatic: true }, ['prices', 'export', '--with-automatic', '--json']], ['prices.diff', { csv: '/tmp/p.csv' }, ['prices', 'import', '--csv', '/tmp/p.csv', '--dry', '--json']],
] as [string, J, string[]][]) {
expect(err(await s.call(m, { project: DEMO, args }))).toBeUndefined();
expect(s.calls.at(-1)!.argv).toEqual(argv);
}
// the fastlane import runs only on a folder without a config, and never through write_with_confirm's project list
expect(err(await s.call('import.fastlane', { project: DEMO, args: {} })).code).toBe(ERR.invalidParams);
const empty = mkdtempSync(join(tmpdir(), 'sb-fl-'));
expect(err(await s.call('import.fastlane', { project: empty, args: {} }))).toBeUndefined();
expect(s.calls.at(-1)!.argv).toEqual(['import', 'fastlane', '--dry']);
});
});
describe('the owner flag', () => {
const saved = { ...process.env };
afterEach(() => { for (const k of ['STOREBOUND_SERVE', 'STOREBOUND_OWNER_CONFIRMED']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } });
const ctx = { loaded: {} as never, args: { has: (n: string) => n === 'yes', get: () => undefined, str: (_: string, d: string) => d, num: (_: string, d: number) => d, list: () => [], many: () => [], _: [] } };
test('under serve: no by default, even with --yes; yes only with the owner flag', async () => {
process.env.STOREBOUND_SERVE = '1';
delete process.env.STOREBOUND_OWNER_CONFIRMED;
expect(await confirm(ctx, 'Resume?')).toBe(false);
process.env.STOREBOUND_OWNER_CONFIRMED = '1';
expect(await confirm(ctx, 'Resume?')).toBe(true);
});
test('a child never inherits it; it comes only per call', () => {
process.env.STOREBOUND_OWNER_CONFIRMED = '1';
expect(childEnv({}, true).STOREBOUND_OWNER_CONFIRMED).toBeUndefined();
expect(childEnv({ STOREBOUND_OWNER_CONFIRMED: '1' }, true).STOREBOUND_OWNER_CONFIRMED).toBe('1');
});
});
describe('reply drafts: write the reply', () => {
const draft = ['---', 'store: play', 'review_id: r-p1', 'rating: 2', 'approved: true', 'sent: null', '---', '# ★★☆☆☆ Crashes', '', '> Game closes at a 20x combo.', '', '## Reply', '',
'<!-- Write the reply below. -->', '', 'Old text', ''].join('\n');
test('replaces the reply, keeps the review, resets the approval', () => {
const next = setReplyText(draft, ' Sorry! Fixed in 2.4.0.\r\nThanks for the level number. ');
const d = parseDraft('/x/play-r-p1.md', next);
expect(d.reply).toBe('Sorry! Fixed in 2.4.0.\nThanks for the level number.');
expect(d.approved).toBe(false);
expect(next).toContain('> Game closes at a 20x combo.');
expect(next).not.toContain('Old text');
expect(parseDraft('/x', setReplyText(next, 'Again <!-- hidden -->')).reply).toBe('Again');
});
test('sent drafts and other files are not edited', () => {
expect(() => setReplyText(draft.replace('sent: null', 'sent: 2026-10-01T00:00:00Z'), 'x')).toThrow();
expect(() => setReplyText('# not a draft', 'x')).toThrow();
});
test('CLI: reviews reply writes one draft, dry run first', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-reply-'));
const cfg = readFileSync(join(DEMO, 'storebound.config.ts'), 'utf8');
writeFileSync(join(dir, 'storebound.config.ts'), cfg);
const replies = join(dir, '.storebound', 'replies');
await Bun.$`mkdir -p ${replies}`.quiet();
writeFileSync(join(replies, 'play-r-p1.md'), draft);
const cli = join(import.meta.dir, '..', 'cli', 'index.ts');
const dry = await Bun.$`bun ${cli} reviews reply play-r-p1.md --text ${'New reply'} --dry`.cwd(dir).quiet().nothrow();
expect(dry.exitCode).toBe(0);
expect(readFileSync(join(replies, 'play-r-p1.md'), 'utf8')).toBe(draft);
const real = await Bun.$`bun ${cli} reviews reply play-r-p1.md --text ${'New reply'}`.cwd(dir).quiet().nothrow();
expect(real.exitCode).toBe(0);
expect(parseDraft('x', readFileSync(join(replies, 'play-r-p1.md'), 'utf8'))).toMatchObject({ reply: 'New reply', approved: false });
const outside = await Bun.$`bun ${cli} reviews reply ../../x.md --text y`.cwd(dir).quiet().nothrow();
expect(outside.exitCode).not.toBe(0);
});
});