209 lines
14 KiB
TypeScript
209 lines
14 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 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 <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);
|
|
});
|
|
});
|
|
|
|
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 }); }
|
|
});
|