storebound/tests/console-flow.test.ts

507 lines
36 KiB
TypeScript

// storebound console fill walks a whole form: it clicks every navigation control (Next, App Privacy's Get Started,
// Edit next to Data Types, each data type) and App Privacy's in-dialog draft Save itself, stops only for an uncertain
// answer (until the owner set it), an agreement or the final button (Publish, Submit, Save, Create), which stays the
// owner's. Runs fillSession against MODELED pages (tests/fixtures/console) on 127.0.0.1. No network.
// Guardrail: no log line ever asks the owner to click a navigation control.
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { engineWaits, fillSession } from '../cli/console/fill';
import type { ConsoleBrowser } from '../cli/console/session';
import { APPLE_DATA_TYPE_IDS, type AppleDataTypeId, type AppleTypeAnswer } from '../cli/lib/app-privacy';
import type { ShipConfig } from '../cli/lib/config';
import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS, labelNames, setUpAllowed, setUpTexts, TYPE_ALIASES } from '../cli/lib/console-forms';
import { APPLE_PRIVACY_TYPES } from '../cli/lib/app-privacy';
import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, logLines, scaled, TEST_TICK_MS, TEST_WAITS, testScope } from './helpers/browser';
const FIX = join(import.meta.dir, 'fixtures', 'console');
const base0: ShipConfig = {
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' }],
apple: { bundleId: 'com.example.game', primaryLocale: 'en-US' },
play: { packageName: 'com.example.game' },
ads: { appAdsTxt: ['example-network.com, pub-0000, DIRECT, abc123'] },
dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] } } },
console: { contentRating: { answers: { violence: false } }, targetAudience: { ages: ['13-15', '16-17', '18-over'] } },
} as ShipConfig;
/** Every App Privacy answer certain: Device ID and Crash Data collected, nothing else. */
function privacy(over: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {}): ShipConfig {
const types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }]));
types.DEVICE_ID = { collected: true, purposes: ['THIRD_PARTY_ADVERTISING', 'ANALYTICS'], linked: false, tracking: true };
types.CRASH_DATA = { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: false, tracking: false };
return { ...base0, dataSafety: undefined, appPrivacy: { types: { ...types, ...over } } } as unknown as ShipConfig;
}
const shared = await launchTestBrowser('playwright');
const { browser, skipWhy } = shared;
if (!browser) console.warn(`console-flow browser tests skipped: no Chromium installed (${skipWhy}). Run \`bunx playwright install chromium\`.`);
const scope = testScope(() => browser);
// a whole App Privacy walk (6 types x 5 dialog steps) on a loaded CI machine: generous, still bounded
const TEST_MS = scaled(90_000);
const WALK_MS = scaled(75_000);
let server: ReturnType<typeof Bun.serve> | null = null;
let base = '';
beforeAll(() => {
server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: req => {
const name = new URL(req.url).pathname.slice(1);
if (!/^[a-z_-]+\.(html|js)$/.test(name)) return new Response('not found', { status: 404 });
return new Response(Bun.file(join(FIX, name)));
} });
base = `http://127.0.0.1:${server.port}/`;
});
afterEach(() => scope.settle(), AFTER_EACH_MS);
let restoreWaits = () => {};
beforeAll(() => { restoreWaits = engineWaits(TEST_WAITS); });
afterAll(async () => { restoreWaits(); await shared.close(); server?.stop(true); }, AFTER_ALL_MS);
async function local(): Promise<ConsoleBrowser> {
const ctx = scope.track(await browser.newContext());
const page = await ctx.newPage();
let closed = false;
const done = new Promise<void>(r => ctx.on('close', () => { closed = true; r(); }));
return { mode: 'profile', context: ctx, page, pages: () => ctx.pages(), closed: done, isClosed: () => closed, close: () => ctx.close(), cleanup: () => {} };
}
const fast = (log: ReturnType<typeof logLines>) => ({ say: log.say, tickMs: TEST_TICK_MS, navTimeoutMs: scaled(5000), signInWaitMs: 30_000, env: {} });
const clicks = (b: ConsoleBrowser): Promise<string[]> => b.page.evaluate(() => (window as any).__clicks);
const navClicks = (b: ConsoleBrowser): Promise<string[]> => b.page.evaluate(() => (window as any).__navClicks());
/** Guardrail: a line that tells the owner to click a navigation control themselves. */
const NAV_YOURSELF = /\b(Next|Continue|Get Started|Edit|Back|Set Up|Start|Manage|New App)\b[^.]*\byourself\b|\byourself\b[^.]*\b(Next|Continue|Get Started|Edit next|Set Up)\b/;
const navAsked = (lines: string[]) => lines.filter(l => NAV_YOURSELF.test(l));
const PUBLISH = 'review the summary, then press Publish yourself';
describe('App Privacy data types as the real Data Collection dialog names them (label + help text, no separator)', () => {
// the labels in the order of a real dialog (October 2026); each name is the label run together with its help text
const REAL = ['Name', 'Email Address', 'Phone Number', 'Physical Address', 'Other User Contact Info', 'Health', 'Fitness', 'Payment Info', 'Credit Info',
'Other Financial Info', 'Precise Location', 'Coarse Location', 'Sensitive Info', 'Contacts', 'Emails or Text Messages', 'Photos or Videos', 'Audio Data',
'Gameplay Content', 'Customer Support', 'Other User Content', 'Browsing History', 'Search History', 'User ID', 'Device ID', 'Purchases',
'Product Interaction', 'Advertising Data', 'Other Usage Data', 'Crash Data', 'Performance Data', 'Other Diagnostic Data', 'Environment Scanning', 'Hands',
'Head', 'Other Data'];
const names = REAL.map(l => `${l}Such as a placeholder help text`);
test('every Apple data type matches exactly one checkbox; "Name" never matches "Phone Number...", "Head" never "Health..."', () => {
for (const [, label] of Object.values(APPLE_PRIVACY_TYPES)) {
const cands = labelNames(label, TYPE_ALIASES[label]);
// what find() does: the first candidate name that matches anything decides, and it must match one checkbox
const hits = cands.map(c => names.filter(n => (typeof c === 'string' ? n === c : c.test(n)))).find(h => h.length) ?? [];
expect({ label, hits: hits.length }).toEqual({ label, hits: 1 });
expect(hits[0]!.startsWith(TYPE_ALIASES[label]?.[0] ?? label)).toBe(true);
}
expect(labelNames('Contact').some(c => typeof c !== 'string' && c.test('ContactsSuch as a list'))).toBe(false);
});
});
describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
const at = (w: Partial<FlowWhere> = {}): FlowWhere => ({ privacyPage: true, inDialog: false, inDataTypes: false, dataTypes: ['Device ID'], ...w });
test('App Privacy: openers, Next and the in-dialog Save are clicked; Publish never', () => {
expect(flowAllowed('apple-app-privacy', 'Get Started', 'button', at())).toBe(true);
expect(flowAllowed('apple-app-privacy', 'Başlayın', 'button', at())).toBe(true);
expect(flowAllowed('apple-app-privacy', 'Edit', 'button', at({ inDataTypes: true }))).toBe(true);
expect(flowAllowed('apple-app-privacy', 'Edit', 'button', at())).toBe(false); // the Privacy Policy URL's Edit
expect(flowAllowed('apple-app-privacy', 'Set Up Device ID', 'button', at({ inDataTypes: true }))).toBe(true);
expect(flowAllowed('apple-app-privacy', 'Device ID', 'link', at({ inDataTypes: true }))).toBe(true);
expect(flowAllowed('apple-app-privacy', 'Next', 'button', at({ inDialog: true }))).toBe(true);
expect(flowClass('apple-app-privacy', 'Save', 'button', at({ inDialog: true }))).toBe('draft-save');
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at({ inDialog: true }))).toBe(true);
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at())).toBe(false); // not in a questionnaire dialog
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at({ inDialog: true, privacyPage: false }))).toBe(false);
for (const n of ['Publish', 'Yayınla', 'Submit for Review']) expect(flowAllowed('apple-app-privacy', n, 'button', at({ inDialog: true, inDataTypes: true }))).toBe(false);
expect(flowAllowed('apple-app-privacy', 'Get Started', 'checkbox', at())).toBe(false);
});
test('the Set Up text gate: exactly "Set Up <a type storebound answers>" in the Data Types section, outside dialogs', () => {
const w = at({ inDataTypes: true, dataTypes: ['Coarse Location', 'Purchase History', 'Purchases'] });
for (const t of ['Set Up Coarse Location', 'set up coarse location', 'Set Up Purchase History', 'Set Up Purchases']) expect(setUpAllowed(t, w)).toBe(true);
for (const t of ['?', 'More information', 'Set Up Device ID', 'Set Up Coarse Location now', 'Publish', 'Coarse Location', 'Edit']) expect(setUpAllowed(t, w)).toBe(false);
expect(setUpAllowed('Set Up Coarse Location', { ...w, inDataTypes: false })).toBe(false);
expect(setUpAllowed('Set Up Coarse Location', { ...w, inDialog: true })).toBe(false);
expect(setUpAllowed('Set Up Coarse Location', { ...w, privacyPage: false })).toBe(false);
expect(setUpTexts('Purchase History')).toContain('Set Up Purchases');
});
test('other forms: Next is navigation; the content rating\'s Save draft and the Play declarations\' Save are clicked; Submit, Create, Done, Send changes for review stay the owner\'s', () => {
const w = at({ privacyPage: false });
for (const f of ['play-content-rating', 'play-target-audience', 'apple-app-record', 'play-app-record'] as const) {
expect(flowAllowed(f, 'Next', 'button', w)).toBe(true);
expect(flowAllowed(f, 'İleri', 'button', w)).toBe(true);
expect(flowAllowed(f, 'Get Started', 'button', w)).toBe(false);
expect(flowAllowed(f, 'Save draft', 'button', w)).toBe(f === 'play-content-rating');
expect(flowAllowed(f, 'Taslağı kaydet', 'button', w)).toBe(f === 'play-content-rating');
// an App content declaration's Save goes to Google only with Send changes for review (verified 2026-10-13)
expect(flowAllowed(f, 'Save', 'button', w)).toBe(f === 'play-content-rating' || f === 'play-target-audience');
for (const n of ['Submit', 'Create', 'Create app', 'Done', 'Start rollout', 'Publish', 'Send changes for review', 'Submit 3 changes for review', 'Restart review']) expect(flowAllowed(f, n, 'button', w)).toBe(false);
}
expect(flowClass('play-content-rating', 'Save draft', 'button', w)).toBe('draft-save');
expect(flowClass('play-target-audience', 'Save', 'button', w)).toBe('declaration-save');
expect(flowClass('play-content-rating', 'Save', 'button', w)).toBe('declaration-save');
expect(flowClass('apple-app-record', 'Save', 'button', w)).toBe('final-submit');
expect(flowClass('play-content-rating', 'Submit', 'button', w)).toBe('final-submit');
expect(flowAllowed('play-target-audience', 'Save', 'button', at({ privacyPage: false, inDialog: true }))).toBe(false);
});
test('every form has its buttons classified; only navigation, the agreed draft saves (App Privacy, content rating) and the Play declaration saves are automatic', () => {
for (const [f, rules] of Object.entries(FORM_BUTTONS)) {
expect(rules.length).toBeGreaterThan(0);
for (const r of rules) if (r.auto) expect(r.class === 'navigation' || (['apple-app-privacy', 'play-content-rating'].includes(f) && r.class === 'draft-save')
|| (['play-content-rating', 'play-target-audience'].includes(f) && r.class === 'declaration-save')).toBe(true);
expect(rules.some(r => r.class === 'final-submit' && !r.auto)).toBe(true);
}
for (const f of ['play-content-rating', 'play-target-audience'] as const) expect(FORM_BUTTONS[f].some(r => r.button === 'Send changes for review' && r.class === 'final-submit' && !r.auto)).toBe(true);
});
});
describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-app-privacy-flow.html)', () => {
scope.concurrent('first time: Get Started > yes > Next > data types > Save > each type (purposes > Next > linked > Next > tracking > Save); Publish never clicked', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
const c = await clicks(b);
expect(c).not.toContain('Publish');
expect(c).not.toContain('See Details');
expect(c).not.toContain('Edit'); // neither the Privacy Policy Edit nor (first time) the Data Types one
expect(c.filter(x => x === 'Get Started')).toHaveLength(1);
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3); // the type list + Device ID + Crash Data, drafts
expect(await navClicks(b)).toContain('Crash Data'); // the link of the second type
expect(c).toContain('Set Up Device ID');
expect(log.lines).toContain('clicked "Save" (draft-save)');
expect(log.lines.some(m => m.startsWith('answer needed'))).toBe(false);
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
expect((await run).fallback).toBe(false);
}, TEST_MS);
scope.concurrent('answers exist and the list differs from the config: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => {
const b = await local();
const log = logLines();
const config = privacy({ COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS'], linked: false, tracking: false } });
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html?existing=1', ...fast(log) }));
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1);
expect(log.lines.some(m => m.startsWith('Data collection was answered before'))).toBe(true);
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(4); // the list + three types
expect(await clicks(b)).not.toContain('Publish');
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;
}, TEST_MS);
scope.concurrent('an uncertain answer stops the walk on its page until the owner sets it, then it goes on', async () => {
const b = await local();
const log = logLines();
const config = privacy({ DEVICE_ID: { collected: true, purposes: ['ANALYTICS'], tracking: false } }); // linked: not in the config
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
await log.wait('the stop', l => l.some(m => m.startsWith('answer needed on "Device ID: linked to the user"')));
await Bun.sleep(600); // several rounds: Next must not be clicked past the open question
const linked = b.page.getByRole('radio', { name: /^No, Device ID .* not linked/ });
expect(await linked.isVisible()).toBe(true);
expect(await b.page.getByRole('radio', { name: /^No, we do not use Device ID/ }).isVisible()).toBe(false);
await linked.check(); // the owner answers
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
expect(log.lines).toContain('"Device ID: linked to the user" answered: storebound goes on');
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
expect(await clicks(b)).not.toContain('Publish');
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;
}, TEST_MS);
scope.concurrent('no data collected: "No" and the dialog\'s Save, then the summary', async () => {
const b = await local();
const log = logLines();
const types = Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }]));
const config = { ...base0, dataSafety: undefined, appPrivacy: { types } } as unknown as ShipConfig;
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(1);
expect(await clicks(b)).not.toContain('Next');
expect(await clicks(b)).not.toContain('Publish');
await b.context.close();
await run;
}, TEST_MS);
});
/** Six collected types as on the real page (Purchase History shows as "Purchases"), every answer certain. */
const SIX: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {
COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'], linked: false, tracking: false },
EMAILS_OR_TEXT_MESSAGES: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false },
DEVICE_ID: { collected: true, purposes: ['THIRD_PARTY_ADVERTISING', 'ANALYTICS'], linked: false, tracking: true },
PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false },
PRODUCT_INTERACTION: { collected: true, purposes: ['ANALYTICS', 'PRODUCT_PERSONALIZATION'], linked: false, tracking: false },
CRASH_DATA: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: false, tracking: false },
};
const six = (over: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {}) => privacy({ CRASH_DATA: SIX.CRASH_DATA!, ...SIX, ...over });
const SIX_NAMES = ['Coarse Location', 'Emails or Text Messages', 'Device ID', 'Purchase History', 'Product Interaction', 'Crash Data'];
const SETUP = 'apple-app-privacy-setup.html';
const answersOf = (b: ConsoleBrowser) => b.page.evaluate(() => (window as any).__answers);
describe.skipIf(!browser)('App Privacy: each data type set up from the real page ("Set Up <type>" as plain text; fixture apple-app-privacy-setup.html)', () => {
scope.concurrent('every collected type is set up through its dialog (purposes > Next > linked > Next > two info steps > Next > Next > tracking > Save; purpose checkboxes without an accessible name); "?", Publish and the Edits never clicked', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: base + SETUP, ...fast(log) }));
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
expect(await b.page.evaluate(() => (window as any).__setups)).toEqual(SIX_NAMES.map(n => `Set Up ${n}`));
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
const got = await answersOf(b);
expect(got['Device ID']).toEqual({ purposes: ['Third-Party Advertising', 'Analytics'], linked: 'no', tracking: 'yes' });
expect(got['Purchase History']).toEqual({ purposes: ['App Functionality'], linked: 'yes', tracking: 'no' });
expect(got['Product Interaction']).toEqual({ purposes: ['Analytics', 'Product Personalization'], linked: 'no', tracking: 'no' });
expect(Object.keys(got).sort()).toEqual([...SIX_NAMES].sort());
const c = await clicks(b);
for (const never of ['?', 'Publish', 'Edit', 'Cancel', 'Back', 'Definitions and Examples']) expect(c).not.toContain(never);
expect(c.filter(x => x === 'Next')).toHaveLength(24); // purposes, linked and the TWO info steps of each of 6 types
expect(log.lines.filter(m => m === 'an information-only step of the dialog: storebound pressed Next')).toHaveLength(12);
expect(c.filter(x => x === 'Save')).toHaveLength(6);
expect(log.lines).toContain('the Data Types section lists exactly the collected types of the config: storebound sets up each one');
expect(log.lines).toContain('clicked "Set Up Purchase History" (navigation)');
expect(log.lines.some(m => m.startsWith('answer needed'))).toBe(false);
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
expect((await run).fallback).toBe(false);
}, TEST_MS);
// --debug-dump writes a page dump (summary, HTML, screenshot) at every step: the first and the last type are walked
// (the four between were set up before with the config's purposes and are left as they are), which covers every
// kind of dump without six types' worth of screenshots on a loaded machine
scope.concurrent('every step dumped with --debug-dump: the start, each Set Up, each dialog step and each filled step', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
try {
const b = await local();
const log = logLines();
const before = ['Emails or Text Messages:App Functionality', 'Device ID:Third-Party Advertising|Analytics', 'Purchase History:App Functionality', 'Product Interaction:Analytics|Product Personalization'];
const url = `${base}${SETUP}?${before.map(p => `preset=${encodeURIComponent(p)}`).join('&')}`;
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: url, ...fast(log), debugDir: dir, debugEveryStep: true }));
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
expect(await b.page.evaluate(() => (window as any).__setups)).toEqual(['Set Up Coarse Location', 'Set Up Crash Data']);
expect(log.lines).toContain('Device ID was set up before with the purposes of the config: left as it is');
expect(navAsked(log.lines)).toEqual([]);
const names = readdirSync(dir);
for (const want of [/-after-set-up-coarse-location$/, /-coarse-location-after-next$/, /-filled-coarse-location-purposes$/, /-filled-coarse-location-linked-to-/, /-filled-crash-data-tracking$/])
expect(names.some(n => want.test(n))).toBe(true);
const step = names.find(n => /-coarse-location-after-next$/.test(n))!;
expect(JSON.parse(readFileSync(join(dir, step, 'page.json'), 'utf8')).dialogs).toContain('Coarse Location');
await b.context.close();
expect((await run).fallback).toBe(false);
} finally { rmSync(dir, { recursive: true, force: true }); }
}, TEST_MS);
scope.concurrent('an uncertain answer ("you decide") pauses on its step, names it, and goes on once the owner set it', async () => {
const b = await local();
const log = logLines();
const config = six({ PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], tracking: false } }); // linked: the owner's
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + SETUP, ...fast(log) }));
await log.wait('the stop', l => l.some(m => m.startsWith('answer needed on "Purchase History: linked to the user": Purchase History: linked to the user\'s identity?')));
await Bun.sleep(600); // several rounds: Next is not clicked past the open question
const yes = b.page.getByRole('radio', { name: /^Yes, purchase history .* linked/ });
expect(await yes.isVisible()).toBe(true);
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
await yes.check(); // the owner answers
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
expect(log.lines).toContain('"Purchase History: linked to the user" answered: storebound goes on');
expect((await answersOf(b))['Purchase History'].linked).toBe('yes');
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
expect(await clicks(b)).not.toContain('Publish');
expect(await clicks(b)).not.toContain('?');
await b.context.close();
await run;
}, TEST_MS);
scope.concurrent('an unknown dialog step: a debug dump, a pause that names the data type, within the step timeout; nothing clicked past it', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
try {
const b = await local();
const log = logLines();
const started = Date.now();
const run = scope.run(fillSession(b, {
form: 'apple-app-privacy', config: six(), startUrl: `${base}${SETUP}?unknown=Device%20ID`, ...fast(log),
stepTimeoutMs: 1500, idleLimitMs: 60_000, heartbeatMs: 700, debugDir: dir,
}));
await log.wait('the pause', l => l.some(m => m.startsWith('paused on "Device ID"')));
expect(Date.now() - started).toBeLessThan(30_000);
const said = log.lines.join('\n');
expect(said).toMatch(/the "Device ID" dialog step after "Next" is not a page storebound knows .*Buttons in the dialog: "Back", "Cancel", "Next"/);
expect(said).toContain(`Debug saved to ${dir}`);
// the Device ID pause's dump (on a loaded machine a slow earlier step can get a dump of its own first)
const sub = readdirSync(dir).find(n => /unknown/.test(n) && /device-id/.test(n))!;
const pj = JSON.parse(readFileSync(join(dir, sub, 'page.json'), 'utf8'));
expect(pj.dialogs).toContain('Device ID');
expect(pj.controls).toContainEqual({ role: 'switch', name: 'Account identity', checked: false });
await Bun.sleep(500);
const c = await clicks(b);
expect(c.filter(x => x === 'Next')).toHaveLength(2 * 4 + 1); // two types done (purposes, linked, the two info steps), Device ID's first step only
expect(c).not.toContain('Publish');
expect(c).not.toContain('?');
await b.context.close();
expect((await run).fallback).toBe(false);
} finally { rmSync(dir, { recursive: true, force: true }); }
}, TEST_MS);
scope.concurrent('types set up before: the same purposes are left as they are, other purposes are opened with their Edit', async () => {
const b = await local();
const log = logLines();
const url = `${base}${SETUP}?preset=${encodeURIComponent('Crash Data:App Functionality')}&preset=${encodeURIComponent('Device ID:Analytics')}`;
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: url, ...fast(log) }));
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
expect(log.lines).toContain('Crash Data was set up before with the purposes of the config: left as it is');
expect(log.lines).toContain('Device ID was set up before with other purposes than the config: storebound opens it with its Edit');
const got = await answersOf(b);
expect(got['Device ID'].purposes).toEqual(['Third-Party Advertising', 'Analytics']);
expect(got['Crash Data']).toBeUndefined();
expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1); // Device ID's own, never the section's
expect(await clicks(b)).not.toContain('Publish');
await b.context.close();
await run;
}, TEST_MS);
});
describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => {
scope.concurrent('content rating: waits for the owner\'s IARC Terms tick, Save draft and Next by itself on each page, a stop at the uncertain questions until answered, then the Summary\'s Save (a declaration save)', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'play-content-rating', config: base0, startUrl: base + 'play-content-rating.html', ...fast(log) }));
// page 1: Next does nothing until the IARC Terms box is ticked (verified 2026-10-13): storebound waits for the owner
await log.wait('the agreement stop', l => l.some(m => m.startsWith('agreement needed on "Email address and category"') && m.includes('IARC Terms of Use')));
await Bun.sleep(300);
expect(await clicks(b)).toEqual([]);
expect(await b.page.getByRole('checkbox', { name: /IARC Terms of Use/ }).isChecked()).toBe(false); // never ticked by storebound
await b.page.getByRole('checkbox', { name: /IARC Terms of Use/ }).check(); // the owner
await log.wait('the questionnaire stop', l => l.some(m => m.startsWith('answer needed on "Questionnaire"')));
expect(log.lines).toContain('clicked "Save draft" (draft-save)');
expect(log.lines).toContain('clicked "Next" (navigation)');
expect(await clicks(b)).toEqual(['Save draft', 'Next']);
await Bun.sleep(500);
expect(await clicks(b)).toEqual(['Save draft', 'Next']); // nothing saved or moved past the open questions
// the owner answers every outlined question
const open = b.page.locator('[role="radiogroup"][data-storebound-check]');
for (let i = 0, n = await open.count(); i < n; i++) await open.nth(i).getByRole('radio', { name: 'No' }).check();
await log.wait('the final line', l => l.some(m => m.includes('storebound pressed Save') && m.includes('Send changes for review in Publishing overview yourself')));
expect(await clicks(b)).toEqual(['Save draft', 'Next', 'Save draft', 'Next', 'Save']);
expect(log.lines).toContain('clicked "Save" (declaration-save)');
expect(await b.page.getByRole('heading', { name: 'Summary' }).isVisible()).toBe(true);
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;
}, TEST_MS);
scope.concurrent('target audience: Next by itself, a stop at the uncertain appeal question until answered, then Next and the declaration\'s Save; Send changes for review stays the owner\'s', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'play-target-audience', config: base0, startUrl: base + 'play-target-audience.html', ...fast(log) }));
await log.wait('the appeal stop', l => l.some(m => m.startsWith('answer needed on "Appeal to children"')));
expect(await clicks(b)).toEqual(['Next']);
await b.page.getByRole('radiogroup', { name: /appeal to children/ }).getByRole('radio', { name: 'No' }).check();
await log.wait('the final line', l => l.some(m => m.includes('storebound pressed Save')));
expect(await clicks(b)).toEqual(['Next', 'Next', 'Save']);
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;
}, TEST_MS);
scope.concurrent('New App (App Store) and Create app (Play): filled, the final button is the owner\'s, no navigation asked of the owner', async () => {
for (const [form, file, final] of [['apple-app-record', 'apple-app-record.html', 'Create'], ['play-app-record', 'play-create-app.html', 'Create app']] as const) {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form, config: base0, startUrl: base + file, ...fast(log) }));
await log.wait('the final line', l => l.some(m => m.includes(`press ${final} yourself`)));
// Play: only the read-only package name check (verified 2026-10-12); never the final button
expect(await clicks(b)).toEqual(form === 'play-app-record' ? ['Check availability'] : []);
// App Store Connect's New App (React, verified 2026-10-13): a programmatic value set left its state empty; the
// engine types like a person (the fixture's state only follows key presses)
if (form === 'apple-app-record') {
const state = await b.page.evaluate(() => (window as any).__state);
const shown = await b.page.evaluate(() => [...document.querySelectorAll('input[type=text]')].map(i => (i as HTMLInputElement).value));
expect(state.Name).toBe('Example Game');
expect([state.Name, state.SKU]).toEqual(shown);
}
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;
}
}, TEST_MS);
});
describe.skipIf(!browser)('watchdog: an unknown page after a click never means a silent wait', () => {
scope.concurrent('Next leads to a page storebound does not know: heartbeat, a sanitized debug dump, the manual steps, a bounded stop', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
try {
const b = await local();
const log = logLines();
const started = Date.now();
const out = await scope.run(fillSession(b, {
form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-unknown.html', ...fast(log),
stepTimeoutMs: 1500, idleLimitMs: 5000, heartbeatMs: 700, debugDir: dir,
}));
expect(Date.now() - started).toBeLessThan(30_000);
expect(out.timedOut).toBe(true);
expect(out.debugDir).toBe(dir);
expect(b.isClosed()).toBe(true);
const said = log.lines.join('\n');
expect(said).toContain('clicked "Next" (navigation)');
expect(said).toMatch(/still waiting for the page after "Next", \d+ s/);
expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons in the dialog: .*"Done"/);
expect(said).toContain(`Debug saved to ${dir}`);
expect(said).toContain('this part is left to the calling agent with its browser tool (`storebound console plan apple-app-privacy`');
expect(said).toContain('it stops here');
// the dump of the unknown page (on a loaded machine a slow earlier step can get a dump of its own first)
const sub = readdirSync(dir).find(n => /unknown-the-page-after-next/.test(n));
const files = readdirSync(join(dir, sub!)).sort();
expect(files).toEqual(['page.html', 'page.json', 'screen.jpg']);
const pj = JSON.parse(readFileSync(join(dir, sub!, 'page.json'), 'utf8'));
expect(pj.dialogs).toContain('Data Collection');
expect(pj.controls).toContainEqual({ role: 'switch', name: 'Contact details', checked: false });
expect(pj.url).toBe(`${base}apple-app-privacy-unknown.html`);
const all = readFileSync(join(dir, sub!, 'page.html'), 'utf8') + JSON.stringify(pj);
for (const leak of ['sb-token-should-not-leak', 'sb-csrf-should-not-leak', 'typed-by-owner-should-not-leak', 'owner@example.com', '010-9999', 'Example Owner']) expect(all).not.toContain(leak);
expect(all).toContain('[email]');
} finally { rmSync(dir, { recursive: true, force: true }); }
}, TEST_MS);
scope.concurrent('--debug-dump: a dump at the start, after each click and after each filled page', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
try {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'play-target-audience', config: base0, startUrl: base + 'play-target-audience.html', ...fast(log), debugDir: dir, debugEveryStep: true }));
await log.wait('the appeal stop', l => l.some(m => m.startsWith('answer needed on "Appeal to children"')));
await Bun.sleep(500);
const names = readdirSync(dir).sort();
expect(names[0]).toBe('01-start');
expect(names.some(n => /-filled-target-age$/.test(n))).toBe(true);
expect(names.some(n => /-after-next$/.test(n))).toBe(true);
await b.context.close();
await run;
} finally { rmSync(dir, { recursive: true, force: true }); }
}, TEST_MS);
});
describe('every wait of the console engine is bounded (code scan)', () => {
const SRC = join(import.meta.dir, '..', 'cli', 'console');
const files = readdirSync(SRC).filter(f => f.endsWith('.ts'));
/** The text from `open` to its matching `close` (brackets balanced). */
const span = (s: string, at: number, open: string, close: string): string => {
let d = 0;
for (let i = s.indexOf(open, at); i >= 0 && i < s.length; i++) { if (s[i] === open) d++; else if (s[i] === close && --d === 0) return s.slice(at, i + 1); }
return s.slice(at);
};
test('a while loop always checks a deadline (Date.now / end) or the watchdog', () => {
const bad: string[] = [];
for (const f of files) {
const s = readFileSync(join(SRC, f), 'utf8');
for (const m of s.matchAll(/\bwhile \(/g)) {
const cond = span(s, m.index!, '(', ')'), body = span(s, m.index! + cond.length, '{', '}');
if (!/Date\.now\(\)|\bend\b|\bwatch\(/.test(cond + body)) bad.push(`${f}: ${cond.slice(0, 80)}`);
}
}
expect(bad).toEqual([]);
});
test('no Playwright wait or click without a timeout, and the window\'s close is awaited only after a bounded wait or a close()', () => {
const bad: string[] = [];
for (const f of files) {
const lines = readFileSync(join(SRC, f), 'utf8').split('\n');
lines.forEach((l, i) => {
if (/\.waitFor(Selector|URL|Event|LoadState|Function)?\(/.test(l) && !/timeout/.test(l)) bad.push(`${f}:${i + 1} ${l.trim()}`);
if (/\.(click|setChecked|check|uncheck)\(/.test(l) && !/timeout|ACTION_MS/.test(l) && !/^\s*(\/\/|\*)/.test(l)) bad.push(`${f}:${i + 1} ${l.trim()}`);
if (/await [\w.]+\.closed\s*;/.test(l) && !lines.slice(Math.max(0, i - 3), i).some(p => /close\(\)/.test(p))) bad.push(`${f}:${i + 1} ${l.trim()}`);
});
}
expect(bad).toEqual([]);
});
});