100 lines
7.6 KiB
TypeScript
100 lines
7.6 KiB
TypeScript
// 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 [
|
|
'<input placeholder="Enter your name" />', "label: 'Choose a player name',", '<a>I Have a Code</a>', "title: 'Enter transfer code'",
|
|
'const transferCode = useState("")', "'Transfer your progress'", '<Text>Recovery code</Text>', "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']);
|
|
});
|
|
});
|