storebound/tests/console-play-plans.test.ts

449 lines
39 KiB
TypeScript

// 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 { 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 { 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 { readyLines, readyToPublish, readyUndetermined } from '../cli/lib/play-publish';
import { appPrivacyPublished, verifyPublishChangesApi, 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-games-services': 'play-games-services/setup',
'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production', 'play-publish-changes': 'publishing',
};
for (const form of PLAN_ONLY_FORM_IDS.filter(f => f.startsWith('play-'))) {
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');
// 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', 'play-games-services', 'play-publish-changes'].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);
// play-publish-changes verifies through the API (no page text needed)
expect(p.verify.command).toBe(form === 'play-publish-changes' ? 'storebound console verify play-publish-changes' : `storebound console verify ${form} ${p.verify.capture.includes("aria-selected") ? "--from" : "--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', '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: 'Yes', altLabels: ['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')!;
// 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(p.ownerBatch.map(o => o.kind)).toEqual(['agreement', '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', 'agreement'], ['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']);
});
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: 'My app does not have any health features', altLabels: ['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 (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('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);
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', 'Restart review']) expect(p.buttons.neverPress).toContain(b);
// seen live: Submit while a review runs opens "Do you want to restart your review?" with "Restart review" (the owner's)
expect(p.steps.map(s => s.text).join('\n')).toContain('opens "Do you want to restart your review?" instead, with "Restart review"');
expect(p.uiFacts.some(f => f.includes('Do you want to restart your review?') && f.includes('agents never press Restart review'))).toBe(true);
expect(p.buttons.allowed.some(b => b.button === 'Cancel' && b.where.includes('restart your review'))).toBe(true);
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('publish changes: Publish N changes is the final button, never-press holds Discard / Stop managed publishing, ready list from the API', () => {
const managed = { ...base, play: { ...base.play!, managedPublishing: true } } as ShipConfig;
const p = consolePlan('play-publish-changes', managed, { readyChanges: [{ track: 'production', version: '2.0.0', build: '45', rollout: 'full rollout' }] });
expect(p.buttons.final).toBe('Publish N changes');
for (const b of ['Discard changes', 'Stop managed publishing', 'Publish N changes']) expect(p.buttons.neverPress).toContain(b);
expect(p.answers[0]!.text).toContain('production 2.0.0 (45) full rollout');
expect(p.steps.map(s => s.text).join('\n')).toContain('Changes ready to publish');
expect(p.ownerBatch.at(-1)!.text).toContain('Publish N changes');
expect(consolePlan('play-publish-changes', managed).answers[0]!.text).toContain('not read');
});
test('publish changes: ready = managed + completed production the store page does not show; verify by API and by page text', () => {
const tracks = [{ track: 'production', releases: [{ name: '2.0.0 (45)', versionCodes: ['45'], status: 'completed' }] }];
const ready = readyToPublish({ tracks, publicPage: { state: 'public', versionName: '1.9.0' }, managedPublishing: true });
expect(ready).toEqual([{ track: 'production', version: '2.0.0', build: '45', rollout: 'full rollout', liveVersion: '1.9.0' }]);
expect(readyToPublish({ tracks, publicPage: { state: 'public', versionName: '1.9.0' }, managedPublishing: false })).toEqual([]);
expect(readyToPublish({ tracks, publicPage: { state: 'public', versionName: '2.0.0' }, managedPublishing: true })).toEqual([]);
// already published (real case: 2.0.0 / 2000001 full rollout, "Last published on ..."): never ready, whatever the page says
const live = [{ track: 'production', releases: [{ name: '2.0.0 (2000001)', versionCodes: ['2000001'], status: 'completed' }] }];
for (const publicPage of [{ state: 'public', versionName: '2.0.0' }, { state: 'public', versionName: '2.1.0' }, { state: 'public', versionName: null }, { state: 'public', varies: true }, { state: 'notPublic' }, { state: 'unknown' }, { state: 'public', versionName: '1.9.0', stale: true, checkedAt: '2026-10-24T00:00:00Z' }]) {
expect({ publicPage, ready: readyToPublish({ tracks: live, publicPage, managedPublishing: true }) }).toEqual({ publicPage, ready: [] });
}
const unk = Object.assign([], { undetermined: readyUndetermined({ tracks: live, publicPage: { state: 'notPublic' }, managedPublishing: true }) });
expect(unk.undetermined).toBe(true);
expect(verifyPublishChangesApi(unk).ok).toBe(true);
expect(verifyPublishChangesApi(unk).rows[0]!.seen).toContain('not determined');
expect(readyLines(ready).join('\n')).toContain('storebound console plan play-publish-changes');
expect(readyLines([])).toEqual([]);
expect(verifyPublishChangesApi(ready).ok).toBe(false);
expect(verifyPublishChangesApi([]).ok).toBe(true);
expect(verifyText('play-publish-changes', base, 'Publishing overview\nChanges ready to publish\nPublish 2 changes').ok).toBe(false);
expect(verifyText('play-publish-changes', 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: [] });
// every config answer given: the IARC Terms agreement and the demo account typing stay owner steps, no warning
const answered = { ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' }, console: { ...base.console,
contentRating: { category: 'other', answers: Object.fromEntries(IARC_QUESTIONS.map(q => [q, false])), details: { miscellaneous: { naziSymbols: false, koreaHistory: false, terrorism: false, crimes: false } } },
targetAudience: { ages: ['18-over'], appealsToChildren: false, containsAds: false },
appContent: { government: false, financialFeatures: false, healthFeatures: false } } } as ShipConfig;
expect(consolePlan('play-app-content', answered).askOwner.length).toBeGreaterThan(0);
expect(playConsoleReminders(answered).warnings.filter(w => /App content answer/.test(w))).toEqual([]);
});
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([]);
}
});
});