console plans: guardrails from a real run (2026-10-21): health none label and Sign in details Yes/No + Add details (old labels as altLabels, verify accepts both), finance boxes on step 1, App Privacy Additional Setup Required OK, verify from DOM input states (verify.capture, --from), Save + confirm before every Next on Play pages, IARC Digital purchases boxes and an exact-label in-page helper that refuses patterns and blanket answers; review contact found in parent folders, the profile and more key shapes with the files checked listed; one-line errors for missing required values (Args.required); pitfalls 86-87, tests
This commit is contained in:
parent
a6dab8f3aa
commit
335a4124ab
23 changed files with 486 additions and 74 deletions
191
tests/console-guardrails-1021.test.ts
Normal file
191
tests/console-guardrails-1021.test.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -58,7 +58,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
|
|||
p.ownerBatch.forEach((o, i) => expect(o.n).toBe(i + 1));
|
||||
expect(planText(p).indexOf('Owner batch')).toBeLessThan(planText(p).indexOf('Steps:'));
|
||||
expect(p.uiFacts.join('\n')).toContain(PLAY_MATERIAL_FACT);
|
||||
expect(p.verify.command).toBe(`storebound console verify ${form} --from-text -`);
|
||||
expect(p.verify.command).toBe(`storebound console verify ${form} ${p.verify.capture.includes("aria-selected") ? "--from" : "--from-text"} -`);
|
||||
expect(p.inPageLoop).toBe(IN_PAGE_LOOP);
|
||||
expect(planText(p)).toContain('window.__sb.run');
|
||||
expect(JSON.parse(JSON.stringify(p))).toEqual(p);
|
||||
|
|
@ -179,7 +179,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
|
|||
|
||||
test('Sign in details: the demo account never printed, typed by the owner', () => {
|
||||
const p = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' } } as ShipConfig, { demoAccount: true });
|
||||
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 === 'access')).toMatchObject({ value: true, status: 'certain', label: 'Yes', altLabels: ['All or some functionality in my app is restricted'] });
|
||||
expect(p.answers.find(a => a.id === 'instructions')).toMatchObject({ status: 'certain', action: 'type' });
|
||||
const cred = p.answers.find(a => a.id === 'credentials')!;
|
||||
// in .storebound/credentials.json: the agent types it, never printed (no value in the plan)
|
||||
|
|
@ -200,7 +200,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
|
|||
expect(ad.answers.filter(a => a.action === 'tick').map(a => a.label).sort()).toEqual(['Advertising or marketing', 'Analytics']);
|
||||
const none = { ...base, console: { ...base.console, appContent: { financialFeatures: false, healthFeatures: false, government: false } } } as ShipConfig;
|
||||
expect(consolePlan('play-financial-features', none).answers).toMatchObject([{ id: 'none', action: 'tick', label: 'My app doesn\'t provide any financial features', status: 'certain' }]);
|
||||
expect(consolePlan('play-health-apps', none).answers).toMatchObject([{ id: 'none', action: 'tick', label: 'Other', status: 'certain' }]);
|
||||
expect(consolePlan('play-health-apps', none).answers).toMatchObject([{ id: 'none', action: 'tick', label: 'My app does not have any health features', altLabels: ['Other'], status: 'certain' }]);
|
||||
expect(consolePlan('play-government-apps', none).answers).toMatchObject([{ id: 'government', value: false, label: 'No', status: 'certain' }]);
|
||||
expect(consolePlan('play-financial-features', base).answers).toMatchObject([{ id: 'features', status: 'ask-owner' }]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ describe('full CLI coverage: new methods', () => {
|
|||
describe('the owner flag', () => {
|
||||
const saved = { ...process.env };
|
||||
afterEach(() => { for (const k of ['STOREBOUND_SERVE', 'STOREBOUND_OWNER_CONFIRMED']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } });
|
||||
const ctx = { loaded: {} as never, args: { has: (n: string) => n === 'yes', get: () => undefined, str: (_: string, d: string) => d, num: (_: string, d: number) => d, list: () => [], many: () => [], _: [] } };
|
||||
const ctx = { loaded: {} as never, args: { has: (n: string) => n === 'yes', get: () => undefined, str: (_: string, d: string) => d, num: (_: string, d: number) => d, list: () => [], many: () => [], required: (n: string) => { throw new Error(`--${n} is required`); }, _: [] } };
|
||||
test('under serve: no by default, even with --yes; yes only with the owner flag', async () => {
|
||||
process.env.STOREBOUND_SERVE = '1';
|
||||
delete process.env.STOREBOUND_OWNER_CONFIRMED;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue