// App Store Product Page Optimization through the App Store Connect API, wired to the `shots` variants. // storebound apple ppo status [--json] // storebound apple ppo create --variant b[,c] [--name "..."] [--traffic 30] [--dry] // storebound apple ppo upload --variant b [--experiment "..."] [--only en-US,tr] [--src store/renders] [--dry] // storebound apple ppo start | stop [--experiment "..."] [--dry] [--yes] // create: one test (v2 experiment, platform IOS) with one treatment per variant (named after the variant, at most 3) // and a treatment localization per App Store locale of the config. upload: the variant renders // (///ios69 ... from `storebound shots render --variant b`) into each treatment localization's // screenshot sets, MD5-idempotent like `apple screenshots`. start / stop change what users see (stop is final, a test // cannot be restarted) and ask first; they are not MCP tools. Google Play store listing experiments have no API: they // stay in Play Console (store-screenshots skill). Pure planners: cli/lib/ppo.ts. import { consoleLink } from '../lib/console-links'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { byAppleLocale, confirm, need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { checkCreate, createExperimentReq, defaultName, type Experiment, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq, } from '../lib/ppo'; import { textTable } from '../lib/table'; import { numberedImages } from '../play/images'; import { variantRoot } from '../shots/lib'; import { type Asc, ascClient, type J } from './asc'; import { describeReq, execute } from './jsonapi'; import { findOrCreate, getById } from '../lib/upsert'; import { displaySets, syncScreenshotSet } from './screenshots'; const TAG = 'ppo'; const USAGE = 'usage: storebound apple ppo status | create --variant b | upload --variant b | start | stop'; async function experiments(c: Asc, appId: string): Promise { return (await c.all(`/v1/apps/${appId}/appStoreVersionExperimentsV2?limit=50`)).map(experimentFromApi); } const treatments = (c: Asc, expId: string) => c.all(`/v2/appStoreVersionExperiments/${expId}/appStoreVersionExperimentTreatments?limit=50`); const treatmentLocs = (c: Asc, tId: string) => c.all(`/v1/appStoreVersionExperimentTreatments/${tId}/appStoreVersionExperimentTreatmentLocalizations?limit=50`); export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; const sub = a._[0]; if (!sub) throw new Error(USAGE); const dry = a.has('dry'); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); const list = await experiments(c, appId); const locales = config.languages.map(l => l.apple).filter((x): x is string => !!x); if (sub === 'status' && a.has('json')) { const tests = []; for (const x of list) { const ts = []; if (!['COMPLETED', 'STOPPED'].includes(x.state)) for (const t of await treatments(c, x.id)) ts.push({ id: t.id, name: t.attributes.name, locales: (await treatmentLocs(c, t.id)).length }); tests.push({ ...x, treatments: ts }); } printJson({ tests }); return; } if (sub === 'status') { if (!list.length) { done(TAG, 'no Product Page Optimization tests (storebound apple ppo create --variant b)'); return; } console.log(textTable(['test', 'state', 'traffic', 'start', 'end'], list.map(x => [x.name, x.state, x.trafficProportion !== undefined ? `${x.trafficProportion}%` : '', (x.startDate ?? '').slice(0, 10), (x.endDate ?? '').slice(0, 10)]))); for (const x of list.filter(e => !['COMPLETED', 'STOPPED'].includes(e.state))) { for (const t of await treatments(c, x.id)) { const locs = await treatmentLocs(c, t.id); let shots = 0; for (const l of locs) for (const s of await c.all(`/v1/appStoreVersionExperimentTreatmentLocalizations/${l.id}/appScreenshotSets?limit=50`)) shots += (await c.all(`/v1/appScreenshotSets/${s.id}/appScreenshots?limit=50`)).length; log(TAG, `"${x.name}" treatment "${t.attributes.name}": ${locs.length} locale(s), ${shots} screenshot(s)`); } } log(TAG, `results (conversion per treatment) are in ${consoleLink('asc.ppo')}; no API returns them`); return; } if (sub === 'create') { const variants = a.list('variant'); const traffic = a.num('traffic', 30); const problems = checkCreate(variants, traffic, Object.keys(config.shots?.variants ?? {})); if (problems.length) throw new Error(problems.join('; ')); const name = a.get('name') ?? defaultName(variants); let exp = pickExperiment(list, name); if (!exp) { if (dry) { await execute(c.asc, [createExperimentReq(appId, name, traffic)], { dry, tag: TAG }); log(TAG, `then: ${variants.length} treatment(s) x ${locales.length} locale(s)`); done(TAG, 'create checked (dry run)'); return; } // test names are not known to be unique: looked up right before the create (and in the journal), never sent twice const req = createExperimentReq(appId, name, traffic); const made = await findOrCreate({ what: `experiment "${name}"`, tag: TAG, find: async () => (await c.all(`/v1/apps/${appId}/appStoreVersionExperimentsV2?limit=50`)).find((x: J) => x.attributes.name === name && !['COMPLETED', 'STOPPED'].includes(x.attributes.state)) ?? null, journal: { root, key: `apple:${appId}:ppo:${name}`, byId: id => getById(c.asc, `/v2/appStoreVersionExperiments/${id}`) as Promise, idOf: v => v.id }, create: async () => (await c.asc('POST', req.path, req.body)).data }); log(TAG, `${describeReq(req)}: ${made.created ? 'done' : 'exists already'}`); exp = experimentFromApi(made.value); } else log(TAG, `experiment "${name}": exists (${exp.state})`); const ts = await treatments(c, exp.id); const planned = planTreatments(exp.id, ts.map((t: J) => ({ id: t.id, name: t.attributes.name })), variants); const all: J[] = ts.filter((t: J) => variants.includes(t.attributes.name)); if (dry) await execute(c.asc, planned, { dry, tag: TAG }); else for (const r of planned) { const tname = (r.body as J).data.attributes.name as string; const expId = exp.id; const made = await findOrCreate({ what: `treatment "${tname}"`, tag: TAG, find: async () => (await treatments(c, expId)).find((t: J) => t.attributes.name === tname) ?? null, journal: { root, key: `apple:${appId}:ppoTreatment:${expId}:${tname}`, byId: id => getById(c.asc, `/v1/appStoreVersionExperimentTreatments/${id}`) as Promise, idOf: v => v.id }, create: async () => (await c.asc('POST', r.path, r.body)).data }); log(TAG, `${describeReq(r)}: ${made.created ? 'done' : 'exists already'}`); all.push(made.value); } for (const t of all) await execute(c.asc, planTreatmentLocales(t.id, (await treatmentLocs(c, t.id)).map((l: J) => l.attributes.locale), locales), { dry, tag: TAG }); done(TAG, dry ? 'create checked (dry run)' : `test "${name}" ready: storebound apple ppo upload --variant ${variants[0]}`); return; } if (sub === 'upload') { const variant = need(a.get('variant'), '--variant '); const exp = pickExperiment(list, a.get('experiment')); if (!exp) throw new Error('no editable Product Page Optimization test (storebound apple ppo create --variant ' + variant + ')'); const t = (await treatments(c, exp.id)).find((x: J) => x.attributes.name === variant); if (!t) throw new Error(`test "${exp.name}" has no treatment "${variant}" (storebound apple ppo create --variant ${variant} --name "${exp.name}")`); const src = variantRoot(resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), variant, config.shots?.variants); const only = a.list('only'); const langOf = byAppleLocale(config.languages); let sets = 0; for (const loc of await treatmentLocs(c, t.id)) { const locale: string = loc.attributes.locale; if (only.length && !only.includes(locale)) continue; const lang = langOf.get(locale); if (!lang) { warn(TAG, `${locale}: no language with apple: "${locale}", skipped`); continue; } const existing = await c.all(`/v1/appStoreVersionExperimentTreatmentLocalizations/${loc.id}/appScreenshotSets?limit=50`); for (const [dir, type] of Object.entries(displaySets(config.apple))) { const d = join(src, lang.id, dir); const files = existsSync(d) ? numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10) : []; if (!files.length) { warn(TAG, `${locale}: nothing in ${d} (storebound shots render --variant ${variant}), skipped`); continue; } await syncScreenshotSet(c, { rel: 'appStoreVersionExperimentTreatmentLocalization', type: 'appStoreVersionExperimentTreatmentLocalizations', id: loc.id }, existing, type, files, `${variant} ${locale} ${type}`, dry); sets++; } } done(TAG, `${sets} screenshot set(s) ${dry ? 'checked (dry run)' : 'synced'} for treatment "${variant}" of "${exp.name}"`); return; } if (sub === 'start' || sub === 'stop') { const start = sub === 'start'; const name = a.get('experiment'); const exp = name ? list.find(x => x.name === name) ?? null : start ? pickExperiment(list) : list.find(x => ['WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'].includes(x.state) || (x.startDate && !x.endDate && !['COMPLETED', 'STOPPED'].includes(x.state))) ?? null; if (!exp) throw new Error(`no test to ${sub}${name ? ` named "${name}"` : ''} (storebound apple ppo status)`); const req = startStopReq(exp, start); if (dry) { await execute(c.asc, [req], { dry, tag: TAG }); return; } const q = start ? `Start "${exp.name}" (${exp.trafficProportion ?? '?'}% of App Store visitors see a treatment)? Treatments not yet approved go to App Review with your next version first.` : `Stop "${exp.name}" now? A stopped test cannot be restarted.`; if (!(await confirm(ctx, q))) return; await execute(c.asc, [req], { dry, tag: TAG }); done(TAG, `${sub} requested for "${exp.name}" (storebound apple ppo status)`); return; } throw new Error(`unknown ppo command "${sub}"\n${USAGE}`); }