console: Play App content card Saves are the agent's (declaration-save; Send changes for review, Submit, Publish, Restart review stay forbidden), one owner batch per plan with the IARC Terms tick before the run, console plan play-app-content --all, New App fields typed not set, play.appId from the Create app redirect (console verify --url --write) and storebound play app-id across app list pages

This commit is contained in:
CRTkafa 2026-10-13 05:43:56 +08:00
parent 1366e7e231
commit f741c15829
29 changed files with 1029 additions and 165 deletions

View file

@ -160,7 +160,8 @@ describe('--dry --json', () => {
expect(Object.keys(r)).toEqual(['form', 'store', 'title', 'url', 'answers', 'uncertain', 'guidance']);
expect(r).toMatchObject({ form: 'play-target-audience', store: 'play', uncertain: 1 });
expect(r.answers[0]).toMatchObject({ id: 'age:5-under', certain: true, source: 'console.targetAudience.ages' });
expect(r.guidance.at(-1)).toContain('press Save yourself');
expect(r.guidance.at(-1)).toContain('storebound presses the declaration\'s Save itself');
expect(r.guidance.at(-1)).toContain('press Send changes for review in Publishing overview yourself');
await expect(run({ loaded: { config, root: '/tmp/none', path: '', resolve: p => p }, args: parseArgs(['play-target-audience', '--store', 'apple', '--dry']) })).rejects.toThrow('play form');
});
});

View file

