// Guardrails from a real run of the console plans (Play Console and App Store Connect, 2026-10-21): new labels with // the old ones as fallbacks, step order, Save before Next, IARC by exact labels, verify from input states, the review // contact lookup and one-line errors for missing values. Pure; every id is a placeholder. import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ShipConfig } from '../cli/lib/config'; import { PLAN_FORM_IDS } from '../cli/lib/console-answers'; import { APPLE_ADDITIONAL_SETUP, consolePlan, IN_PAGE_LOOP, SAVE_CONFIRM, saveBeforeNext } from '../cli/lib/console-plan'; import { INPUT_STATES_CAPTURE, PLAY_ADD_DETAILS, PLAY_ADD_DETAILS_OLD, PLAY_NO_HEALTH, PLAY_RESTRICTED_QUESTION } from '../cli/lib/console-play-plans'; import { parseStates, verifyPlayDeclaration, verifyText } from '../cli/lib/console-verify'; import { contactOf, findReviewContact, noContactMessage } from '../cli/lib/credentials'; import { parseArgs } from '../cli/lib/args'; const base = { 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' }, apple: { bundleId: 'com.example.game' }, products: [{ id: 'com.example.game.coins', type: 'consumable', usd: 0.99, listings: { en: { title: 'Coins', description: 'Coins' } }, randomRewards: false }], console: { appContent: { financialFeatures: false, healthFeatures: false, government: false, advertisingId: false }, contentRating: { category: 'game' } }, reviewAccess: { signInRequired: false }, } as unknown as ShipConfig; const json = (states: { label: string; role: string; checked: boolean; group?: string }[], text = '') => JSON.stringify({ states, text }); const REPO_MARK = ['', 'g', 'i', 't'].join('').replace(/^/, '.'); describe('1. health: the new none label, accepted by verify', () => { test('the sheet ticks the new label, "Other" is the fallback', () => { expect(consolePlan('play-health-apps', base).answers[0]).toMatchObject({ label: PLAY_NO_HEALTH, altLabels: ['Other'], action: 'tick' }); }); test('verify: page text and input states with the new label match', () => { expect(verifyText('play-health-apps', base, `Health apps\n${PLAY_NO_HEALTH}\nSave`).ok).toBe(true); expect(verifyText('play-health-apps', base, json([{ label: PLAY_NO_HEALTH, role: 'checkbox', checked: true }, { label: 'Other', role: 'checkbox', checked: false }])).ok).toBe(true); expect(verifyText('play-health-apps', base, json([{ label: 'Other', role: 'checkbox', checked: true }])).ok).toBe(true); // an older page expect(verifyText('play-health-apps', base, json([{ label: PLAY_NO_HEALTH, role: 'checkbox', checked: false }])).mismatches).toBe(1); }); }); describe('2. sign in details: Yes / No and "Add details", old labels kept', () => { test('labels', () => { const p = consolePlan('play-sign-in-details', base); expect(p.answers[0]).toMatchObject({ question: PLAY_RESTRICTED_QUESTION, label: 'No', altLabels: ['All functionality in my app is available without any access restrictions'] }); const g = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: false, instructions: 'Tap Play as guest.' } } as ShipConfig); expect(g.steps.some(s => s.click?.includes(PLAY_ADD_DETAILS))).toBe(true); expect(g.steps.some(s => s.text.includes(PLAY_ADD_DETAILS_OLD))).toBe(true); expect(g.buttons.allowed.map(b => b.button)).toEqual(expect.arrayContaining([PLAY_ADD_DETAILS, PLAY_ADD_DETAILS_OLD])); expect(g.uiFacts.join(' ')).toContain(PLAY_RESTRICTED_QUESTION); }); test('verify: the No radio of the question from input states', () => { const r = verifyText('play-sign-in-details', base, json([{ label: 'Yes', role: 'radio', checked: false, group: PLAY_RESTRICTED_QUESTION }, { label: 'No', role: 'radio', checked: true, group: PLAY_RESTRICTED_QUESTION }])); expect(r).toMatchObject({ source: 'inputs', ok: true }); }); }); test('3. finance: the boxes on step 1, step 2 information only', () => { const p = consolePlan('play-financial-features', base); const tick = p.steps.findIndex(s => s.answers?.includes('none')); expect(p.steps[tick]!.text).toMatch(/^Step 1: tick/); expect(p.steps[tick + 1]!.text).toMatch(/^Step 2: an information page only/); expect(p.answers[0]!.where).toContain('step 1'); }); test('4. App Privacy: the Additional Setup Required OK is in the steps and the UI facts', () => { const p = consolePlan('apple-app-privacy', { ...base, dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { collect: ['analytics'], shared: false } } } } as ShipConfig); expect(p.steps.some(s => s.text === APPLE_ADDITIONAL_SETUP && s.click?.includes('OK'))).toBe(true); expect(p.uiFacts.some(f => f.includes('Additional Setup Required') && f.includes('first type'))).toBe(true); expect(p.buttons.allowed.map(b => b.button)).toContain('OK'); }); describe('5. verify from input states', () => { test('the plan gives the snippet and --from', () => { for (const f of ['play-advertising-id', 'play-government-apps', 'play-ads', 'play-health-apps'] as const) { const v = consolePlan(f, base).verify; expect(v.capture).toBe(INPUT_STATES_CAPTURE); expect(v.command).toBe(`storebound console verify ${f} --from -`); } for (const s of ['aria-checked', 'aria-selected', '.checked']) expect(INPUT_STATES_CAPTURE).toContain(s); expect(() => new Function(`return ${INPUT_STATES_CAPTURE}`)).not.toThrow(); }); test('advertising-id and government: radios read from the JSON, not "not readable"', () => { const no = json([{ label: 'Yes', role: 'radio', checked: false }, { label: 'No', role: 'radio', checked: true }]); expect(verifyText('play-advertising-id', base, no)).toMatchObject({ ok: true, source: 'inputs' }); expect(verifyText('play-government-apps', base, no)).toMatchObject({ ok: true }); expect(verifyText('play-government-apps', base, json([{ label: 'Yes', role: 'radio', checked: true }, { label: 'No', role: 'radio', checked: false }])).mismatches).toBe(1); expect(verifyPlayDeclaration('play-government-apps', base, 'Government apps\nYes\nNo').rows[0]!.seen).toBe('not readable from page text'); expect(parseStates('plain text')).toBeNull(); }); }); describe('8. Save after every page before Next', () => { test('every Play plan step that answers a page and presses Next saves and confirms first', () => { let checked = 0; for (const form of PLAN_FORM_IDS) { const p = consolePlan(form, base); if (p.store !== 'play') continue; for (const s of p.steps) { const i = s.click?.indexOf('Next') ?? -1; if (i < 0 || !s.answers?.length) continue; checked++; expect(s.click!.slice(0, i)).toContain('Save'); expect(s.text).toContain(SAVE_CONFIRM); } expect(p.buttons.rule).toContain(SAVE_CONFIRM); } expect(checked).toBeGreaterThan(2); }); test('saveBeforeNext leaves information pages alone', () => { expect(saveBeforeNext([{ n: 1, text: 'Read it.', click: ['Next'] }])[0]!.click).toEqual(['Next']); expect(saveBeforeNext([{ n: 1, text: 'Pick.', click: ['Next'], answers: ['a'] }])[0]!.click).toEqual(['Save', 'Next']); }); }); describe('9. IARC Digital purchases by exact labels', () => { test('Yes reveals "Purchases of digital goods", ticked before the follow-ups', () => { const p = consolePlan('play-content-rating', base); expect(p.answers.find(a => a.id === 'q:digitalPurchases')?.value).toBe(true); expect(p.answers.find(a => a.id === 'd:digitalPurchases:digitalGoods')).toMatchObject({ label: 'Purchases of digital goods', action: 'tick', status: 'certain' }); const ids = p.answers.map(a => a.id); expect(ids.indexOf('d:digitalPurchases:digitalGoods')).toBeLessThan(ids.indexOf('d:digitalPurchases:playerTrading')); expect(p.steps.find(s => s.answers?.includes('d:digitalPurchases:digitalGoods'))!.text).toContain('exact console label'); }); test('the in-page helper matches exact labels and refuses blanket answers', () => { expect(IN_PAGE_LOOP).toContain('l === want'); expect(IN_PAGE_LOOP).toContain('refused: a radio needs within'); expect(IN_PAGE_LOOP).toContain('refused: no blanket answers'); expect(IN_PAGE_LOOP).toContain('refused: keywords or a pattern'); expect(() => new Function(IN_PAGE_LOOP)).not.toThrow(); }); test('the helper in a tiny DOM: keyword and blanket answers for the digital purchases block are refused', async () => { const g = globalThis as Record; const radio = (label: string) => ({ getAttribute: (n: string) => (n === 'aria-label' ? label : n === 'aria-checked' ? 'false' : null), hasAttribute: (n: string) => n === 'aria-checked', getClientRects: () => [1], matches: () => true, closest: () => null, click() {} }); const els = [radio('Yes'), radio('No')]; const block = { innerText: 'Digital purchases\nDoes the app offer digital goods, or cash rewards?', querySelector: () => els[0], querySelectorAll: () => els, contains: () => false }; g.document = { body: {}, querySelectorAll: (q: string) => (q.includes('section') ? [block] : els) }; g.window = g; try { new Function(IN_PAGE_LOOP)(); const sb = g.__sb as { run(s: unknown[]): string }; sb.run([{ role: 'radio', label: 'No|no', within: 'cash' }, { role: 'radio', label: 'No' }, { role: 'radio', label: 'No', within: 'cash rewards' }, { role: 'radio', group: true, label: 'No', within: 'Digital purchases' }]); await (g.__run as Promise); const log = (g.__log as string[]).join('\n'); expect(log).toContain('refused: keywords or a pattern'); expect(log).toContain('refused: a radio needs within'); expect(log).toContain('no block whose question is exactly'); expect(log).toContain('refused: no blanket answers'); } finally { for (const k of ['document', '__sb', '__log', '__done', '__run', 'window']) delete g[k]; } }); }); describe('6. the review contact', () => { const contact = { firstName: 'Ada', lastName: 'Example', phone: '+1 555 0100', email: 'review@example.org' }; const repo = () => { const top = mkdtempSync(join(tmpdir(), 'sb-rc-')); mkdirSync(join(top, REPO_MARK)); mkdirSync(join(top, '.storebound')); return top; }; test('the documented shape in the project, a parent folder and other key shapes', () => { const top = repo(); mkdirSync(join(top, 'app')); writeFileSync(join(top, '.storebound', 'credentials.json'), JSON.stringify({ reviewContact: contact })); const r = findReviewContact(join(top, 'app')); expect(r.contact).toEqual(contact); expect(r.checked[0]).toContain(join(top, 'app', '.storebound', 'credentials.json')); expect(contactOf({ contactFirstName: 'Ada', contactLastName: 'Example', contactPhone: '1', contactEmail: 'review@example.org' })?.firstName).toBe('Ada'); expect(contactOf({ name: 'Ada Example', email: 'review@example.org' })).toMatchObject({ firstName: 'Ada', lastName: 'Example' }); writeFileSync(join(top, '.storebound', 'credentials.json'), JSON.stringify({ apple: { reviewContact: contact } })); expect(findReviewContact(top).contact?.email).toBe(contact.email); }); test('none: the error names every file and key checked', () => { const top = repo(); writeFileSync(join(top, '.storebound', 'credentials.json'), JSON.stringify({ reviewContact: { firstName: 'Ada' } })); const m = noContactMessage(top); expect(m).toContain(join(top, '.storebound', 'credentials.json')); expect(m).toContain('reviewContact found but its email is missing'); expect(m).toContain('--contact-from'); writeFileSync(join(top, '.storebound', 'credentials.json'), JSON.stringify({ other: 1 })); expect(noContactMessage(top)).toContain('reviewContact, apple.reviewContact'); }); }); describe('7. one-line errors for missing required values', () => { test('missing and value-less flags', () => { const fix = 'storebound apple status shows the editable version'; expect(() => parseArgs([]).required('version', 'x.y.z', fix)).toThrow(`--version is required (${fix})`); expect(() => parseArgs(['--version']).required('version', 'x.y.z', fix)).toThrow('is required'); expect(() => parseArgs(['--version', '--dry']).required('version', 'x.y.z')).toThrow('is required'); expect(parseArgs(['--version', '1.2.0']).required('version', 'x.y.z')).toBe('1.2.0'); }); test('the CLI prints one line for apple listing without --version', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-one-')); writeFileSync(join(dir, 'storebound.config.ts'), `export default ${JSON.stringify(base)};\n`); const p = Bun.spawnSync(['bun', join(import.meta.dir, '..', 'cli', 'index.ts'), 'apple', 'listing'], { cwd: dir, env: { ...process.env, STOREBOUND_NO_NETWORK: '1' } }); expect(p.stderr.toString().trim()).toBe('error: --version is required (storebound apple status shows the editable version)'); expect(p.exitCode).toBe(1); }); }); test('a reviewContact that is there with empty fields is named as such, not "no review contact"', async () => { const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const { findReviewContact, noContactMessage } = await import('../cli/lib/credentials'); const d = mkdtempSync(join(tmpdir(), 'sb-contact-empty-')); try { mkdirSync(join(d, '.storebound'), { recursive: true }); mkdirSync(join(d, '.git')); writeFileSync(join(d, '.storebound', 'credentials.json'), JSON.stringify({ reviewContact: { firstName: '', lastName: '', phone: '', email: '' } })); const r = findReviewContact(d); expect(r.contact).toBeNull(); expect(noContactMessage(d, r.checked)).toContain('reviewContact found but all its fields are empty'); } finally { rmSync(d, { recursive: true, force: true }); } });