storebound/tests/console-guardrails-1021.test.ts

191 lines
13 KiB
TypeScript

// 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<string, unknown>;
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<void>);
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, apple.reviewContact');
expect(m).toContain('--contact-from');
});
});
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 <x.y.z> 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 <x.y.z> is required (storebound apple status shows the editable version)');
expect(p.exitCode).toBe(1);
});
});