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:
parent
1366e7e231
commit
f741c15829
29 changed files with 1029 additions and 165 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
7
tests/fixtures/console/apple-app-record.html
vendored
7
tests/fixtures/console/apple-app-record.html
vendored
|
|
@ -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
129
tests/play-app-id.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue