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; } });