// A guest-play game (no sign-in) that Play rejected as a login wall ("provide valid login credentials"): the first // screen had an empty name field, Continue disabled until a name was typed and an "I have a code" link. The guardrails: // `storebound check` warns when such an app has no reviewer steps, and the play-sign-in-details plan declares one entry // without credentials when reviewAccess.instructions holds the guest steps. Pure; every id is a placeholder. import { describe, expect, test } from 'bun:test'; import type { ShipConfig } from '../cli/lib/config'; import { consolePlan } from '../cli/lib/console-plan'; import { GUEST_ENTRY_NAME, PLAY_ACCESS_INFO_MAX, PLAY_FULL_ACCESS_BOX } from '../cli/lib/console-play-plans'; import { verifyPlayDeclaration } from '../cli/lib/console-verify'; import { profileIssues } from '../cli/lib/profile'; import { SIGNALS } from '../cli/lib/scan/rules'; 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' }, } as ShipConfig; const STEPS = 'No account needed. 1) Open the app. 2) The player name is pre-filled: tap Continue. 3) "I Have a Code" is optional (moves progress from another device); skip it. Every feature works as a guest.'; const guest = { ...base, reviewAccess: { signInRequired: false, instructions: STEPS } } as ShipConfig; describe('check: guest-play apps that Play may read as a login wall', () => { const msg = 'Play may reject as a login wall; add guest steps'; const warns = (c: ShipConfig, firstRunGate?: string[]) => profileIssues(c, { demoAccount: false, firstRunGate }).filter(i => i.level === 'warn').map(i => i.msg); test('a first-run gate in the code or IAP without instructions warns', () => { const noSteps = { ...base, reviewAccess: { signInRequired: false } } as ShipConfig; expect(warns(noSteps, ['src/onboarding.tsx:12']).find(m => m.startsWith(msg))).toContain('src/onboarding.tsx:12'); const iap = { ...noSteps, products: [{ id: 'com.example.game.coins', type: 'consumable', usd: 0.99, listings: { en: { title: 'Coins', description: 'Coins' } } }] } as ShipConfig; expect(warns(iap).find(m => m.startsWith(msg))).toContain('in-app purchases'); // no reviewAccess at all is the same: nothing tells the reviewer how to get in expect(warns(base, ['src/a.ts:1']).some(m => m.startsWith(msg))).toBe(true); }); test('quiet with guest steps, without a gate, for a sign-in app and without Play', () => { expect(warns(guest, ['src/onboarding.tsx:12']).some(m => m.startsWith(msg))).toBe(false); expect(warns({ ...base, reviewAccess: { signInRequired: false } } as ShipConfig, []).some(m => m.startsWith(msg))).toBe(false); expect(warns({ ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' } } as ShipConfig, ['src/a.ts:1']).some(m => m.startsWith(msg))).toBe(false); const { play: _, ...noPlay } = base; expect(warns({ ...noPlay, apple: { bundleId: 'com.example.game' } } as ShipConfig, ['src/a.ts:1']).some(m => m.startsWith(msg))).toBe(false); }); test('instructions longer than Play\'s 500 characters warn', () => { expect(warns({ ...base, reviewAccess: { signInRequired: false, instructions: 'x'.repeat(PLAY_ACCESS_INFO_MAX + 1) } } as ShipConfig).join('\n')).toContain('at most 500'); }); test('the first-run gate signal: name entry, code / transfer entry, account transfer UI', () => { for (const t of [ '', "label: 'Choose a player name',", 'I Have a Code', "title: 'Enter transfer code'", 'const transferCode = useState("")', "'Transfer your progress'", 'Recovery code', "button('Restore progress')", ]) expect({ t, hit: SIGNALS.firstRunGate.test(t) }).toEqual({ t, hit: true }); for (const t of [ "import { transferCode } from './transfer';", 'const playerName = save.name;', '// enter your name later', 'const code = 42;', "'Your name is shown on the leaderboard'", ]) expect({ t, hit: SIGNALS.firstRunGate.test(t) }).toEqual({ t, hit: false }); }); }); describe('plan play-sign-in-details: guest steps', () => { test('instructions with no sign-in: restricted, one entry without credentials, the box is the owner\'s', () => { const p = consolePlan('play-sign-in-details', guest); 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 === 'entryName')).toMatchObject({ value: GUEST_ENTRY_NAME, status: 'certain', action: 'type' }); expect(p.answers.find(a => a.id === 'credentials')).toMatchObject({ value: null, status: 'certain', text: 'leave both empty (no account)' }); expect(p.answers.find(a => a.id === 'credentials')!.action).toBeUndefined(); expect(p.answers.find(a => a.id === 'instructions')).toMatchObject({ value: STEPS, status: 'certain', label: 'Any other information required to access your app' }); expect(p.answers.find(a => a.id === 'fullAccess')).toMatchObject({ status: 'agreement', value: null, control: 'checkbox', label: PLAY_FULL_ACCESS_BOX }); expect(p.answers.find(a => a.id === 'fullAccess')!.action).toBeUndefined(); // the agent presses Add instructions, Add and Save; the box and Send changes for review are the owner's expect(p.buttons.allowed.map(b => b.button)).toEqual(expect.arrayContaining(['Add instructions', 'Add', 'Save'])); expect(p.buttons.final).toBe('Send changes for review'); expect(p.buttons.neverPress).toContain('Send changes for review'); expect(p.ownerBatch.map(o => [o.when, o.kind])).toEqual([['after', 'agreement'], ['after', 'final']]); expect(p.ownerBatch[0]!.text).toContain(PLAY_FULL_ACCESS_BOX); expect(p.askOwner.join('\n')).toContain('the owner ticks it'); const text = p.steps.map(s => s.text).join('\n'); expect(text).toContain(`"${GUEST_ENTRY_NAME}"`); expect(text).toContain('leave Username and Password empty'); expect(text).toMatch(/unticked and stop/); }); test('verify reads the entry name and the steps back, never the box', () => { const page = `Sign in details\nAll or some functionality in my app is restricted\n${GUEST_ENTRY_NAME}\n${STEPS}`; const r = verifyPlayDeclaration('play-sign-in-details', guest, page); expect(r.rows.filter(x => x.status === 'match').map(x => x.item)).toEqual(['Is any functionality restricted (sign-in, membership, location)?', 'Name of the instructions entry', 'Instructions for the reviewer']); expect(r.rows.some(x => /full access/.test(x.item))).toBe(false); expect(r.mismatches).toBe(0); }); test('steps over 500 characters are an owner check', () => { const p = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: false, instructions: 'x'.repeat(PLAY_ACCESS_INFO_MAX + 1) } } as ShipConfig); expect(p.answers.find(a => a.id === 'instructions')).toMatchObject({ status: 'owner-check' }); }); test('no instructions: the "No restrictions" path stays', () => { const p = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: false } } as ShipConfig); expect(p.answers.map(a => [a.id, a.value])).toEqual([['access', false]]); expect(p.answers[0]!.label).toBe('All functionality in my app is available without any access restrictions'); expect(p.buttons.allowed.map(b => b.button)).not.toContain('Add'); expect(p.ownerBatch.map(o => o.kind)).toEqual(['final']); }); });