console: agent-first mode (console plan / console verify), verified App Privacy and Play Create app facts

- `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.
This commit is contained in:
CRTkafa 2026-10-12 08:22:03 +08:00
parent a9261a8052
commit c9a7f095c4
34 changed files with 1430 additions and 113 deletions

View file

@ -194,6 +194,8 @@ beforeAll(() => {
const dev = path.match(/^\/playm\/u\/0\/developers(?:\/(\d+)\/app-list)?$/);
// playn: the same chooser; every account's app list has a Create app button that opens the Create app form
const devNew = path.match(/^\/play([nrs])\/u\/0\/developers(?:\/(\d+)\/app-list)?$/);
// the Create app form of one developer account has its own URL (verified 2026-10-12)
if (/^\/play[nrs]\/u\/0\/developers\/\d+\/create-new-app$/.test(path)) return new Response(Bun.file(join(FIX, 'play-create-app.html')));
// playn: the modeled chooser; playr: the chooser as the owner's screenshot shows it; plays: one account only
const name = path.endsWith('/_common.js') ? '_common.js' : devNew ? (devNew[2] ? 'play-home.html' : ({ n: 'play-chooser.html', r: 'play-chooser-real.html', s: 'play-chooser-one.html' } as Record<string, string>)[devNew[1]!]!)
: dev ? (!dev[1] ? 'play-chooser.html' : dev[1] === '4444' ? 'play-apps.html' : 'play-apps-other.html')
@ -744,12 +746,13 @@ describe('play-app-record (pure)', () => {
expect(bare.get('type')?.certain).toBe(false);
expect(bare.get('price')).toMatchObject({ value: null, certain: false });
});
test('guidance and notes: package name from the first AAB upload, free is final, declarations are yours', () => {
test('guidance and notes: the package name field and its Check availability, free is final, declarations are yours', () => {
const r = consoleResult('play-app-record', config);
const g = r.guidance.join('\n');
expect(g).toContain('first AAB upload');
expect(g).toContain('play upload --track internal');
expect(g).toContain('flow run release');
expect(g).not.toContain('first AAB upload');
expect(g).toContain('App package name: com.example.game');
expect(g).toContain('"Check availability" (read-only)');
expect(g).toContain('Package name available');
expect(g).toContain('Free or paid: Free (check: Owner check');
expect(g).toContain('Declarations: Developer Program Policies: you tick it');
expect(r.guidance.at(-1)).toContain('press Create app yourself');
@ -760,12 +763,14 @@ describe('play-app-record (pure)', () => {
const e = validateExtras({ play: { packageName: 'com.example.game', developerAccount: ' ', name: 'x'.repeat(31), free: 'yes' as unknown as boolean } }).join('\n');
for (const k of ['play.developerAccount', 'play.name', 'play.free']) expect(e).toContain(k);
});
test('navigation plan: the app list (of play.developerId) > Create app > the form', () => {
test('navigation plan: the app list > Create app > the form; with play.developerId the form\'s own URL', () => {
const p = navPlan('play-app-record', config);
expect(p.map(s => s.kind)).toEqual(['goto', 'click', 'wait']);
expect(p[1]).toMatchObject({ createApp: true, names: CREATE_APP_OPENERS });
expect(navPlan('play-app-record', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig)[0])
.toMatchObject({ url: 'https://play.google.com/console/u/0/developers/4444/app-list' });
const direct = navPlan('play-app-record', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig);
expect(direct.map(s => s.kind)).toEqual(['goto', 'wait']);
expect(direct[0]).toMatchObject({ url: 'https://play.google.com/console/u/0/developers/4444/create-new-app' });
expect(formMeta('play-app-record', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig).url).toBe('https://play.google.com/console/u/0/developers/4444/create-new-app');
});
test('the Create app allowlist: only the page-level opener on the app list, never inside the form (EN and TR)', () => {
const list = { appList: true, formOpen: false, inForm: false };
@ -803,7 +808,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
expect(await navClicks(page)).toEqual([opener]);
const s = await fillPage(page, def(), ans());
expect(s.fallback).toBe(false);
expect(s.filled.sort()).toEqual(['language', 'name', 'price', 'type']);
expect(s.filled.sort()).toEqual(['language', 'name', 'packageName', 'price', 'type']);
expect(s.highlighted.sort()).toEqual(['agree:exportLaws', 'agree:policies']);
expect(await page.getByRole('textbox', { name: nameField, exact: true }).inputValue()).toBe('Example Game');
expect(await page.getByRole('combobox').evaluate((e: HTMLSelectElement) => e.selectedOptions[0]!.textContent)).toMatch(/– en-US$/);
@ -839,7 +844,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
const s = await fillPage(page, def(), ans(), new Set(), true);
expect(s.fallback).toBe(false);
expect(s.missing).toEqual(['language']);
expect(s.filled.sort()).toEqual(['name', 'price', 'type']);
expect(s.filled.sort()).toEqual(['name', 'packageName', 'price', 'type']);
expect(s.highlighted).toContain('language');
expect(await agreed(page)).toBe(0);
expect(await clicks(page)).toEqual([]);
@ -875,8 +880,8 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
({ r, page } = await go({ ...config, play: { ...config.play!, developerAccount: 'example games ltd' } } as ShipConfig, file, said = []));
expect(r.nav.ok).toBe(true);
expect(said).toContain('creating the app in developer account "Example Games Ltd" (4444, play.developerAccount)');
expect(new URL(page.url()).pathname).toBe('/play-create-app.html');
expect(await navClicks(page)).toEqual(['Create app']);
expect(new URL(page.url()).pathname).toMatch(/\/developers\/4444\/create-new-app$/);
expect(await navClicks(page)).toEqual([]); // the form's own URL: no Create app click at all
expect(readAccounts(file).play['com.example.game']).toMatchObject({ developerId: '4444', account: 'Example Games Ltd' });
await page.close();
@ -895,7 +900,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
({ r, page } = await go(config, tmpAccounts(), said = [], { STOREBOUND_PLAY_DEVELOPER_ID: '2222' }));
expect(r.nav.ok).toBe(true);
expect(said).toContain('creating the app in developer account 2222 (play.developerId)');
expect(said).toContain('opening the form, step 1/3: open the app list of the developer account');
expect(said).toContain('opening the form, step 1/2: open the Create app form of the developer account');
await page.close();
}, SLOW_TEST_MS);
@ -911,7 +916,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
let { r, page } = await go('Example AI', said);
expect(r.nav.ok).toBe(true);
expect(said).toContain('creating the app in developer account "Example AI" (7000000005, play.developerAccount)');
expect(new URL(page.url()).pathname).toBe('/play-create-app.html');
expect(new URL(page.url()).pathname).toMatch(/\/developers\/7000000005\/create-new-app$/);
await page.close();
// spaces and case aside
({ r, page } = await go(' example ai ', said = []));
@ -951,17 +956,22 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
}, SLOW_TEST_MS);
test('fillSession: notes logged, form filled, the run ends without any submit', async () => {
const b = await consoleBrowser(u => (u.hostname !== 'play.google.com' ? null : u.pathname === '/console' || u.pathname.endsWith('/app-list') ? 'play-home.html' : u.pathname === '/play-create-app.html' ? 'play-create-app.html' : null));
const b = await consoleBrowser(u => (u.hostname !== 'play.google.com' ? null : u.pathname === '/console' || u.pathname.endsWith('/app-list') ? 'play-home.html' : u.pathname === '/play-create-app.html' || u.pathname.endsWith('/create-new-app') ? 'play-create-app.html' : null));
const log = logLines(), said = log.lines;
const c = { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig;
const done = scope.run(fillSession(b, { form: 'play-app-record', config: c, ...fast(log) }));
await log.wait('the Create app form filled', l => l.some(m => /^Create app: filled 4, check 2/.test(m)));
expect(said.some(m => m.includes('first AAB upload'))).toBe(true);
await log.wait('the Create app form filled', l => l.some(m => /^Create app: filled 5, check 2/.test(m)));
expect(said.some(m => m.includes('Check availability'))).toBe(true);
await log.wait('the availability check', l => l.includes('App package name: "Package name available"'));
expect(said).toContain('clicked "Check availability" (navigation)');
// the material radios: the inner [role=radio] was clicked, so the form's model has them and no "Select …" hint shows
expect(await b.page.evaluate(() => (window as any).__model)).toEqual({ type: 'Game', price: 'Free' });
expect(await b.page.getByText(/^Select (app or game|free or paid)$/).filter({ visible: true }).count()).toBe(0);
expect(said.some(m => m.includes('never become paid'))).toBe(true);
await log.wait('the final line', l => l.some(m => m.includes('press Create app yourself')));
expect(await clicks(b.page)).toEqual([]);
expect(await clicks(b.page)).toEqual(['Check availability']); // read-only; never the final Create app
await b.context.close();
expect((await done).filled).toBe(4);
expect((await done).filled).toBe(5);
}, SLOW_TEST_MS);
});
@ -986,7 +996,7 @@ const playOgb = (u: URL): string | null => {
if (u.hostname === 'accounts.google.com' || u.hostname === 'ogs.google.com') return u.pathname.startsWith('/v3/signin') ? 'google-signin.html' : 'google-account-widget.html';
if (u.hostname !== 'play.google.com') return null;
if (u.pathname.endsWith('/app-list')) return 'play-home-ogb.html';
if (u.pathname === '/play-create-app.html') return 'play-create-app.html';
if (u.pathname === '/play-create-app.html' || u.pathname.endsWith('/create-new-app')) return 'play-create-app.html';
return u.pathname === '/console' || u.pathname === '/console/u/0/developers' ? 'play-chooser-ogb.html' : null;
};
const frameOn = (page: any, host: string): boolean => page.frames().some((f: any) => { try { return new URL(f.url()).hostname === host; } catch { return false; } });

View file

@ -37,7 +37,9 @@ 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;
// 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 = '';
@ -141,7 +143,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
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)));
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');
@ -162,7 +164,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
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)));
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
@ -183,7 +185,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
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)));
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');
@ -198,7 +200,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
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)));
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');
@ -222,13 +224,13 @@ 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 > tracking > Save); "?", Publish and the Edits never clicked; every step dumped', async () => {
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)));
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);
@ -237,8 +239,9 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
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']) expect(c).not.toContain(never);
expect(c.filter(x => x === 'Next')).toHaveLength(18);
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)');
@ -266,7 +269,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
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)));
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);
@ -297,7 +300,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
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 * 3 + 1); // two types done (purposes, linked, definitions), Device ID's first step only
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();
@ -310,7 +313,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
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)));
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);
@ -365,7 +368,8 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
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([]);
// 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;

197
tests/console-plan.test.ts Normal file
View file

@ -0,0 +1,197 @@
// The agent-first console mode: `console plan` (what an AI agent needs to drive a console form with its own browser
// tool) and `console verify` (the finished page against the config). Pure; the page text samples are SYNTHESIZED
// from the structure verified on a real App Privacy page (2026-10-12) and the Play Create app form; no real app data.
import { describe, expect, test } from 'bun:test';
import { APPLE_DATA_TYPE_IDS, type AppleDataTypeId, type AppleTypeAnswer } from '../cli/lib/app-privacy';
import type { ShipConfig } from '../cli/lib/config';
import { FORM_IDS } from '../cli/lib/console-answers';
import { consolePlan, nameText, PLAY_SA_PERMISSIONS, planText } from '../cli/lib/console-plan';
import { forbiddenControl } from '../cli/lib/console-forms';
import { parseAppPrivacy, verifyAppPrivacy, verifyContentRating, verifyPlayAppRecord, verifyTargetAudience, verifyAgeRatingApi, verifyAppRecordApi } from '../cli/lib/console-verify';
import { listTools, TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
const base: 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', appId: '1234567890' },
play: { packageName: 'com.example.game', developerId: '4444' },
ads: { appAdsTxt: ['example-network.com, pub-0000, DIRECT, abc123'] },
console: { contentRating: { category: 'game', answers: { violence: false, gambling: false } }, targetAudience: { ages: ['13-15', '16-17', '18-over'], appealsToChildren: false, containsAds: true }, appRecord: { sku: 'com.example.game', userAccess: 'full' } },
} as ShipConfig;
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', 'DEVELOPERS_ADVERTISING', 'ANALYTICS'], linked: false, tracking: true };
types.CRASH_DATA = { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: false, tracking: false };
types.PURCHASE_HISTORY = { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false };
return { ...base, appPrivacy: { types: { ...types, ...over } } } as unknown as ShipConfig;
}
describe('console plan: the JSON shape for every form', () => {
for (const form of FORM_IDS) {
test(form, () => {
const p = consolePlan(form, privacy());
expect(p).toMatchObject({ form, store: form.startsWith('play-') ? 'play' : 'apple' });
expect(typeof p.start.url).toBe('string');
expect(p.start.url).toMatch(/^https:\/\/(appstoreconnect\.apple\.com|play\.google\.com\/console)/);
expect(p.steps.length).toBeGreaterThan(1);
p.steps.forEach((s, i) => { expect(s.n).toBe(i + 1); expect(s.text.length).toBeGreaterThan(10); });
expect(p.answers.length).toBeGreaterThan(0);
for (const a of p.answers) {
expect(['certain', 'ask-owner', 'owner-check', 'agreement']).toContain(a.status);
if (a.status === 'ask-owner' || a.status === 'agreement') expect(a.value).toBeNull();
}
// every answer an agent sets on the page is listed in a step
const inSteps = new Set(p.steps.flatMap(s => s.answers ?? []));
if (p.fillable) for (const a of p.answers) expect({ id: a.id, listed: inSteps.has(a.id) }).toEqual({ id: a.id, listed: true });
expect(p.buttons.neverPress).toContain(p.buttons.final === 'Save' ? 'Save' : p.buttons.final);
for (const b of ['Publish', 'Submit', 'Definitions and Examples']) expect(p.buttons.neverPress).toContain(b);
// no allowed button is a final one
for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit');
expect(p.uiFacts.length).toBeGreaterThan(0);
expect(p.driving.join(' ')).toContain('window.__log');
expect(p.verify.command).toContain(`console verify ${form}`);
expect(planText(p)).toContain(p.title);
expect(JSON.parse(JSON.stringify(p))).toEqual(p);
});
}
test('App Privacy: deep link, the verified flow per type, console labels with curly quotes, the never list', () => {
const p = consolePlan('apple-app-privacy', privacy());
expect(p.start).toEqual({ url: 'https://appstoreconnect.apple.com/apps/1234567890/distribution/privacy', deepLink: true, reach: [] });
const dev = p.answers.find(a => a.id === 'purpose:Device ID:Developer\'s Advertising or Marketing')!;
expect(dev).toMatchObject({ label: 'Developer’s Advertising or Marketing', curlyQuotes: true, action: 'tick', status: 'certain', where: 'the "Device ID" dialog, step 1 (purposes)' });
expect(p.answers.find(a => a.id === 'linked:Purchase History')).toMatchObject({ label: 'Yes, purchase history data collected from this app is linked to the user’s identity', action: 'pick' });
expect(p.answers.find(a => a.id === 'tracking:Device ID')).toMatchObject({ label: 'Yes, we use device id data for tracking purposes' });
expect(p.answers.find(a => a.id === 'tracking:Crash Data')).toMatchObject({ label: 'No, we do not use crash data data for tracking purposes' });
expect(p.answers.find(a => a.id === 'type:Purchase History')).toMatchObject({ label: 'Purchases', action: 'tick' });
const ph = p.steps.filter(s => s.type === 'Purchase History');
expect(ph.map(s => s.click ?? [])).toEqual([['Set Up Purchase History', 'Set Up Purchases'], ['Next'], ['Next'], ['Next', 'Next'], ['Save']]);
const facts = p.uiFacts.join('\n');
for (const f of ['plain <p>', 'Let’s define two important terms', 'To help put tracking into context, here are a few examples:', 'Definitions and Examples', 'nearest non-empty parent text', 'Developer’s Advertising or Marketing'])
expect(facts).toContain(f);
expect(p.buttons.final).toBe('Publish');
expect(p.verify.source).toBe('page-text');
});
test('ask-owner answers stay null and are listed for the owner', () => {
const p = consolePlan('apple-app-privacy', privacy({ DEVICE_ID: { collected: true, purposes: ['ANALYTICS'] } }));
expect(p.answers.find(a => a.id === 'linked:Device ID')).toMatchObject({ status: 'ask-owner', value: null });
expect(p.askOwner.some(a => a.startsWith('Device ID: linked to the user\'s identity?'))).toBe(true);
});
test('Play Create app: the verified form (deep link, package name + Check availability, material-radio gotcha, service account owner step)', () => {
const p = consolePlan('play-app-record', base);
expect(p.start).toMatchObject({ url: 'https://play.google.com/console/u/0/developers/4444/create-new-app', deepLink: true });
expect(p.answers.find(a => a.id === 'packageName')).toMatchObject({ value: 'com.example.game', label: 'App package name', action: 'type', status: 'certain' });
expect(p.answers.filter(a => a.status === 'agreement').map(a => a.action)).toEqual(['untick', 'untick']);
expect(p.buttons.allowed.map(b => b.button)).toContain('Check availability');
expect(forbiddenControl('Check availability')).toBe(false);
expect(forbiddenControl('Definitions and Examples')).toBe(true); // the scripted engine never presses it either
expect(p.buttons.neverPress).toContain('Create app');
const all = [...p.uiFacts, ...p.steps.map(s => s.text)].join('\n');
for (const f of ['inner [role=radio]', 'Select app or game', 'Package name available', 'max 150', '/app/<appId>/app-dashboard', 'Add app', 'Save changes', 'storebound apps check --store play'])
expect(all).toContain(f);
for (const perm of PLAY_SA_PERMISSIONS) expect(all).toContain(perm);
expect(all).not.toContain('first AAB upload');
expect(p.verify.capture).toContain('aria-checked=true');
// without a developer id: the console, then the chooser
expect(consolePlan('play-app-record', { ...base, play: { packageName: 'com.example.game' } } as ShipConfig).start.deepLink).toBe(false);
});
test('Play App content forms deep-link from the app dashboard once the app id is known', () => {
expect(consolePlan('play-content-rating', base, { playAppId: '9876' }).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app/9876/app-dashboard');
expect(consolePlan('play-target-audience', base).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app-list');
});
test('nameText renders the engine\'s patterns readably', () => {
expect(nameText(/^Yes,.*\blinked to the user/i)).toBe('Yes,… linked to the user');
expect(nameText('App name')).toBe('App name');
});
});
/** The Data Types section as innerText after saving, synthesized from the verified structure. */
const SAMPLE = [
'App Privacy', 'Publish', 'Privacy Policy', 'Edit', 'Privacy Policy URL', 'https://example.com/privacy',
'Data Types', 'Edit', '3 data types collected from this app: Device ID, Purchase History, Crash Data',
'Identifiers', 'Device ID', '?', 'Used for Third-Party Advertising, Developer’s Advertising or Marketing, Analytics', 'Not linked to the user’s identity', 'Used for tracking purposes', 'Edit',
'Purchases', 'Used for App Functionality', 'Linked to the user’s identity', 'Edit',
'Diagnostics', 'Crash Data', '?', 'Used for App Functionality', 'Not linked to the user’s identity', 'Not used for tracking purposes', 'Edit',
].join('\n');
describe('console verify: App Privacy from the page text', () => {
test('parses the per-type summaries (curly quotes, the Purchases label, missing tracking line read as No)', () => {
const m = parseAppPrivacy(SAMPLE);
expect([...m.keys()].sort()).toEqual(['Crash Data', 'Device ID', 'Purchase History']);
expect(m.get('Device ID')).toEqual({ setUp: false, purposes: ['Third-Party Advertising', 'Developer\'s Advertising or Marketing', 'Analytics'], linked: false, tracking: true, inferred: [] });
expect(m.get('Purchase History')).toEqual({ setUp: false, purposes: ['App Functionality'], linked: true, tracking: false, inferred: ['tracking'] });
});
test('a match: every row matches, ok', () => {
const r = verifyAppPrivacy(privacy(), SAMPLE);
expect(r.mismatches).toBe(0);
expect(r.ok).toBe(true);
expect(r.rows.filter(x => x.status === 'match')).toHaveLength(9);
});
test('a mismatch: wrong purpose, wrong tracking, a type still "Set Up", an extra type', () => {
const bad = SAMPLE
.replace('Used for Third-Party Advertising, Developer’s Advertising or Marketing, Analytics', 'Used for Analytics')
.replace('Not used for tracking purposes', 'Used for tracking purposes')
.replace('Purchases\nUsed for App Functionality\nLinked to the user’s identity\nEdit', 'Purchases\nSet Up Purchase History')
+ '\nLocation\nCoarse Location\nUsed for Analytics\nEdit';
const r = verifyAppPrivacy(privacy(), bad);
expect(r.ok).toBe(false);
const mm = r.rows.filter(x => x.status === 'mismatch').map(x => x.item).sort();
expect(mm).toEqual(['Coarse Location', 'Crash Data: used for tracking', 'Device ID: purposes', 'Purchase History']);
expect(r.rows.find(x => x.item === 'Purchase History')!.seen).toBe('"Set Up Purchase History" still shown');
});
test('nothing readable is not ok', () => {
expect(verifyAppPrivacy(privacy(), 'App Privacy\nGet Started').ok).toBe(false);
});
});
describe('console verify: the other forms', () => {
test('Play Create app from the capture JSON: names, checked material radios, no "Select …" hint', () => {
const good = JSON.stringify({ fields: { 'App name': 'Example Game', 'App package name': 'com.example.game' }, checked: ['Game', 'Free'], text: 'Create app\nApp name\nExample Game\nCheck availability\nPackage name available\nDefault language\nEnglish (United States) – en-US' });
const r = verifyPlayAppRecord(base, good);
expect(r.source).toBe('inputs');
expect(r.ok).toBe(true);
expect(r.rows.find(x => x.item === 'Check availability')?.status).toBe('match');
const bad = JSON.stringify({ fields: { 'App name': 'Example Game', 'App package name': 'com.example.gam' }, checked: ['Free'], text: 'Create app\nSelect app or game' });
const b = verifyPlayAppRecord(base, bad);
expect(b.ok).toBe(false);
expect(b.rows.filter(x => x.status === 'mismatch').map(x => x.item).sort()).toEqual(['"Select …" hints (a radio set on the input but not in the form\'s model)', 'App or game', 'App package name']);
});
test('content rating and target audience summaries', () => {
expect(verifyContentRating(base, 'Summary\nCategory\nGame\nDoes the app contain violence?\nNo\nReal-money gambling\nNo').ok).toBe(true);
expect(verifyContentRating(base, 'Summary\nDoes the app contain violence?\nYes').ok).toBe(false);
expect(verifyTargetAudience(base, 'Target age\nAges 13-15, Ages 16-17, Ages 18 and over\nAppeal to children\nNo\nAds\nYour app contains ads').ok).toBe(true);
expect(verifyTargetAudience(base, 'Target age\nAges 9-12, Ages 13-15\nAds\nYour app does not contain ads').mismatches).toBe(2);
});
test('App Store app record and age rating from API attributes', () => {
expect(verifyAppRecordApi(base, { name: 'Example Game', bundleId: 'com.example.game', sku: 'com.example.game', primaryLocale: 'en-US' }).ok).toBe(true);
expect(verifyAppRecordApi(base, { name: 'Other', bundleId: 'com.example.game', sku: 'com.example.game', primaryLocale: 'en-US' }).mismatches).toBe(1);
expect(verifyAppRecordApi(base, null).ok).toBe(false);
expect(verifyAgeRatingApi(base, null).ok).toBe(false);
});
});
describe('MCP: console_plan and console_verify are read-only; the scripted fill is not exposed', () => {
const t = (n: string) => TOOLS.find(x => x.name === n)!;
test('read-only tools, argv', () => {
for (const n of ['console_plan', 'console_verify']) {
expect(t(n).write).toBe(false);
expect(listTools().find(x => x.name === n)!.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false });
expect(validateInput(t(n), { form: 'apple-app-privacy' })).toEqual([]);
expect(validateInput(t(n), { form: 'nope' }).length).toBeGreaterThan(0);
}
expect(toArgv(t('console_plan'), { form: 'play-app-record' })).toEqual({ argv: ['console', 'plan', 'play-app-record', '--json'], dryRun: false });
expect(toArgv(t('console_verify'), { form: 'apple-app-privacy', text: 'Data Types\nDevice ID' }).argv).toEqual(['console', 'verify', 'apple-app-privacy', '--text=Data Types\nDevice ID', '--json']);
expect(validateInput(t('console_verify'), { form: 'apple-app-privacy', text: '--yes' }).length).toBeGreaterThan(0);
expect(TOOLS.some(x => x.command.join(' ') === 'console fill')).toBe(false);
});
});

View file

@ -6,12 +6,16 @@
row per data type as plain <p> texts: the type label, a "?" help button (aria-label "More information") and
"Set Up <type>", a <p> inside a div with the click handler (no button role, no link). Purchase History's row has
only its "Set Up" (the console shows the category "Purchases" above it).
The per-type DIALOG is MODELED from Apple's help page "Manage app privacy"
(https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy: for each data type,
the purposes, whether it is linked to the user's identity, whether it is used for tracking; Next between, Save at the
end) in the markup of the real Data Collection dialog: <dialog role="dialog"> titled by <h2><span>, checkboxes whose
<label> holds the label and its help text as two <p>, radios "<strong>Yes</strong>, ..." / "<strong>No</strong>, ...",
Back / Cancel / Next / Save. Saving shows the type's purposes in its row and an Edit instead of "Set Up".
The per-type DIALOG follows the flow verified on a real console (October 2026, structure only): a <dialog> whose h2
is the type name; step 1 purposes: 6 checkboxes in Apple's order with NO accessible name (the label and its help
text sit in a sibling <label> without for=, run together), Cancel / Next; step 2 linked: radios "Yes, <type lower>
data collected from this app is linked to the user’s identity" / "No, ...", Back / Cancel / Next; step 3 an info
page "Let’s define two important terms..." and step 4 an info page "To help put tracking into context, here are a
few examples:" (no inputs, Back / Cancel / Next); step 5 tracking: radios "Yes, we use <type lower> data for
tracking purposes" / "No, we do not use ...", "Definitions and Examples" (help, never clicked) / Back / Cancel /
Save. Saving closes the dialog; the row then shows an Edit and the summary ("Used for <purposes>", "Linked to the
user’s identity" or "Not linked to the user’s identity", "Used for tracking purposes" or "Not used for tracking
purposes"), which `console verify` reads.
?unknown=<type>: that type's second step is one storebound does not know (switches). ?preset=<type>:<purpose>|...
(repeatable): that type was set up before with these purposes.
window.__setups: the "Set Up" texts clicked; window.__answers: what each Save stored; window.__saves. -->
@ -65,10 +69,13 @@ function setUpOpen(type) {
function setUpDone(type, purposes) {
const a = document.querySelector(`[data-type="${type}"] .action`);
a.onclick = null;
a.innerHTML = `<p>${purposes.join(', ')}</p><button type="button" role="button">Edit</button>`;
const ans = window.__answers[type];
a.innerHTML = `<p>Used for ${purposes.join(', ')}</p>`
+ (ans ? `<p>${ans.linked === 'yes' ? 'Linked' : 'Not linked'} to the user’s identity</p><p>${ans.tracking === 'yes' ? 'Used' : 'Not used'} for tracking purposes</p>` : '')
+ `<button type="button" role="button">Edit</button>`;
a.querySelector('button').addEventListener('click', () => openType(type));
}
// the per-type dialog: purposes > Next > linked > Next > definitions (text only, real console) > Next > tracking > Save
// the per-type dialog: purposes > Next > linked > Next > definitions > Next > examples (both text only) > Next > tracking > Save
function openType(type) {
const low = type.toLowerCase();
const d = document.createElement('dialog');
@ -76,16 +83,17 @@ function openType(type) {
const radio = (name, v, html) => `<div><input type="radio" name="${name}" id="${name}_${v}" value="${v}"><div><label for="${name}_${v}"><span>${html}</span></label></div></div>`;
const step2 = unknown === type
? `<p>Which kinds of identity does ${low} carry?</p><span role="switch" aria-checked="false" tabindex="0">Account identity</span><span role="switch" aria-checked="false" tabindex="0">Device identity</span>`
: radio('linked', 'yes', `<strong>Yes</strong>, ${low} collected from this app is linked to the user’s identity`) + radio('linked', 'no', `<strong>No</strong>, ${low} collected from this app is not linked to the user’s identity`);
: radio('linked', 'yes', `<strong>Yes</strong>, ${low} data collected from this app is linked to the user’s identity`) + radio('linked', 'no', `<strong>No</strong>, ${low} data collected from this app is not linked to the user’s identity`);
d.innerHTML = `<div role="document"><h2 id="modal-header"><span>${type}</span></h2>`
+ `<div data-p="1"><p>Indicate how ${low} collected from this app is being used by you or your third-party partners.</p>`
+ PURPOSES.map((p, i) => `<div><input type="checkbox" id="purpose_${i}"><div><label for="purpose_${i}"><p>${p}</p><p>Description of ${p}</p></label></div></div>`).join('')
+ PURPOSES.map((p, i) => `<div><input type="checkbox" id="purpose_${i}"><div><label><p>${p}</p><p>Description of ${p}</p></label></div></div>`).join('')
+ `<div><button type="button">Cancel</button><button type="button" data-go="2">Next</button></div></div>`
+ `<div data-p="2" hidden>${step2}<div><button type="button" data-go="1">Back</button><button type="button">Cancel</button><button type="button" data-go="3">Next</button></div></div>`
+ `<div data-p="3" hidden><p>Let’s define two important terms before continuing.</p><h5>Tracking</h5><p>Linking data collected from your app with third-party data.</p><h5>Third-Party Data</h5><p>Data about a user collected from other companies.</p><div><button type="button" data-go="2">Back</button><button type="button">Cancel</button><button type="button" data-go="4">Next</button></div></div>`
+ `<div data-p="4" hidden>`
+ radio('tracking', 'yes', `<strong>Yes</strong>, we use ${low} for tracking purposes`) + radio('tracking', 'no', `<strong>No</strong>, we do not use ${low} for tracking purposes`)
+ `<div><button type="button" data-go="3">Back</button><button type="button">Cancel</button><button type="button" data-save>Save</button></div></div></div>`;
+ `<div data-p="4" hidden><p>To help put tracking into context, here are a few examples:</p><ul><li>Displaying targeted advertisements.</li><li>Sharing location data with a data broker.</li></ul><div><button type="button" data-go="3">Back</button><button type="button">Cancel</button><button type="button" data-go="5">Next</button></div></div>`
+ `<div data-p="5" hidden>`
+ radio('tracking', 'yes', `<strong>Yes</strong>, we use ${low} data for tracking purposes`) + radio('tracking', 'no', `<strong>No</strong>, we do not use ${low} data for tracking purposes`)
+ `<div><button type="button" data-help>Definitions and Examples</button><button type="button" data-go="4">Back</button><button type="button">Cancel</button><button type="button" data-save>Save</button></div></div></div>`;
d.setAttribute('open', '');
d.addEventListener('click', e => {
const go = e.target.closest('[data-go]')?.getAttribute('data-go');

View file

@ -7,6 +7,7 @@
<h1>Create app</h1>
<form onsubmit="return false">
<p><label>App name <input type="text" maxlength="30"></label></p>
<p><label>App package name <input type="text" maxlength="150"></label></p>
<p><label>Store language <select><option value="">Choose a language</option><option>English (United States) – en-US</option></select></label></p>
<fieldset><legend>App or game</legend>
<label><input type="radio" name="type"> App</label> <label><input type="radio" name="type"> Game</label>

View file

@ -7,6 +7,7 @@
<form onsubmit="return false">
<h2>Uygulama ayrıntıları</h2>
<p><label>Uygulama adı <input type="text" maxlength="30"></label></p>
<p><label>Uygulama paket adı <input type="text" maxlength="150"></label></p>
<p><label>Varsayılan dil <select><option value="">Dil seçin</option><option>İngilizce (Birleşik Krallık) – en-GB</option><option>İngilizce (ABD) – en-US</option><option>Türkçe – tr-TR</option></select></label></p>
<fieldset><legend>Uygulama veya oyun</legend>
<label><input type="radio" name="type"> Uygulama</label> <label><input type="radio" name="type"> Oyun</label>

View file

@ -1,21 +1,30 @@
<!doctype html>
<!-- MODELED, not recorded: Play Console > Create app (App details: App name, Default language; App or game; Free or
paid; Declarations: Developer Program Policies, US export laws), written from Google's public help "Create and set
up your app". The declarations are agreements storebound never ticks; the final "Create app" is the owner's click.
Placeholder data only. -->
<!-- Play Console > Create app (/console/u/0/developers/<id>/create-new-app). Structure verified on a real console
(2026-10-12, driven through Claude in Chrome; structure only, no data copied): App name (textbox, 30 max), App
package name (textbox, 150 max) with a read-only "Check availability" button that answers "Package name
available", Default language, App or game and Free or paid as <material-radio> elements (an inner [role=radio]
with aria-checked; the native input inside does NOT update the form's model: until the inner [role=radio] is
clicked the form shows "Select app or game" / "Select free or paid"), and the two declarations (agreements the
owner ticks). The final "Create app" is the owner's click. Placeholder data only.
window.__model: the form's model (what Angular would hold). -->
<html lang="en"><head><meta charset="utf-8"><title>Create app - Play Console</title><script src="/_common.js"></script></head>
<body>
<h1>Create app</h1>
<form onsubmit="return false">
<h2>App details</h2>
<p><label>App name <input type="text" maxlength="30"></label></p>
<p><label>App package name <input type="text" maxlength="150" id="pkg"></label> <button type="button" id="check">Check availability</button> <span id="avail"></span></p>
<p><label>Default language <select><option value="">Choose a language</option><option>English (United Kingdom) – en-GB</option><option>English (United States) – en-US</option><option>Turkish – tr-TR</option></select></label></p>
<fieldset><legend>App or game</legend>
<label><input type="radio" name="type"> App</label> <label><input type="radio" name="type"> Game</label>
</fieldset>
<fieldset><legend>Free or paid</legend>
<label><input type="radio" name="price"> Free</label> <label><input type="radio" name="price"> Paid</label>
</fieldset>
<div role="radiogroup" aria-label="App or game">
<material-radio><div role="radio" aria-checked="false" tabindex="0" aria-labelledby="t-app"><input type="radio" name="type" tabindex="-1" style="opacity:0;position:absolute;width:1px;height:1px"></div><span id="t-app">App</span></material-radio>
<material-radio><div role="radio" aria-checked="false" tabindex="0" aria-labelledby="t-game"><input type="radio" name="type" tabindex="-1" style="opacity:0;position:absolute;width:1px;height:1px"></div><span id="t-game">Game</span></material-radio>
<p class="hint" data-group="type">Select app or game</p>
</div>
<div role="radiogroup" aria-label="Free or paid">
<material-radio><div role="radio" aria-checked="false" tabindex="0" aria-labelledby="p-free"><input type="radio" name="price" tabindex="-1" style="opacity:0;position:absolute;width:1px;height:1px"></div><span id="p-free">Free</span></material-radio>
<material-radio><div role="radio" aria-checked="false" tabindex="0" aria-labelledby="p-paid"><input type="radio" name="price" tabindex="-1" style="opacity:0;position:absolute;width:1px;height:1px"></div><span id="p-paid">Paid</span></material-radio>
<p class="hint" data-group="price">Select free or paid</p>
</div>
<h2>Declarations</h2>
<fieldset><legend>Developer Program Policies</legend>
<label><input type="checkbox"> Confirm app meets the Developer Program Policies</label>
@ -25,4 +34,21 @@
</fieldset>
<button type="button">Cancel</button> <button type="submit">Create app</button>
</form>
<style>material-radio { display: inline-block; margin-right: 12px } [role=radio] { display: inline-block; width: 14px; height: 14px; border: 1px solid #555; border-radius: 50%; vertical-align: middle } [role=radio][aria-checked=true] { background: #1a73e8 }</style>
<script>
window.__model = { type: null, price: null };
document.querySelectorAll('[role=radiogroup]').forEach(g => {
const key = g.querySelector('input').name;
g.querySelectorAll('material-radio [role=radio]').forEach(r => r.addEventListener('click', () => {
// only a click on the inner [role=radio] updates the model (the native input does not)
g.querySelectorAll('[role=radio]').forEach(x => x.setAttribute('aria-checked', String(x === r)));
r.querySelector('input').checked = true;
window.__model[key] = document.getElementById(r.getAttribute('aria-labelledby')).textContent;
g.querySelector('.hint').hidden = true;
}));
});
document.getElementById('check').addEventListener('click', () => {
document.getElementById('avail').textContent = document.getElementById('pkg').value ? 'Package name available' : 'Enter a package name';
});
</script>
</body></html>

View file

@ -33,8 +33,8 @@ describe('growth extras config', () => {
});
describe('growth extras over MCP', () => {
test('no console tool exists in MCP', () => {
expect(TOOLS.filter(t => t.command[0] === 'console' || /console/.test(t.name))).toEqual([]);
test('no console tool that drives a browser exists in MCP (only the read-only plan and verify)', () => {
expect(TOOLS.filter(t => (t.command[0] === 'console' || /console/.test(t.name)) && !(t.write === false && ['plan', 'verify'].includes(t.command[1] ?? ''))).map(t => t.name)).toEqual([]);
});
test('Apple Ads, RevenueCat, device registration and internal sharing are read-only in MCP', () => {
const risky = TOOLS.filter(t => (t.command[0] === 'ads' && t.command[1] === 'apple') || t.command[0] === 'revenuecat' || (t.command[0] === 'play' && t.command[1] === 'share')

View file

@ -211,7 +211,8 @@ describe('serve --stdio: session', () => {
test('console.fill: desktop only (never MCP), answers as data in the dry run, browser only with a host token, cancel kills it', async () => {
expect(describeMethods().find(m => m.method === 'console.fill')).toMatchObject({ kind: 'write', json: true, network: true, destructive: false });
expect(DESKTOP_ONLY.some(t => t.name === 'console_fill')).toBe(true);
expect(TOOLS.some(t => t.name.startsWith('console') || t.command[0] === 'console')).toBe(false);
// MCP has only the read-only agent-first console tools (plan, verify); the scripted fill never
expect(TOOLS.filter(t => t.name.startsWith('console') || t.command[0] === 'console').map(t => [t.name, t.command.join(' '), t.write])).toEqual([['console_plan', 'console plan', false], ['console_verify', 'console verify', false]]);
expect('console.fill').not.toMatch(FORBIDDEN_METHOD_WORDS);
let now = 9_000_000;
const answers = { form: 'apple-age-rating', store: 'apple', title: 'Age rating', url: 'https://appstoreconnect.apple.com/', answers: [{ id: 'violence', label: 'Cartoon violence', value: 'None', certain: true, source: 'apple.ageRating' }], uncertain: 0, guidance: [] };