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:
parent
2e7dd44ae6
commit
33eb24a3dc
17 changed files with 335 additions and 49 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { notReadyYet, otherSubmissionOf, submitTiming } from '../cli/apple/submi
|
|||
import { run as appleIapSubmit } from '../cli/apple/iap-submit';
|
||||
import { run as appleResubmit } from '../cli/apple/resubmit';
|
||||
import { run as appleStatus } from '../cli/apple/status';
|
||||
import { run as appleSubmit } from '../cli/apple/submit';
|
||||
import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock';
|
||||
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
|
||||
|
||||
|
|
@ -177,6 +178,62 @@ describe('apple resubmit --build <n>', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('apple submit --build <n> (a first submission, the desktop owner action)', () => {
|
||||
/** Version 1.0.0 in Prepare for Submission without a build, build 8 VALID, the gems IAP ready, no open submission. */
|
||||
function fresh(): void {
|
||||
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'inAppPurchases', id: 'iap-gems', attributes: { productId: 'com.example.notes.gems', inAppPurchaseType: 'CONSUMABLE', state: 'READY_TO_SUBMIT' }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-gems', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' }, rels: { inAppPurchase: 'iap-gems' } });
|
||||
}
|
||||
const ONE: ShipConfig = { ...CONFIG, products: CONFIG.products!.slice(0, 1) };
|
||||
|
||||
test('dry run: plan table (build attach, version + IAP), nothing sent; under serve the table comes with the --json result', async () => {
|
||||
fresh();
|
||||
const p = project(ONE);
|
||||
const a = s.calls.length;
|
||||
const r = await capture(() => appleSubmit(p.ctx(['--build', '8'])));
|
||||
expect(r.exit).toBeFalsy();
|
||||
expect(shown(a)).toEqual([]);
|
||||
expect(r.out).toMatch(/build\s+build 8\s+none attached\s+attach build 8/);
|
||||
expect(r.out).toMatch(/submission\s+new draft\s+-\s+create/);
|
||||
expect(r.out).toMatch(/app version 1\.0\.0/);
|
||||
expect(r.out).toContain('com.example.notes.gems');
|
||||
expect(r.out).toContain('Submitting is the owner');
|
||||
process.env.STOREBOUND_MACHINE = '1';
|
||||
try {
|
||||
const j = await capture(() => appleSubmit(p.ctx(['--build', '8', '--json'])));
|
||||
expect(j.out).toContain('STEP');
|
||||
const line = j.out.split('\n').find(l => l.includes('storebound-result'))!;
|
||||
expect(JSON.parse(line.slice(line.indexOf('{')))).toMatchObject({ dryRun: true, version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' }, build: { action: 'attach', id: 'b-8' }, blockers: [] });
|
||||
} finally { delete process.env.STOREBOUND_MACHINE; }
|
||||
expect(shown(a)).toEqual([]);
|
||||
});
|
||||
|
||||
test('--yes: build attached, one draft with the version and the IAP, submitted; a re-run sends nothing', async () => {
|
||||
fresh();
|
||||
const p = project(ONE);
|
||||
const a = s.calls.length;
|
||||
const r = await capture(() => appleSubmit(p.ctx(['--build', '8', '--yes'])));
|
||||
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
||||
const sid = s.asc.all('reviewSubmissions')[0]!.id;
|
||||
expect(shown(a)).toEqual(['PATCH /v1/appStoreVersions/ver-1/relationships/build', 'POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems', `PATCH /v1/reviewSubmissions/${sid}`]);
|
||||
expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']);
|
||||
const b = s.calls.length;
|
||||
const again = await capture(() => appleSubmit(p.ctx(['--build', '8', '--yes'])));
|
||||
expect(again.exit).toBeFalsy();
|
||||
expect(shown(b)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a live version is not submitted; no editable version is a clear stop', async () => {
|
||||
fresh();
|
||||
s.asc.res.get('ver-1')!.attributes.appVersionState = 'READY_FOR_SALE';
|
||||
const p = project(ONE);
|
||||
await expect(appleSubmit(p.ctx(['--build', '8', '--version', '1.0.0']))).rejects.toThrow(/READY_FOR_SALE: only a version in Prepare for Submission/);
|
||||
await expect(appleSubmit(p.ctx(['--build', '8']))).rejects.toThrow(/no App Store version to submit/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple iap-submit with a submission back from App Review', () => {
|
||||
test('--with-version --yes reuses UNRESOLVED_ISSUES: no new draft, the missing item only, then resubmitted; a re-run writes nothing', async () => {
|
||||
rejected({ ver: 'READY_FOR_REVIEW' }); // the version item was resolved already
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { join } from 'node:path';
|
|||
import { canonical, mac, MAX_TTL_MS, mint, parseSecret, TokenGuard } from '../cli/lib/confirm-token';
|
||||
import { ERR } from '../cli/lib/desktop-protocol';
|
||||
import { childEnv, pickCallEnv, type RunOptions, type Running } from '../cli/lib/runner';
|
||||
import { FORBIDDEN_ARGS, TOOLS } from '../cli/mcp-tools';
|
||||
import { describeMethods, DESKTOP_ONLY, DESKTOP_TOOLS, FORBIDDEN_METHOD_WORDS, JSON_TOOLS, methodName, OWNER_TOOLS, Session } from '../cli/serve';
|
||||
import { FORBIDDEN_ARGS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
|
||||
import { describeMethods, DESKTOP_ONLY, DESKTOP_TOOLS, FORBIDDEN_METHOD_WORDS, JSON_TOOLS, methodName, OWNER_SUBMIT, OWNER_TOOLS, Session } from '../cli/serve';
|
||||
import { buildOne, hostTarget, leaks } from '../tools/build-sidecars';
|
||||
|
||||
type J = Record<string, unknown>;
|
||||
|
|
@ -90,17 +90,28 @@ describe('confirm tokens', () => {
|
|||
});
|
||||
|
||||
describe('serve --stdio: methods', () => {
|
||||
test('nothing submits, releases or publishes', () => {
|
||||
test('only owner actions submit or release; nothing publishes; MCP has none of them', () => {
|
||||
const names = describeMethods().map(m => m.method);
|
||||
// the owner actions (rollout share, phased release, sending one approved reply: tests/desktop-d3.test.ts) are the
|
||||
// only methods with those words, all under "owner.", and none submits, publishes, cancels, deletes or invites
|
||||
// the owner actions (rollout share, phased release, sending one approved reply, submit / resubmit, the Play
|
||||
// production release: tests/desktop-d3.test.ts) are the only methods with those words, all under "owner."
|
||||
const owner = describeMethods().filter(m => m.kind === 'owner').map(m => m.method);
|
||||
expect(owner.sort()).toEqual(OWNER_TOOLS.map(t => methodName(t.name)).sort());
|
||||
for (const n of owner) expect(n.startsWith('owner.')).toBe(true);
|
||||
for (const n of names.filter(x => !owner.includes(x))) expect(n).not.toMatch(FORBIDDEN_METHOD_WORDS);
|
||||
for (const n of names) expect(n).not.toMatch(/submit|publish|cancel|delete|remove|invite|manual/i);
|
||||
for (const n of ['apple.submit', 'apple.release', 'play.release', 'play.release_testing', 'apple.phased_release', 'play.rollout', 'reviews.send', 'apple.ppo_start', 'prices.import'])
|
||||
// submitting: exactly these owner actions, nothing else
|
||||
const submitting = [...OWNER_SUBMIT].map(methodName).sort();
|
||||
expect(submitting).toEqual(['owner.apple_iap_submit', 'owner.apple_resubmit', 'owner.apple_submit', 'owner.play_production']);
|
||||
for (const n of names.filter(x => !submitting.includes(x))) expect(n).not.toMatch(/submit|production/i);
|
||||
for (const n of names) expect(n).not.toMatch(/publish|cancel|delete|remove|invite|manual/i);
|
||||
for (const n of ['apple.submit', 'apple.resubmit', 'apple.iap_submit', 'apple.release', 'play.release', 'play.release_testing', 'apple.phased_release', 'play.rollout', 'reviews.send', 'apple.ppo_start', 'prices.import'])
|
||||
expect(names).not.toContain(n);
|
||||
// the MCP server (AI tools) never submits, resubmits or sends a production release
|
||||
for (const t of TOOLS) {
|
||||
// only the read-only IAP review plan runs iap-submit, and always with --dry
|
||||
if (t.command.some(c => /submit/.test(c))) { expect([t.name, t.write]).toEqual(['apple_iap_review_plan', false]); expect(toArgv(t, { dry_run: false }).argv).toContain('--dry'); }
|
||||
for (const s of [{}, { track: 'production' }, { dry_run: false }]) if (!validateInput(t, s).length) expect(toArgv(t, s).argv).not.toContain('--production');
|
||||
}
|
||||
expect(TOOLS.some(t => OWNER_SUBMIT.has(t.name) || t.name.startsWith('owner_'))).toBe(false);
|
||||
// every desktop tool still goes through the MCP rules: no forbidden flag can come out of any of them
|
||||
for (const t of DESKTOP_TOOLS) expect(t.command.some(c => FORBIDDEN_ARGS.includes(c))).toBe(false);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue