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:
parent
dbd9f2973c
commit
b7bfe89151
37 changed files with 1624 additions and 121 deletions
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
310
tests/console-play-plans.test.ts
Normal file
310
tests/console-play-plans.test.ts
Normal 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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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?',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue