Play console steps without an API as agent plans: play-store-settings (App category; play listing cannot set it), play-app-content (every card in order, one owner batch) and its cards (privacy policy, ads, sign in details, advertising ID, government, financial, health), play-production-countries and play-production-release; IARC plan with the Terms of Use agreement, follow-ups (console.contentRating.details, products[].randomRewards, social) and the Summary's final Save; config play.category / countries / managedPublishing, console.appContent; check, validate, play status and play release reminders; inPageLoop helper and material checkbox clicks; App Privacy published signal; first IAPs via apple iap-submit --with-version. Pitfalls 27, 28

This commit is contained in:
CRTkafa 2026-10-12 10:16:34 +08:00
parent dbd9f2973c
commit b7bfe89151
37 changed files with 1624 additions and 121 deletions

View file

@ -327,7 +327,7 @@ 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', () => {
test('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; Submit never clicked', async () => {
test('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 () => {
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) }));
@ -340,7 +340,7 @@ 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 Submit yourself')));
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']);
expect(await b.page.getByRole('heading', { name: 'Summary' }).isVisible()).toBe(true);
expect(navAsked(log.lines)).toEqual([]);

View file

@ -99,8 +99,9 @@ describe('console plan: the JSON shape for every form', () => {
expect(consolePlan('play-app-record', { ...base, play: { packageName: 'com.example.game' } } as ShipConfig).start.deepLink).toBe(false);
});
test('Play App content forms deep-link from the app dashboard once the app id is known', () => {
expect(consolePlan('play-content-rating', base, { playAppId: '9876' }).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app/9876/app-dashboard');
test('Play App content forms deep-link to their card page once the app id is known (verified 2026-10-12)', () => {
expect(consolePlan('play-content-rating', base, { playAppId: '9876' }).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app/9876/app-content/content-rating-overview');
expect(consolePlan('play-target-audience', base, { playAppId: '9876' }).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app/9876/app-content/target-audience-content');
expect(consolePlan('play-target-audience', base).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app-list');
});

View file

@ -0,0 +1,310 @@
// The agent-only Play console plans (Store settings, the App content overview and its cards), the IARC follow-ups,
// the in-page helper and the config fields behind them. Pure; the facts were verified on a real Play Console
// (2026-10-12); every id here is a placeholder.
import { describe, expect, test } from 'bun:test';
import { 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 { 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';
import { CONSOLE_PLAN_FORMS, TOOLS, validateInput } from '../cli/mcp-tools';
import { playRules, type PlaySnapshot } from '../cli/lib/validate-rules';
const base: ShipConfig = {
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
play: { packageName: 'com.example.game', developerId: '4444', appId: '9876', category: 'Strategy' },
ads: { appAdsTxt: ['example-network.com, pub-0000, DIRECT, abc123'] },
products: [{ id: 'com.example.game.chest', type: 'consumable', usd: 0.99, listings: { en: { title: 'Chest', description: 'A random chest' } }, randomRewards: true }],
dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { collect: ['advertising', 'analytics'], shared: true, share: ['advertising'] } } },
console: { contentRating: { category: 'game', answers: { violence: false } }, targetAudience: { ages: ['13-15', '16-17', '18-over'], containsAds: true } },
} as ShipConfig;
const dev = 'https://play.google.com/console/u/0/developers/4444/app/9876';
describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch', () => {
const slugs: Record<string, string> = {
'play-store-settings': 'store-settings', 'play-app-content': 'app-content/overview', 'play-privacy-policy': 'app-content/privacy-policy',
'play-ads': 'app-content/ads-declaration', 'play-sign-in-details': 'app-content/testing-credentials', 'play-advertising-id': 'app-content/ad-id-declaration',
'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health',
'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production',
};
for (const form of PLAN_ONLY_FORM_IDS) {
test(form, () => {
const p = consolePlan(form, base);
expect(p).toMatchObject({ form, store: 'play', fillable: true });
expect(p.start).toEqual({ url: `${dev}/${slugs[form]}`, deepLink: true, reach: [] });
p.steps.forEach((s, i) => { expect(s.n).toBe(i + 1); expect(s.text.length).toBeGreaterThan(10); });
expect(p.answers.length).toBeGreaterThan(0);
for (const a of p.answers) if (a.status === 'ask-owner' || a.status === 'agreement') expect(a.value).toBeNull();
// every answer an agent sets is in a step
const inSteps = new Set(p.steps.flatMap(s => s.answers ?? []));
for (const a of p.answers) expect({ id: a.id, listed: inSteps.has(a.id) }).toEqual({ id: a.id, listed: true });
// the owner batch holds exactly the answers the config cannot give
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'); }
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);
expect(planText(p)).toContain('window.__sb.run');
expect(JSON.parse(JSON.stringify(p))).toEqual(p);
});
}
test('without the app id: the app list, then the card by its "Start <Name> declaration" button', () => {
const p = consolePlan('play-government-apps', { ...base, play: { packageName: 'com.example.game', developerId: '4444' } } as ShipConfig);
expect(p.start.deepLink).toBe(false);
expect(p.start.url).toBe('https://play.google.com/console/u/0/developers/4444/app-list');
expect(p.start.reach.join('\n')).toContain('aria-label="Start Government apps declaration"');
});
test('Store settings: the category from play.category, the first Edit, the [role=option], Save allowed', () => {
const p = consolePlan('play-store-settings', base);
expect(p.answers.find(a => a.id === 'category')).toMatchObject({ value: 'Strategy', status: 'certain', action: 'choose', label: 'Strategy' });
expect(p.answers.find(a => a.id === 'appType')).toMatchObject({ value: 'Game', status: 'certain' });
const text = p.steps.map(s => s.text).join('\n');
for (const f of ['FIRST "Edit"', '[role=option]', 'Save']) expect(text).toContain(f);
expect(p.uiFacts.join('\n')).toContain('edits.details has no category field');
expect(p.buttons.allowed.map(b => b.button)).toContain('Save');
expect(p.buttons.final).toBe('Send changes for review');
// mapped from the App Store category: filled, but an owner check
const mapped = consolePlan('play-store-settings', { ...base, play: { packageName: 'com.example.game' }, apple: { bundleId: 'com.example.game', info: { categories: { primary: 'GAMES', primarySubOne: 'GAMES_PUZZLE' } } } } as ShipConfig);
expect(mapped.answers.find(a => a.id === 'category')).toMatchObject({ value: 'Puzzle', status: 'owner-check', source: 'apple.info.categories (GAMES_PUZZLE)' });
// none: ask the owner, with Play's list
const none = consolePlan('play-store-settings', { ...base, play: { packageName: 'com.example.game' } } as ShipConfig);
expect(none.answers.find(a => a.id === 'category')).toMatchObject({ value: null, status: 'ask-owner' });
expect(none.askOwner.join('\n')).toContain('play.category');
});
test('App content overview: every card in order, its page and start button, one batch for the owner', () => {
const p = consolePlan('play-app-content', base);
expect(APP_CONTENT_CARDS.map(c => c.name)).toEqual(['Privacy policy', 'Ads', 'Sign in details', 'Content ratings', 'Target audience and content', 'Data safety', 'Advertising ID', 'Government apps', 'Financial features', 'Health apps']);
const facts = p.uiFacts.join('\n');
for (const s of ['privacy-policy', 'ads-declaration', 'testing-credentials', 'content-rating-overview', 'target-audience-content', 'ad-id-declaration', 'government-apps', 'finance', 'health']) expect(facts).toContain(`/app-content/${s}`);
expect(p.steps.flatMap(s => s.click ?? [])).toEqual(APP_CONTENT_CARDS.filter(c => c.form).map(c => startLabel(c.name)));
expect(p.steps.map(s => s.text).join('\n')).toContain('storebound play data-safety');
// answers carry their card; the batch holds every card's open questions, the IARC Terms agreement among them
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);
expect(p.verify.capture).toContain('[aria-label^="Start "]');
});
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 });
expect(cred.note).toContain('reviewDemoAccount: present');
expect(cred.note).toContain('never types a password');
expect(consolePlan('play-sign-in-details', base).answers.map(a => a.status)).toEqual(['ask-owner']);
});
test('Advertising ID from the ads config and the Data safety purposes; finance and health none boxes', () => {
const ad = consolePlan('play-advertising-id', base);
expect(ad.answers.find(a => a.id === 'uses')).toMatchObject({ value: true, status: 'certain' });
expect(ad.answers.filter(a => a.action === 'tick').map(a => a.label).sort()).toEqual(['Advertising or marketing', 'Analytics']);
const none = { ...base, console: { ...base.console, appContent: { financialFeatures: false, healthFeatures: false, government: false } } } as ShipConfig;
expect(consolePlan('play-financial-features', none).answers).toMatchObject([{ id: 'none', action: 'tick', label: 'My app doesn\'t provide any financial features', status: 'certain' }]);
expect(consolePlan('play-health-apps', none).answers).toMatchObject([{ id: 'none', action: 'tick', label: 'Other', status: 'certain' }]);
expect(consolePlan('play-government-apps', none).answers).toMatchObject([{ id: 'government', value: false, label: 'No', status: 'certain' }]);
expect(consolePlan('play-financial-features', base).answers).toMatchObject([{ id: 'features', status: 'ask-owner' }]);
});
});
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', () => {
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.allowed.map(b => b.button)).toContain('Save draft / Save (questionnaire page)');
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);
expect(forbiddenTick('I agree to the IARC Terms of Use')).toBe(true);
expect(forbiddenTick('IARC Terms of Use')).toBe(true);
});
test('facts the config proves: loot boxes from products, digital purchases, user interaction from social', () => {
const cfg = { ...base, social: { interaction: true, block: true, report: true } } as ShipConfig;
const a = new Map(consoleAnswers('play-content-rating', cfg).map(x => [x.id, x]));
expect(a.get('q:lootBoxes')).toMatchObject({ value: true, certain: true, source: 'products[].randomRewards (com.example.game.chest)' });
expect(a.get('q:lootBoxes')!.note).toContain('ClassInd');
expect(a.get('q:digitalPurchases')).toMatchObject({ value: true, certain: true });
expect(a.get('q:userInteraction')).toMatchObject({ value: true, certain: true, source: 'social.interaction' });
expect(a.get('d:userInteraction:block')).toMatchObject({ value: true, certain: true, source: 'social.block' });
expect(a.get('d:userInteraction:moderation')).toMatchObject({ value: null, certain: false });
expect(a.get('d:digitalPurchases:playerTrading')).toMatchObject({ certain: false });
// Miscellaneous is always asked; violence false reveals nothing
for (const k of ['naziSymbols', 'koreaHistory', 'terrorism', 'crimes']) expect(a.has(`d:miscellaneous:${k}`)).toBe(true);
expect([...a.keys()].some(k => k.startsWith('d:violence:'))).toBe(false);
// no products sold: no paid random items; no interaction: no trading
const plain = new Map(consoleAnswers('play-content-rating', { ...base, products: undefined, social: { interaction: false } } as ShipConfig).map(x => [x.id, x]));
expect(plain.get('q:lootBoxes')).toMatchObject({ value: false, certain: true });
expect(plain.get('q:userInteraction')).toMatchObject({ value: false, certain: true });
});
test('details: the owner\'s follow-up answers fill the sheet, the rest is in the one batch; the scripted fill has a field for each', () => {
const cfg = { ...base, console: { ...base.console, contentRating: { category: 'game', answers: { violence: true, fear: true },
details: { violence: { humans: false, nonHumans: true, setting: 'Fantasy', childlike: true, reactions: ['No reaction'], presentation: [], blood: 'No blood' },
fear: { other: { 'Does the app contain jump scares?': false } }, miscellaneous: { naziSymbols: false, koreaHistory: false, terrorism: false, crimes: false } } } } } as ShipConfig;
const ans = consoleAnswers('play-content-rating', cfg);
const a = new Map(ans.map(x => [x.id, x]));
expect(a.get('d:violence:nonHumans')).toMatchObject({ value: true, certain: true, source: 'console.contentRating.details.violence.nonHumans' });
expect(a.get('d:violence:setting')).toMatchObject({ value: 'Fantasy', certain: true });
expect(a.get('d:violence:reactions:No reaction')).toMatchObject({ value: true, certain: true });
expect([...a.keys()].some(k => k.startsWith('d:violence:presentation'))).toBe(false); // [] = tick none
expect(a.get('d:violence:historicalWar')).toMatchObject({ value: null, certain: false });
expect(a.get('d:fear:other:Does the app contain jump scares?')).toMatchObject({ value: false, certain: true });
expect(a.get('d:miscellaneous:crimes')).toMatchObject({ value: false, certain: true });
const p = consolePlan('play-content-rating', cfg);
expect(p.askOwner.some(l => l.startsWith('Violence: set in a historical war?: ask the owner'))).toBe(true);
const inSteps = new Set(p.steps.flatMap(s => s.answers ?? []));
for (const x of p.answers) expect({ id: x.id, listed: inSteps.has(x.id) }).toEqual({ id: x.id, listed: true });
const def = formDef('play-content-rating', ans);
const ids = new Set(def.sections.flatMap(s => s.fields.map(f => f.id)));
for (const x of ans) expect({ id: x.id, field: ids.has(x.id) }).toEqual({ id: x.id, field: true });
expect(def.sections.filter(s => s.title.endsWith('follow-ups')).every(s => s.optional && s.page)).toBe(true);
// an unanswered topic names its follow-ups in its note (so the batch asks them together)
const open = new Map(consoleAnswers('play-content-rating', { ...base, console: undefined } as ShipConfig).map(x => [x.id, x]));
expect(open.get('q:violence')!.note).toContain('details.violence.humans');
});
test('config validation of the follow-ups', () => {
expect(validateExtras({ console: { contentRating: { details: { violence: { humans: true, reactions: ['No reaction'], setting: 'Fantasy' }, fear: { other: { 'Q?': ['A', 'B'] } } } } } })).toEqual([]);
const e = validateExtras({ console: { contentRating: { details: { violence: { humans: 'yes', reactions: 'x', blood: '' }, nope: {}, fear: { scary: true, other: { 'Q?': 3 } } } as never } } }).join('\n');
for (const k of ['details.violence.humans', 'details.violence.reactions', 'details.violence.blood', 'details.nope', 'details.fear.scary', 'details.fear.other'])
expect(e).toContain(k);
});
});
describe('the in-page helper and the driving rules', () => {
test('one evaluate call starts a logged job; it never clicks buttons and refuses agreements', () => {
expect(() => new Function(`return ${IN_PAGE_LOOP}`)).not.toThrow(); // valid JavaScript
expect(IN_PAGE_LOOP).not.toContain('`'); // no template literals: the same text in JSON and a terminal
for (const s of ['window.__log', 'window.__done', 'window.__sb.run', '[role=radio]', '[role=checkbox]', 'innerText', 'terms of use', '.click()'])
expect(IN_PAGE_LOOP).toContain(s);
expect(IN_PAGE_LOOP).not.toMatch(/button/i);
const p = consolePlan('play-content-rating', base);
const d = p.driving.join('\n');
for (const s of ['45 s', 'window.__log', 'plan.inPageLoop', 'short calls']) expect(d).toContain(s);
expect(planText(p)).toContain('In-page helper');
});
test('every Play plan says how to click material radios and checkboxes and where their labels are', () => {
for (const f of PLAN_FORM_IDS.filter(x => x.startsWith('play-'))) {
const facts = consolePlan(f, base).uiFacts.join('\n');
expect({ f, radio: facts.includes('[role=radio]'), box: facts.includes('[role=checkbox]') }).toEqual({ f, radio: true, box: true });
}
expect(PLAY_MATERIAL_FACT).toContain('nearest parent whose innerText is not empty');
});
});
describe('the first production release: countries, managed publishing, the owner\'s Send changes for review', () => {
test('countries: all by default (Select all rows), or play.countries by name; Save is the agent\'s', () => {
const p = consolePlan('play-production-countries', base);
expect(p.answers).toMatchObject([{ id: 'all', action: 'tick', label: 'Select all rows', status: 'certain' }]);
expect(p.steps.map(s => s.text).join('\n')).toContain('Go to Publishing overview?');
expect(p.buttons.allowed.map(b => b.button)).toContain('Save');
expect(p.uiFacts.join('\n')).toContain('edits.countryAvailability is read-only');
const some = consolePlan('play-production-countries', { ...base, play: { ...base.play!, countries: ['US', 'TR'] } } as ShipConfig);
expect(some.answers.map(a => a.label)).toEqual(['United States', 'Türkiye']);
expect(verifyText('play-production-countries', base, 'Countries / regions\n177 countries / regions').ok).toBe(true);
expect(verifyText('play-production-countries', base, 'Countries / regions\n0 countries / regions').mismatches).toBe(1);
});
test('release: Edit release, Next, Save; Publishing overview\'s Submit and Send changes for review are the owner\'s (a real click); the managed publishing warning', () => {
const p = consolePlan('play-production-release', base);
for (const b of ['Submit N changes for review', 'Send changes for review']) expect(p.buttons.neverPress).toContain(b);
expect(p.buttons.final).toBe('Send changes for review');
const text = p.steps.map(s => s.text).join('\n');
for (const f of ['Edit release', 'deobfuscation file is only a warning', 'Send N changes for review?', 'a JS .click() did nothing', 'goes live automatically once Google approves it']) expect(text).toContain(f);
expect(p.answers[0]).toMatchObject({ id: 'managedPublishing', status: 'owner-check', value: false });
const managed = consolePlan('play-production-release', { ...base, play: { ...base.play!, managedPublishing: true } } as ShipConfig);
expect(managed.steps.map(s => s.text).join('\n')).not.toContain('goes live automatically');
expect(managed.askOwner).toEqual([]);
expect(verifyText('play-production-release', base, 'Publishing overview\nChanges not yet sent for review\nSubmit 3 changes for review').ok).toBe(false);
expect(verifyText('play-production-release', base, 'Publishing overview\nChanges in review').ok).toBe(true);
});
test('validate: production with 0 countries is a blocker with a release on the track, a warning without', () => {
const snap = { defaultLanguage: 'en-US', expectedLanguages: [], listings: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, website: null, appAds: null, appAdsLines: [], adsLikely: false, tracks: [], products: [], expectedProducts: [] } as unknown as PlaySnapshot;
expect(playRules({ ...snap, productionCountries: 0 }).find(f => f.code === 'countries')).toMatchObject({ level: 'warn', msg: 'production has 0 countries / regions (the API cannot set them)', fix: 'storebound console plan play-production-countries' });
expect(playRules({ ...snap, productionCountries: 0, tracks: [{ track: 'production', releases: [{ status: 'draft', versionCodes: ['3'] }] }] }).find(f => f.code === 'countries')!.level).toBe('blocker');
expect(playRules({ ...snap, productionCountries: 12 }).find(f => f.code === 'countries')!.level).toBe('ok');
expect(playRules({ ...snap, productionCountries: null }).some(f => f.code === 'countries')).toBe(false);
});
test('config: play.countries and play.managedPublishing', () => {
expect(validateConfig({ ...base, play: { ...base.play!, countries: ['US', 'GB'], managedPublishing: false } })).toEqual([]);
const e = validateConfig({ ...base, play: { ...base.play!, countries: ['usa'], managedPublishing: 'no' as unknown as boolean } }).join('\n');
for (const k of ['play.countries', 'play.managedPublishing']) expect(e).toContain(k);
});
test('App Privacy: the "Published … ago by …" line is the published signal; the plan names the Publish dialog', () => {
expect(appPrivacyPublished('App Privacy\nPublished a few seconds ago by Example Owner')).toBe(true);
expect(appPrivacyPublished('App Privacy\nPublish')).toBe(false);
expect(consolePlan('apple-app-privacy', base).uiFacts.join('\n')).toContain('Publish Your App Privacy Responses?');
});
});
describe('verify, check, validate, config and MCP for the new forms', () => {
test('Store settings and declarations from the page text; the overview from the capture JSON', () => {
expect(verifyPlayDeclaration('play-store-settings', base, 'Store settings\nApp category\nApp or game\nGame\nCategory\nStrategy').ok).toBe(true);
const bad = verifyPlayDeclaration('play-store-settings', base, 'App category\nCategory\nNot selected');
expect(bad.ok).toBe(false);
expect(bad.rows.find(r => r.item === 'App category')).toMatchObject({ status: 'mismatch', seen: 'Not selected' });
expect(verifyText('play-privacy-policy', base, 'Privacy policy URL\nhttps://example.com/privacy').ok).toBe(true);
const done = JSON.stringify({ start: [], text: 'App content' });
expect(verifyAppContent(done).ok).toBe(true);
const open = verifyAppContent(JSON.stringify({ start: ['Start Health apps declaration'], text: '' }));
expect(open.ok).toBe(false);
expect(open.rows.filter(r => r.status === 'mismatch').map(r => r.item)).toEqual(['Health apps']);
expect(verifyAppContent('App content\nHealth apps').ok).toBe(false); // page text alone cannot show the buttons
});
test('check: the category warning, the console-step hint, the owner batch count', () => {
const none = playConsoleReminders({ ...base, play: { packageName: 'com.example.game' } } as ShipConfig);
expect(none.warnings.some(w => w.startsWith('play.category is not set'))).toBe(true);
const set = playConsoleReminders(base);
expect(set.warnings.some(w => w.startsWith('play.category'))).toBe(false);
expect(set.hints.some(h => h.includes('"Strategy" (play.category) is a console step'))).toBe(true);
expect(set.hints.some(h => h.includes('ClassInd'))).toBe(true);
expect(set.warnings.some(w => /Play App content answer\(s\) are not in the config/.test(w))).toBe(true);
expect(playConsoleReminders({ ...base, play: undefined } as ShipConfig)).toEqual({ warnings: [], hints: [] });
});
test('validate: the category reminder (ok with one, warn without), nothing when not passed', () => {
const snap = { defaultLanguage: 'en-US', expectedLanguages: [], listings: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, website: null, appAds: null, appAdsLines: [], adsLikely: false, tracks: [], products: [], expectedProducts: [] } as unknown as PlaySnapshot;
expect(playRules({ ...snap, category: { category: 'Strategy', source: 'play.category' } }).find(f => f.code === 'category')).toMatchObject({ level: 'ok', fix: 'storebound console plan play-store-settings' });
expect(playRules({ ...snap, category: null }).find(f => f.code === 'category')).toMatchObject({ level: 'warn' });
expect(playRules(snap).some(f => f.code === 'category')).toBe(false);
});
test('config: play.category against Play\'s lists, social, randomRewards, appContent', () => {
const cfg = (over: object) => ({ ...base, ...over });
expect(validateConfig(base)).toEqual([]);
expect(validateConfig(cfg({ play: { packageName: 'com.example.game', category: 'Strategy Games' } })).join('\n')).toContain('play.category: "Strategy Games" is not a Play game category');
expect(validateConfig(cfg({ play: { packageName: 'com.example.game', category: 'Tools' } })).join('\n')).toContain('play.category');
expect(validateConfig({ ...cfg({ play: { packageName: 'com.example.game', category: 'Tools' } }), app: { ...base.app, profile: 'app' } })).toEqual([]);
expect(validateConfig(cfg({ social: { interaction: 'yes' } })).join('\n')).toContain('social.interaction');
expect(validateConfig(cfg({ social: { interaction: true, block: 1 } })).join('\n')).toContain('social.block');
expect(validateConfig(cfg({ products: [{ ...base.products![0], randomRewards: 'yes' }] })).join('\n')).toContain('products[0].randomRewards');
const e = validateExtras({ console: { appContent: { government: 'no', financialFeatures: true, healthFeatures: [] } as never } }).join('\n');
for (const k of ['appContent.government', 'appContent.financialFeatures', 'appContent.healthFeatures']) expect(e).toContain(k);
expect(validateExtras({ console: { appContent: { government: false, financialFeatures: false, healthFeatures: ['Other'], advertisingId: true } } })).toEqual([]);
});
test('MCP console_plan and console_verify list every plan form', () => {
expect(CONSOLE_PLAN_FORMS).toEqual([...PLAN_FORM_IDS]);
for (const n of ['console_plan', 'console_verify']) {
const t = TOOLS.find(x => x.name === n)!;
for (const f of PLAN_ONLY_FORM_IDS) expect(validateInput(t, { form: f })).toEqual([]);
}
});
});

