storebound/tests/desktop-d3.test.ts

261 lines
21 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_ALLOWED_ARGS, OWNER_FORBIDDEN_ARGS, OWNER_REAL_ONLY, OWNER_SUBMIT, 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', {}],
// submit for review, resubmit, IAPs, the Play production release, price changes of existing products (owner actions)
['owner.apple_submit', { build: '52' }], ['owner.apple_submit', { build: '52', version: '2.4.0', resolve_iaps: true }], ['owner.apple_resubmit', { build: '53' }],
['owner.apple_resubmit', { build: '53', resolve_iaps: true }], ['owner.apple_iap_submit', { with_version: true }], ['owner.apple_iap_submit', { only: 'coins_100' }],
['owner.play_production', { version_code: '24' }], ['owner.play_production', { version_code: '24', status: 'inProgress', percent: 10 }], ['owner.play_production', { version_code: '24', status: 'draft' }],
['owner.prices_play_products', {}], ['owner.prices_play_subscriptions', {}], ['owner.prices_apple_subscriptions', { only: 'pro_monthly' }],
['apple.testflight_groups', {}],
// the editors: save into the app folder (local files), then the store writes they apply
['project.edit', { ops: JSON.stringify([{ kind: 'listing', store: 'apple', lang: 'en-US', field: 'subtitle', value: 'Lists that stay tidy' }]) }],
['apple.testflight_info', {}], ['apple.iap_review', {}], ['apple.cpp_create', {}], ['apple.events_create', {}], ['play.listing_pull', {}], ['apple.listing_pull', {}],
];
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.apple_iap_submit', 'owner.apple_resubmit', 'owner.apple_submit', 'owner.ios_phased', 'owner.play_production',
'owner.play_rollout', 'owner.ppo_start', 'owner.ppo_stop', 'owner.prices_apple_subscriptions', 'owner.prices_import', 'owner.prices_play_products', 'owner.prices_play_subscriptions',
'owner.reply_send', 'owner.testflight_testers']);
for (const n of OWNER_SUBMIT) expect(OWNER_TOOLS.some(t => t.name === n)).toBe(true);
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' },
{ build: '7' }, { build: '7', resolve_iaps: true }, { with_version: true }, { version_code: '9', status: 'inProgress', percent: 5 }, { only: 'coins_100' },
];
for (const t of OWNER_TOOLS) for (const s of samples) for (const dry of [true, false]) {
let argv: string[];
try { argv = toArgv(t, { ...s, dry_run: dry }).argv; } catch { continue; }
// a forbidden flag only in the one owner action it belongs to, and the owner-only ones never in a dry run
const allowed = OWNER_ALLOWED_ARGS[t.name] ?? [];
for (const a of argv) if (!allowed.includes(a)) expect(OWNER_FORBIDDEN_ARGS).not.toContain(a);
if (dry) for (const a of OWNER_REAL_ONLY) expect(argv).not.toContain(a);
}
// every allowed flag belongs to exactly the actions named, and the submit ones are OWNER_SUBMIT
expect(Object.keys(OWNER_ALLOWED_ARGS).filter(n => OWNER_ALLOWED_ARGS[n]!.some(a => ['--yes', 'submit', '--production'].includes(a))).sort()).toEqual(['owner_apple_resubmit', 'owner_apple_submit', 'owner_play_production']);
});
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('submit for review from the desktop: owner actions only, build by number', () => {
const t = (name: string) => OWNER_TOOLS.find(x => x.name === name)!;
test('argv: dry run first; --yes / --allow-price-changes only in the real run', () => {
expect(toArgv(t('owner_apple_submit'), { build: '52' }).argv).toEqual(['apple', 'submit', '--build', '52', '--dry']);
expect(toArgv(t('owner_apple_submit'), { build: '52', version: '2.4.0', dry_run: false }).argv).toEqual(['apple', 'submit', '--build', '52', '--version', '2.4.0', '--yes']);
expect(toArgv(t('owner_apple_resubmit'), { build: '53', resolve_iaps: true }).argv).toEqual(['apple', 'resubmit', '--build', '53', '--resolve-iaps', '--dry']);
expect(toArgv(t('owner_apple_resubmit'), { build: '53', dry_run: false }).argv).toEqual(['apple', 'resubmit', '--build', '53', '--yes']);
expect(toArgv(t('owner_apple_iap_submit'), { with_version: true, dry_run: false }).argv).toEqual(['apple', 'iap-submit', '--with-version']);
expect(toArgv(t('owner_play_production'), { version_code: '24', status: 'inProgress', percent: 10, dry_run: false }).argv).toEqual(['play', 'release', '--version-code', '24', '--production', 'inProgress', '--fraction', '0.1']);
expect(toArgv(t('owner_play_production'), { version_code: '24' }).argv).toEqual(['play', 'release', '--version-code', '24', '--production', 'completed', '--dry']);
expect(toArgv(t('owner_prices_play_subscriptions'), { dry_run: false }).argv).toEqual(['play', 'subscriptions', '--allow-price-changes']);
expect(toArgv(t('owner_prices_play_products'), { only: 'coins_100', dry_run: false }).argv).toEqual(['play', 'products', '--only', 'coins_100', '--allow-price-changes']);
expect(toArgv(t('owner_prices_apple_subscriptions'), {}).argv).toEqual(['apple', 'subscriptions', '--dry']);
});
test('builds are numbers, never "latest"; bad values never reach a command', async () => {
const s = await session();
for (const [m, args] of [['owner.apple_submit', {}], ['owner.apple_submit', { build: 'latest' }], ['owner.apple_submit', { build: '--yes' }], ['owner.apple_resubmit', { build: '7', version: 'x' }],
['owner.play_production', {}], ['owner.play_production', { version_code: 'latest' }], ['owner.play_production', { version_code: '3', status: 'halted' }], ['owner.play_production', { version_code: '3', name: '--yes' }],
['owner.prices_play_products', { only: '--yes' }], ['owner.prices_apple_subscriptions', { target: 'apple_iap' }], ['owner.apple_iap_submit', { only: 'a,--yes' }]] as [string, J][])
expect(err(await s.call(m, { project: DEMO, args })).code).toBe(ERR.invalidParams);
expect(s.calls.length).toBe(0);
});
test('the real run carries --yes and the owner flag only with the host token; without it, a dry run or refused', async () => {
let now = 2_000_000;
const s = await session(() => now);
const p = { project: DEMO, args: { build: '52' } };
await s.call('owner.apple_submit', p);
expect(s.calls.at(-1)!.argv).toEqual(['apple', 'submit', '--build', '52', '--dry', '--json']);
expect(s.calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBeUndefined();
expect(err(await s.call('owner.apple_submit', { ...p, dryRun: false })).code).toBe(ERR.confirmRequired);
await s.call('owner.apple_submit', { ...p, dryRun: false, confirmToken: mint(SECRET, 'owner.apple_submit', p, { now }) });
expect(s.calls.at(-1)!.argv).toEqual(['apple', 'submit', '--build', '52', '--yes', '--json']);
expect(s.calls.at(-1)!.o.env?.STOREBOUND_OWNER_CONFIRMED).toBe('1');
// a write method is never a way to submit: the MCP-shaped tools have no submit, and the iap plan stays --dry
expect(err(await s.call('apple.iap_review_plan', { project: DEMO, dryRun: false }))).toBeUndefined();
expect(s.calls.at(-1)!.argv).toContain('--dry');
});
});
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)!;
// the price sheet's real run says --allow-price-changes itself (the owner pressed the dialog); its dry run never
expect(toArgv(t('owner_prices_import'), { csv: '/tmp/p.csv', dry_run: false }).argv).toEqual(['prices', 'import', '--csv', '/tmp/p.csv', '--allow-price-changes']);
expect(toArgv(t('owner_prices_import'), { csv: '/tmp/p.csv' }).argv).toEqual(['prices', 'import', '--csv', '/tmp/p.csv', '--dry']);
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: () => [], required: (n: string) => { throw new Error(`--${n} is required`); }, _: [] } };
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);
});
});