@ -117,7 +117,7 @@ describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
expect(setUpAllowed('Set Up Coarse Location', { ...w, privacyPage: false })).toBe(false);
expect(setUpTexts('Purchase History')).toContain('Set Up Purchases');
});
test('other forms: Next is navigation; the content rating\'s Save draft is clicked; Save, Submit, Create, Done stay the owner\'s', () => {
test('other forms: Next is navigation; the content rating\'s Save draft and the Play declarations\' Save are clicked; Submit, Create, Done, Send changes for review stay the owner\'s', () => {
const w = at({ privacyPage: false });
for (const f of ['play-content-rating', 'play-target-audience', 'apple-app-record', 'play-app-record'] as const) {
expect(flowAllowed(f, 'Next', 'button', w)).toBe(true);
@ -125,18 +125,25 @@ describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
expect(flowAllowed(f, 'Get Started', 'button', w)).toBe(false);
expect(flowAllowed(f, 'Save draft', 'button', w)).toBe(f === 'play-content-rating');
expect(flowAllowed(f, 'Taslağı kaydet', 'button', w)).toBe(f === 'play-content-rating');
for (const n of ['Save', 'Submit', 'Create', 'Create app', 'Done', 'Start rollout', 'Publish']) expect(flowAllowed(f, n, 'button', w)).toBe(false);
// an App content declaration's Save goes to Google only with Send changes for review (verified 2026-10-13)
expect(flowAllowed(f, 'Save', 'button', w)).toBe(f === 'play-content-rating' || f === 'play-target-audience');
for (const n of ['Submit', 'Create', 'Create app', 'Done', 'Start rollout', 'Publish', 'Send changes for review', 'Submit 3 changes for review', 'Restart review']) expect(flowAllowed(f, n, 'button', w)).toBe(false);
}
expect(flowClass('play-content-rating', 'Save draft', 'button', w)).toBe('draft-save');
expect(flowClass('play-target-audience', 'Save', 'button', w)).toBe('final-submit');
expect(flowClass('play-target-audience', 'Save', 'button', w)).toBe('declaration-save');
expect(flowClass('play-content-rating', 'Save', 'button', w)).toBe('declaration-save');
expect(flowClass('apple-app-record', 'Save', 'button', w)).toBe('final-submit');
expect(flowClass('play-content-rating', 'Submit', 'button', w)).toBe('final-submit');
expect(flowAllowed('play-target-audience', 'Save', 'button', at({ privacyPage: false, inDialog: true }))).toBe(false);
});
test('every form has its buttons classified; only navigation and the agreed draft saves (App Privacy, content rating) are automatic', () => {
test('every form has its buttons classified; only navigation, the agreed draft saves (App Privacy, content rating) and the Play declaration saves are automatic', () => {
for (const [f, rules] of Object.entries(FORM_BUTTONS)) {
expect(rules.length).toBeGreaterThan(0);
for (const r of rules) if (r.auto) expect(r.class === 'navigation' || (['apple-app-privacy', 'play-content-rating'].includes(f) && r.class === 'draft-save')).toBe(true);
for (const r of rules) if (r.auto) expect(r.class === 'navigation' || (['apple-app-privacy', 'play-content-rating'].includes(f) && r.class === 'draft-save')
|| (['play-content-rating', 'play-target-audience'].includes(f) && r.class === 'declaration-save')).toBe(true);
expect(rules.some(r => r.class === 'final-submit' && !r.auto)).toBe(true);
}
for (const f of ['play-content-rating', 'play-target-audience'] as const) expect(FORM_BUTTONS[f].some(r => r.button === 'Send changes for review' && r.class === 'final-submit' && !r.auto)).toBe(true);
});
});
@ -330,10 +337,16 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
});
describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => {
scope.concurrent('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; its Save never clicked', async () => {
scope.concurrent('content rating: waits for the owner\'s IARC Terms tick, Save draft and Next by itself on each page, a stop at the uncertain questions until answered, then the Summary\'s Save (a declaration save)', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'play-content-rating', config: base0, startUrl: base + 'play-content-rating.html', ...fast(log) }));
// page 1: Next does nothing until the IARC Terms box is ticked (verified 2026-10-13): storebound waits for the owner
await log.wait('the agreement stop', l => l.some(m => m.startsWith('agreement needed on "Email address and category"') && m.includes('IARC Terms of Use')));
await Bun.sleep(300);
expect(await clicks(b)).toEqual([]);
expect(await b.page.getByRole('checkbox', { name: /IARC Terms of Use/ }).isChecked()).toBe(false); // never ticked by storebound
await b.page.getByRole('checkbox', { name: /IARC Terms of Use/ }).check(); // the owner
await log.wait('the questionnaire stop', l => l.some(m => m.startsWith('answer needed on "Questionnaire"')));
expect(log.lines).toContain('clicked "Save draft" (draft-save)');
expect(log.lines).toContain('clicked "Next" (navigation)');
@ -343,23 +356,24 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
// the owner answers every outlined question
const open = b.page.locator('[role="radiogroup"][data-storebound-check]');
for (let i = 0, n = await open.count(); i < n; i++) await open.nth(i).getByRole('radio', { name: 'No' }).check();
await log.wait('the final line', l => l.some(m => m.includes('press Save yourself')));
expect(await clicks(b)).toEqual(['Save draft', 'Next', 'Save draft', 'Next']);
await log.wait('the final line', l => l.some(m => m.includes('storebound pressed Save') && m.includes('Send changes for review in Publishing overview yourself')));
expect(await clicks(b)).toEqual(['Save draft', 'Next', 'Save draft', 'Next', 'Save']);
expect(log.lines).toContain('clicked "Save" (declaration-save)');
expect(await b.page.getByRole('heading', { name: 'Summary' }).isVisible()).toBe(true);
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;
}, TEST_MS);
scope.concurrent('target audience: Next by itself, a stop at the uncertain appeal question until answered, then Next; Save stays the owner\'s', async () => {
scope.concurrent('target audience: Next by itself, a stop at the uncertain appeal question until answered, then Next and the declaration\'s Save; Send changes for review stays the owner\'s', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'play-target-audience', config: base0, startUrl: base + 'play-target-audience.html', ...fast(log) }));
await log.wait('the appeal stop', l => l.some(m => m.startsWith('answer needed on "Appeal to children"')));
expect(await clicks(b)).toEqual(['Next']);
await b.page.getByRole('radiogroup', { name: /appeal to children/ }).getByRole('radio', { name: 'No' }).check();
await log.wait('the final line', l => l.some(m => m.includes('press Save yourself')));
expect(await clicks(b)).toEqual(['Next', 'Next']);
await log.wait('the final line', l => l.some(m => m.includes('storebound pressed Save')));
expect(await clicks(b)).toEqual(['Next', 'Next', 'Save']);
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;
@ -373,6 +387,14 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
await log.wait('the final line', l => l.some(m => m.includes(`press ${final} yourself`)));
// Play: only the read-only package name check (verified 2026-10-12); never the final button
expect(await clicks(b)).toEqual(form === 'play-app-record' ? ['Check availability'] : []);
// App Store Connect's New App (React, verified 2026-10-13): a programmatic value set left its state empty; the
// engine types like a person (the fixture's state only follows key presses)
if (form === 'apple-app-record') {
const state = await b.page.evaluate(() => (window as any).__state);
const shown = await b.page.evaluate(() => [...document.querySelectorAll('input[type=text]')].map(i => (i as HTMLInputElement).value));
expect(state.Name).toBe('Example Game');
expect([state.Name, state.SKU]).toEqual(shown);
}
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;

