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

@ -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;