storebound/cli/apple/ppo.ts

155 lines
10 KiB
TypeScript

// 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
// (<renders>/<variant>/<lang>/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<Experiment[]> {
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<void> {
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<J>({ 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<J | null>, 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<J>({ 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<J | null>, 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 <id>');
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}`);
}