View file

@ -1,7 +1,8 @@
<!doctype html>
<!-- MODELED, not recorded: a sanitized stand-in for Play Console > Policy > App content > Content rating (IARC), written
from Google's public help page "Content ratings" (email address, category, questionnaire, Save draft / Next /
Submit on the Summary). The question wording is modeled on the IARC topics, not copied from the console. -->
Save on the Summary: the final button was "Save", not "Submit", on a real run 2026-10-12; page 1 has an IARC
Terms of Use checkbox). The question wording is modeled on the IARC topics, not copied from the console. -->
<html lang="en"><head><meta charset="utf-8"><title>Content rating - Play Console</title><script src="_common.js"></script></head>
<body>
<h1>Content rating</h1>
@ -12,10 +13,11 @@
<label><input type="radio" name="cat"> Social or communication</label>
<label><input type="radio" name="cat"> All other app types</label>
</fieldset>
<p><label><input type="checkbox"> I agree to the IARC Terms of Use</label></p>
<button type="button">Save draft</button> <button type="button" data-next>Next</button>
</section>
<section data-step hidden id="q"><button type="button">Save draft</button> <button type="button" data-next>Next</button></section>
<section data-step hidden><h2>Summary</h2><p>Calculated ratings</p><button type="button">Submit</button></section>
<section data-step hidden><h2>Summary</h2><p>Calculated ratings</p><button type="button">Save</button></section>
<script>
const Q = ['Does the app contain any violence?', 'Does the app contain content that might scare or frighten (fear or horror)?',
'Does the app contain sexual content or nudity?', 'Does the app contain profanity or crude language?',