apple submit --build <n> (first submission, the resubmit steps; plan table with --json under serve); desktop owner actions to send for review: owner.apple_submit, owner.apple_resubmit, owner.apple_iap_submit, owner.play_production, and the price gate owner actions (owner.prices_play_products / _play_subscriptions / _apple_subscriptions; owner.prices_import passes --allow-price-changes after the dialog); OWNER_ALLOWED_ARGS ties each forbidden flag to its one action, dry runs never carry owner flags, MCP still has no submit or production release; submit dialog text in the host; system.tools androidSdk, project.inspect playRelease, declarations dataDeletion

This commit is contained in:
CRTkafa 2026-10-12 12:07:55 +08:00
parent 2e7dd44ae6
commit 33eb24a3dc
17 changed files with 335 additions and 49 deletions

View file

@ -12,7 +12,7 @@ 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 { 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>;
@ -45,6 +45,12 @@ const D3_WRITES: [string, J][] = [
['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', {}],
];
describe('D3 writes: token binding for every method the window offers', () => {
@ -87,19 +93,28 @@ describe('D3 writes: token binding for every method the window offers', () => {
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']);
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 t of OWNER_TOOLS) for (const s of samples) for (const dry of [true, false]) {
let argv: string[];
try { argv = toArgv(t, s).argv; } catch { continue; }
for (const a of argv) expect(OWNER_FORBIDDEN_ARGS).not.toContain(a);
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)!;
@ -124,10 +139,52 @@ describe('owner actions', () => {
});
});
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)!;
expect(toArgv(t('owner_prices_import'), { csv: '/tmp/p.csv', dry_run: false }).argv).toEqual(['prices', 'import', '--csv', '/tmp/p.csv']);
// 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();