View file

@ -4,7 +4,7 @@
import { describe, expect, test } from 'bun:test';
import { IARC_QUESTIONS, type ShipConfig, validateConfig, validateExtras } from '../cli/lib/config';
import { consoleAnswers, PLAN_FORM_IDS, PLAN_ONLY_FORM_IDS } from '../cli/lib/console-answers';
import { formDef, forbiddenTick } from '../cli/lib/console-forms';
import { declarationSaveAllowed, flowAllowed, forbiddenControl, formDef, forbiddenTick, publishingControl } from '../cli/lib/console-forms';
import { consolePlan, IN_PAGE_LOOP, playConsoleReminders, planText } from '../cli/lib/console-plan';
import { APP_CONTENT_CARDS, PLAY_MATERIAL_FACT, startLabel } from '../cli/lib/console-play-plans';
import { appPrivacyPublished, verifyAppContent, verifyPlayDeclaration, verifyText } from '../cli/lib/console-verify';
@ -44,7 +44,19 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
expect(p.askOwner).toHaveLength(p.answers.filter(a => a.status !== 'certain').length);
for (const b of ['Publish', 'Submit', 'Send changes for review']) expect(p.buttons.neverPress).toContain(b);
for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit');
if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release'].includes(form)) { expect(p.buttons.final).toBe('Save'); expect(p.buttons.neverPress).toContain('Save'); }
// an App content card's Save is the agent's (verified 2026-10-13); the owner's button is Send changes for review
if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release'].includes(form)) {
expect(p.buttons.final).toBe('Send changes for review');
expect(p.buttons.neverPress).not.toContain('Save');
expect(p.buttons.allowed).toContainEqual(expect.objectContaining({ button: 'Save', class: 'declaration-save' }));
expect(p.steps.some(s => s.text.startsWith('Press Save yourself'))).toBe(true);
const last = p.ownerBatch.at(-1)!;
expect([last.when, last.kind]).toEqual(['after', 'final']);
expect(last.text).toContain('Send changes for review');
}
for (const b of ['Restart review', 'Submit N changes for review']) expect(p.buttons.neverPress).toContain(b);
p.ownerBatch.forEach((o, i) => expect(o.n).toBe(i + 1));
expect(planText(p).indexOf('Owner batch')).toBeLessThan(planText(p).indexOf('Steps:'));
expect(p.uiFacts.join('\n')).toContain(PLAY_MATERIAL_FACT);
expect(p.verify.command).toBe(`storebound console verify ${form} --from-text -`);
expect(p.inPageLoop).toBe(IN_PAGE_LOOP);
@ -89,18 +101,96 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
expect(p.answers.some(a => a.id === 'play-content-rating/agree:iarcTerms' && a.status === 'agreement')).toBe(true);
expect(p.askOwner.some(l => l.startsWith('Content ratings: IARC Terms of Use: an agreement'))).toBe(true);
expect(p.askOwner.some(l => l.startsWith('Government apps: '))).toBe(true);
for (const b of ['IARC Terms of Use box', 'Save (Summary page)', 'Save', 'Submit', 'Create app']) expect(p.buttons.neverPress).toContain(b);
for (const b of ['IARC Terms of Use box', 'Send changes for review', 'Restart review', 'Submit', 'Create app']) expect(p.buttons.neverPress).toContain(b);
for (const b of ['Save', 'Save (Summary page)']) expect(p.buttons.neverPress).not.toContain(b);
expect(p.buttons.final).toBe('Send changes for review');
expect(p.verify.capture).toContain('[aria-label^="Start "]');
});
test('App content: ONE owner batch at the top: the open answers, the IARC Terms tick before the run (with its place), Send changes for review after', () => {
const p = consolePlan('play-app-content', { ...base, console: { ...base.console, appContent: undefined } } as ShipConfig);
const kinds = p.ownerBatch.map(o => `${o.when}:${o.kind}`);
expect(kinds).toEqual(['before:answers', 'before:agreement', 'after:final']);
expect(p.ownerBatch[0]!.text).toContain('Is the app developed by or on behalf of a government?');
expect(p.ownerBatch[1]).toMatchObject({ text: 'Tick the IARC Terms of Use box' });
expect(p.ownerBatch[1]!.where).toContain(`${dev}/app-content/content-rating-iarc-questionnaire`);
expect(p.ownerBatch[1]!.where).toContain('page 1');
expect(p.ownerBatch[2]!.text).toContain('Send changes for review');
const text = planText(p);
expect(text.indexOf('Owner batch')).toBeLessThan(text.indexOf('start:'));
expect(text).toContain('[before the run] Tick the IARC Terms of Use box');
expect(p.steps[0]!.text).toContain('ONE message');
// a complete config: only the two owner steps are left
const done = consolePlan('play-app-content', { ...base, console: { ...base.console, appContent: { government: false, financialFeatures: false, healthFeatures: false, advertisingId: true } }, reviewAccess: { signInRequired: false } } as ShipConfig);
const steps = done.ownerBatch.filter(o => o.kind !== 'answers');
expect(steps.map(o => [o.when, o.kind])).toEqual([['before', 'agreement'], ['after', 'final']]);
expect(steps[0]!.text).toBe('Tick the IARC Terms of Use box');
expect(steps[1]!.text).toContain('Send changes for review');
});
test('content rating: the owner batch has the IARC Terms tick before the run; the Summary Save is the agent\'s', () => {
const p = consolePlan('play-content-rating', base);
expect(p.ownerBatch.map(o => [o.when, o.kind])).toEqual([['before', 'answers'], ['before', 'agreement'], ['after', 'final']]);
expect(p.ownerBatch[1]!.where).toContain('Next does nothing until it is ticked');
expect(p.steps.map(s => s.text).join('\n')).toContain('Next does nothing (verified 2026-10-13)');
expect(p.uiFacts.join('\n')).toContain('page 1\'s Next does nothing until the IARC Terms of Use box is ticked');
});
test('--all: every card inline in fix order, ending with the one owner list [IARC Terms (before), Send changes for review (after)]', () => {
const cfg = { ...base, console: { ...base.console, appContent: { government: false, financialFeatures: false, healthFeatures: false, advertisingId: true } }, reviewAccess: { signInRequired: false } } as ShipConfig;
const one = consolePlan('play-app-content', cfg), all = consolePlan('play-app-content', cfg, {}, { all: true });
expect(all.steps.length).toBeGreaterThan(one.steps.length + 20);
all.steps.forEach((s, i) => expect(s.n).toBe(i + 1));
// the cards in the overview's order
const at = APP_CONTENT_CARDS.map(c => all.steps.findIndex(s => s.text.startsWith(`${c.name}: `)));
expect(at.every(i => i > 0)).toBe(true);
expect([...at].sort((a, b) => a - b)).toEqual(at);
// every answer of every card is set in a step; answer ids keep their card
const inSteps = new Set(all.steps.flatMap(s => s.answers ?? []));
for (const a of all.answers) expect({ id: a.id, listed: inSteps.has(a.id) }).toEqual({ id: a.id, listed: true });
// card Saves are pressed by the agent, the run ends with the owner list
expect(all.steps.filter(s => s.text.includes('Press Save yourself')).length).toBeGreaterThanOrEqual(7);
const last = all.steps.at(-1)!.text;
expect(last).toContain('[before: Tick the IARC Terms of Use box]');
expect(last).toContain('[after: Press Send changes for review');
expect(all.ownerBatch.filter(o => o.kind !== 'answers').map(o => o.when)).toEqual(['before', 'after']);
expect(all.buttons.final).toBe('Send changes for review');
});
test('the click gate: card Saves allowed on an App content card page; Send changes for review, Submit, Publish, Restart review never', () => {
const card = (slug: string) => ({ url: `${dev}/app-content/${slug}` });
for (const slug of ['privacy-policy', 'ads-declaration', 'testing-credentials', 'ad-id-declaration', 'government-apps', 'finance', 'health', 'target-audience-content', 'content-rating-iarc-questionnaire', 'data-privacy-security'])
expect({ slug, ok: declarationSaveAllowed('Save', 'button', card(slug)) }).toEqual({ slug, ok: true });
expect(declarationSaveAllowed('Kaydet', 'button', card('ads-declaration'))).toBe(true);
for (const n of ['Send changes for review', 'Submit', 'Publish', 'Restart review', 'Submit 3 changes for review', 'Save and publish', 'Save draft', 'Start rollout'])
expect({ n, ok: declarationSaveAllowed(n, 'button', card('ads-declaration')) }).toEqual({ n, ok: false });
// only on a card page, only a button
expect(declarationSaveAllowed('Save', 'button', { url: `${dev}/app-content/overview` })).toBe(false);
expect(declarationSaveAllowed('Save', 'button', { url: `${dev}/tracks/production` })).toBe(false);
expect(declarationSaveAllowed('Save', 'button', { url: 'https://example.com/app-content/ads-declaration' })).toBe(false);
expect(declarationSaveAllowed('Save', 'link', card('ads-declaration'))).toBe(false);
for (const n of ['Send changes for review', 'Submit', 'Publish', 'Restart review', 'Submit 3 changes for review', 'Değişiklikleri incelemeye gönder']) {
expect(publishingControl(n)).toBe(true);
expect(forbiddenControl(n)).toBe(true);
for (const f of ['play-content-rating', 'play-target-audience'] as const) expect(flowAllowed(f, n, 'button', { privacyPage: false, inDialog: false, inDataTypes: false, dataTypes: [] })).toBe(false);
}
for (const n of ['Save', 'Releases', 'Next']) expect(publishingControl(n)).toBe(false);
});
test('Sign in details: the demo account never printed, typed by the owner', () => {
const p = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' } } as ShipConfig, { demoAccount: true });
expect(p.answers.find(a => a.id === 'access')).toMatchObject({ value: true, status: 'certain', label: 'All or some functionality in my app is restricted' });
expect(p.answers.find(a => a.id === 'instructions')).toMatchObject({ status: 'certain', action: 'type' });
const cred = p.answers.find(a => a.id === 'credentials')!;
expect(cred).toMatchObject({ status: 'ask-owner', value: null });
// in .storebound/credentials.json: the agent types it, never printed (no value in the plan)
expect(cred).toMatchObject({ status: 'certain', value: null, action: 'type', text: 'from .storebound/credentials.json (never printed)' });
expect(cred.note).toContain('reviewDemoAccount: present');
expect(cred.note).toContain('never types a password');
expect(p.ownerBatch.map(o => o.kind)).toEqual(['final']);
// kept out of .storebound: the owner types it (in the owner batch, before the run)
const own = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' } } as ShipConfig, { demoAccount: false });
expect(own.answers.find(a => a.id === 'credentials')).toMatchObject({ status: 'ask-owner', value: null });
expect(own.ownerBatch.map(o => [o.when, o.kind])).toEqual([['before', 'credentials'], ['after', 'final']]);
expect(JSON.stringify(own)).not.toMatch(/password"\s*:/i);
expect(consolePlan('play-sign-in-details', base).answers.map(a => a.status)).toEqual(['ask-owner']);
});
@ -117,12 +207,14 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
});
describe('IARC content rating: verified flow, follow-ups, never-press', () => {
test('the IARC Terms box is an agreement, the Summary\'s Save is final (not Submit), the flow and example ratings are recorded', () => {
test('the IARC Terms box is an agreement, the Summary\'s Save (not Submit) is the agent\'s declaration save, the flow and example ratings are recorded', () => {
const p = consolePlan('play-content-rating', base);
expect(p.answers.find(a => a.id === 'agree:iarcTerms')).toMatchObject({ status: 'agreement', value: null, action: 'untick' });
expect(p.buttons.final).toBe('Save (Summary page)');
for (const b of ['IARC Terms of Use box', 'Save (Summary page)', 'Submit']) expect(p.buttons.neverPress).toContain(b);
expect(p.buttons.final).toBe('Send changes for review');
for (const b of ['IARC Terms of Use box', 'Send changes for review', 'Submit']) expect(p.buttons.neverPress).toContain(b);
expect(p.buttons.neverPress).not.toContain('Save (Summary page)');
expect(p.buttons.allowed.map(b => b.button)).toContain('Save draft / Save (questionnaire page)');
expect(p.buttons.allowed).toContainEqual(expect.objectContaining({ button: 'Save (Summary page)', class: 'declaration-save' }));
const facts = p.uiFacts.join('\n');
for (const f of ['content-rating-iarc-questionnaire', 'one long page', '"Save" (not "Submit")', 'PEGI 7, ESRB E10+, USK 12, ClassInd 18', 'Google Play 7+']) expect(facts).toContain(f);
expect(p.askOwner.some(l => l.startsWith('IARC Terms of Use: an agreement'))).toBe(true);

View file

@ -20,4 +20,11 @@
</fieldset>
<button type="button">Help</button> <button type="button">Cancel</button> <button type="button">Create</button>
</div>
<script>
// Models the verified 2026-10-13 behaviour of the real (React) dialog: a programmatic value set leaves the component
// state empty; only key presses update it. window.__state holds what the dialog would submit.
window.__state = { Name: '', SKU: '' };
for (const [label, input] of [['Name', document.querySelectorAll('input[type=text]')[0]], ['SKU', document.querySelectorAll('input[type=text]')[1]]])
input.addEventListener('keyup', () => { window.__state[label] = input.value; });
</script>
</body></html>

129
tests/play-app-id.test.ts Normal file
View file

@ -0,0 +1,129 @@
// The Play Console app id (play.appId): taken from the URL Create app redirects to (`console verify play-app-record
// --url`), or found by package name in captured app list pages (`play app-id`, a modeled list of 10 apps per page with
// the new app on page 3), then written into the config as an upsert after a dry diff. Placeholder ids only.
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ShipConfig } from '../cli/lib/config';
import { consolePlan } from '../cli/lib/console-plan';
import { PLAY_CREATE_CAPTURE, verifyText } from '../cli/lib/console-verify';
import { APP_LIST_CAPTURE, appIdChange, appIdDiff, appIdSteps, appListPage, type AppListPage, findPlayAppId, parseAppListPages, playAppIdInText, writePlayAppId } from '../cli/lib/play-app-id';
import { upsertNestedString } from '../cli/lib/scan/config-edit';
const PKG = 'com.example.game';
const base: ShipConfig = {
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
play: { packageName: PKG, developerId: '4444' },
} as ShipConfig;
const dash = (app: string) => `https://play.google.com/console/u/0/developers/4444/app/${app}/app-dashboard`;
/** A modeled app list: 25 apps, 10 per page, the new app (com.example.game) on page 3, a look-alike on page 1. */
function list(): AppListPage[] {
const apps = Array.from({ length: 24 }, (_, i) => ({ name: `Other App ${i + 1}`, pkg: `com.example.other${i + 1}`, id: String(4975000000000000000 + i) }));
apps.splice(1, 0, { name: 'Example Game Pro', pkg: 'com.example.game2', id: '4975999999999999998' }); // not a whole-token match
apps.push({ name: 'Example Game', pkg: PKG, id: '4975123456789012345' });
const pages: AppListPage[] = [];
for (let i = 0; i < apps.length; i += 10) {
const slice = apps.slice(i, i + 10);
pages.push({ url: appListPage('4444', pages.length + 1), hasNext: i + 10 < apps.length,
rows: slice.map(a => ({ text: `${a.name} ${a.pkg} Production Published`, href: `/console/u/0/developers/4444/app/${a.id}/app-dashboard` })) });
}
return pages;
}
describe('the app id from a URL or a capture', () => {
test('playAppIdInText: the /developers/<id>/app/<id> digits, anywhere', () => {
expect(playAppIdInText(dash('4975123456789012345'))).toBe('4975123456789012345');
expect(playAppIdInText(JSON.stringify({ url: dash('9876'), fields: {} }))).toBe('9876');
expect(playAppIdInText('https://play.google.com/console/u/0/developers/4444/app-list')).toBeNull();
expect(playAppIdInText('https://play.google.com/console/u/0/developers/4444/create-new-app')).toBeNull();
});
test('console verify play-app-record --url: the created app\'s id matches and is handed to the upsert', () => {
const r = verifyText('play-app-record', base, dash('9876'));
expect(r).toMatchObject({ ok: true, playAppId: '9876', matched: 1 });
expect(r.rows[0]!.expected).toContain('not in the config yet');
// the capture snippet carries the URL too
expect(PLAY_CREATE_CAPTURE).toContain('url: location.href');
expect(verifyText('play-app-record', base, JSON.stringify({ url: dash('9876'), fields: {}, checked: [], text: 'Dashboard' })).playAppId).toBe('9876');
// a different id in the config: a mismatch, and --write would replace it
const other = verifyText('play-app-record', { ...base, play: { ...base.play!, appId: '1111' } } as ShipConfig, dash('9876'));
expect(other).toMatchObject({ ok: false, mismatches: 1, playAppId: '9876' });
// the form still open (before Create app): no app id, the usual field rows
const form = verifyText('play-app-record', base, JSON.stringify({ url: 'https://play.google.com/console/u/0/developers/4444/create-new-app', fields: { 'App name': 'Example Game' }, checked: [], text: '' }));
expect(form.playAppId).toBeUndefined();
expect(form.rows.length).toBeGreaterThan(1);
});
test('the plan says how to capture it after Create app', () => {
const p = consolePlan('play-app-record', base);
expect(p.steps.map(s => s.text).join('\n')).toContain('console verify play-app-record --url');
expect(p.uiFacts.join('\n')).toContain('10 apps per page');
});
});
describe('play app-id: the package name across the app list pages', () => {
test('found on page 3 of a modeled 3-page list; the look-alike com.example.game2 never matches', () => {
const pages = list();
expect(pages).toHaveLength(3);
expect(pages[0]!.rows).toHaveLength(10);
expect(findPlayAppId(pages.slice(0, 1), PKG)).toEqual({ found: false, pages: 1, hasNext: true });
expect(findPlayAppId(pages.slice(0, 2), PKG)).toEqual({ found: false, pages: 2, hasNext: true });
expect(findPlayAppId(pages, PKG)).toMatchObject({ found: true, appId: '4975123456789012345', page: 3 });
expect(findPlayAppId(pages, 'com.example.missing')).toEqual({ found: false, pages: 3, hasNext: false });
});
test('captures: one object, a JSON array or one per line', () => {
const pages = list();
expect(parseAppListPages(JSON.stringify(pages[2]))).toHaveLength(1);
expect(parseAppListPages(JSON.stringify(pages))).toHaveLength(3);
expect(parseAppListPages(pages.map(p => JSON.stringify(p)).join('\n'))).toHaveLength(3);
expect(() => parseAppListPages('{"nope":1}')).toThrow('rows');
expect(findPlayAppId(parseAppListPages(pages.map(p => JSON.stringify(p)).join('\n')), PKG)).toMatchObject({ found: true, page: 3 });
});
test('the agent steps: the search box first, then the pages; the capture snippet is valid JavaScript', () => {
const steps = appIdSteps(base, '4444').join('\n');
for (const s of ['https://play.google.com/console/u/0/developers/4444/app-list', 'search box', PKG, 'Next page', '/app-list?page=', '--write']) expect(steps).toContain(s);
expect(() => new Function(`return ${APP_LIST_CAPTURE}`)).not.toThrow();
expect(appListPage('4444', 3)).toBe('https://play.google.com/console/u/0/developers/4444/app-list?page=3');
});
});
describe('play.appId written into the config: a dry diff, then an upsert', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-app-id-'));
afterAll(() => rmSync(dir, { recursive: true, force: true }));
const TS = `import { defineConfig } from 'storebound';\n\nexport default defineConfig({\n app: { name: 'Example Game' },\n languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],\n play: {\n packageName: '${PKG}', // the package\n developerId: '4444',\n listing: { title: 'x' },\n },\n});\n`;
test('adds play.appId inside play (only there), then replaces it; the rest of the file stays', () => {
const added = upsertNestedString(TS, 'storebound.config.ts', 'play', 'appId', '9876');
expect(added).toContain(` play: {\n appId: '9876',\n packageName: '${PKG}', // the package`);
expect(added.match(/appId/g)).toHaveLength(1);
expect(added.replace(" appId: '9876',\n", '')).toBe(TS);
const again = upsertNestedString(added, 'storebound.config.ts', 'play', 'appId', '1111');
expect(again).toBe(added.replace("'9876'", "'1111'"));
expect(upsertNestedString("export default { app: { name: 'X' }, play: { packageName: 'a.b' } };\n", 'c.ts', 'play', 'appId', '5')).toBe("export default { app: { name: 'X' }, play: { packageName: 'a.b', appId: '5' } };\n");
expect(JSON.parse(upsertNestedString(JSON.stringify({ play: { packageName: 'a.b' } }), 'c.json', 'play', 'appId', '5')).play).toEqual({ packageName: 'a.b', appId: '5' });
expect(() => upsertNestedString("export default { app: {} };\n", 'c.ts', 'play', 'appId', '5')).toThrow('no play block');
// a nested appId (another block inside play) is not the one replaced
const nested = "export default {\n play: {\n extra: { appId: 'keep' },\n packageName: 'a.b',\n },\n};\n";
expect(upsertNestedString(nested, 'c.ts', 'play', 'appId', '5')).toContain(" appId: '5',\n extra: { appId: 'keep' },");
});
test('writePlayAppId: a dry diff first, a write only when it changes, re-runs converge', () => {
const file = join(dir, 'storebound.config.ts');
writeFileSync(file, TS);
const cfg = { ...base } as ShipConfig;
const change = appIdChange(cfg, '9876');
expect(change).toEqual({ from: null, to: '9876', changed: true });
expect(appIdDiff(change, 'storebound.config.ts')).toBe("storebound.config.ts: play.appId (not set) -> '9876'");
expect(writePlayAppId(file, cfg, '9876')).toBe(true);
expect(readFileSync(file, 'utf8')).toContain("appId: '9876'");
const now = { ...cfg, play: { ...cfg.play!, appId: '9876' } } as ShipConfig;
expect(writePlayAppId(file, now, '9876')).toBe(false); // already there: nothing written
expect(appIdDiff(appIdChange(now, '9876'), 'storebound.config.ts')).toContain('already');
expect(() => writePlayAppId(file, cfg, 'abc')).toThrow('not a Play Console app id');
});
});