- `storebound console plan <form> [--json] [--lookup]`: read-only plan for an AI agent that drives the browser itself: start URL (deep links), ordered steps, answer sheet with the console's exact labels (certain / ask owner, curly quotes noted), never-press list, verified UI facts, driving guidance (in-page async loop + window.__log), verify instructions. All six forms. - `storebound console verify <form> [--from-text <file|->]`: App Privacy per-type summaries from page text, Play Create app from input states, content rating / target audience summaries, App Store record and age rating from the API; exit 1 on a mismatch. - MCP: console_plan, console_verify (read-only); console fill stays off MCP. - App Privacy (verified 2026-10-12): two information steps, unlabeled purpose checkboxes (nearest parent text), "Definitions and Examples" never pressed; fixture and engine follow. - Play Create app (verified 2026-10-12): /developers/<id>/create-new-app, App package name + read-only Check availability, material-radio inner [role=radio] click, app dashboard URL and play.appId, service account "Add app" as an owner step. - console-autofill skill rewritten agent-first; agents-core rule 10; README; pitfalls 12, 18 (confirmed sign-out), 19; CHANGELOG; play-first-release.
465 lines
32 KiB
TypeScript
465 lines
32 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 { 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, 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<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 = 90_000;
|
|
const WALK_MS = 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);
|
|
afterAll(async () => { 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: 100, navTimeoutMs: 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 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)), 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);
|
|
|
|
test('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);
|
|
|
|
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)), 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);
|
|
|
|
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)), 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)', () => {
|
|
test('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; every step dumped', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
|
try {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: base + SETUP, ...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(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([]);
|
|
// --debug-dump: the start, each Set Up, each dialog step and each filled step
|
|
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);
|
|
|
|
test('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);
|
|
|
|
test('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}`);
|
|
const sub = readdirSync(dir).find(n => /unknown/.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);
|
|
|
|
test('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', () => {
|
|
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`)));
|
|
// 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'] : []);
|
|
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 in the dialog: .*"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 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([]);
|
|
});
|
|
});
|