// 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 { 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 } from '../cli/lib/console-forms'; import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, logLines, 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, deletionRequest: false, 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> = {}): ShipConfig { const types: Partial> = 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); const TEST_MS = 60_000; let server: ReturnType | 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); afterAll(async () => { await shared.close(); server?.stop(true); }, AFTER_ALL_MS); async function local(): Promise { const ctx = scope.track(await browser.newContext()); const page = await ctx.newPage(); let closed = false; const done = new Promise(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) => ({ say: log.say, tickMs: 100, navTimeoutMs: 5000, signInWaitMs: 30_000, env: {} }); const clicks = (b: ConsoleBrowser): Promise => b.page.evaluate(() => (window as any).__clicks); const navClicks = (b: ConsoleBrowser): Promise => 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('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => { const at = (w: Partial = {}): 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('other forms: Next is navigation; the content rating\'s Save draft is clicked; Save, Submit, Create, Done 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'); for (const n of ['Save', 'Submit', 'Create', 'Create app', 'Done', 'Start rollout', 'Publish']) 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('final-submit'); expect(flowClass('play-content-rating', 'Submit', 'button', w)).toBe('final-submit'); }); test('every form has its buttons classified; only navigation and the agreed draft saves (App Privacy, content rating) 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')).toBe(true); expect(rules.some(r => r.class === 'final-submit' && !r.auto)).toBe(true); } }); }); describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-app-privacy-flow.html)', () => { test('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))); 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); test('answers exist: Edit next to Data Types (never the Privacy Policy Edit), then every type', 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?existing=1', ...fast(log) })); await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH))); 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(3); expect(await clicks(b)).not.toContain('Publish'); expect(navAsked(log.lines)).toEqual([]); await b.context.close(); await run; }, TEST_MS); test('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))); 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); test('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))); 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); }); describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => { test('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; Submit never clicked', 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) })); 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('press Submit yourself'))); expect(await clicks(b)).toEqual(['Save draft', 'Next', 'Save draft', 'Next']); expect(await b.page.getByRole('heading', { name: 'Summary' }).isVisible()).toBe(true); expect(navAsked(log.lines)).toEqual([]); await b.context.close(); await run; }, TEST_MS); test('target audience: Next by itself, a stop at the uncertain appeal question until answered, then Next; Save 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('press Save yourself'))); expect(await clicks(b)).toEqual(['Next', 'Next']); expect(navAsked(log.lines)).toEqual([]); await b.context.close(); await run; }, TEST_MS); test('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`))); expect(await clicks(b)).toEqual([]); 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', () => { test('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 there: .*"Done"/); expect(said).toContain(`Debug saved to ${dir}`); expect(said).toContain('do this part yourself'); expect(said).toContain('it stops here'); const [sub] = readdirSync(dir); 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); test('--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 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 (/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([]); }); });