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

@ -72,6 +72,37 @@ async function find(root: Locator, role: string, names: Name[], label = false):
const f = await first(tries);
if (f.state !== 'none') return f;
}
return role === 'checkbox' || role === 'radio' ? byParentText(root, role, names) : { state: 'none' };
}
/**
* The nearest non-empty texts around controls that has no accessible name of its own (no aria-label, aria-labelledby
* or associated <label>): its parents are walked up until one has text. App Privacy's purpose checkboxes are like
* that (verified October 2026): the label and its help text sit in a sibling, run together ("Third-Party
* AdvertisingSuch as displaying ..."). '' when the control has a name (getByRole already saw it). Read-only.
*/
export const PARENT_TEXTS = (els: any[]): string[] => els.map((el: any) => {
const norm = (t: string | null | undefined) => (t ?? '').trim().replace(/\s+/g, ' ');
if (norm(el.getAttribute('aria-label'))) return '';
const by = el.getAttribute('aria-labelledby');
if (by && norm(by.split(/\s+/).map((id: string) => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' '))) return '';
if (el.labels && [...el.labels].some((l: any) => norm(l.textContent))) return '';
for (let a = el.parentElement; a && a !== el.ownerDocument.body; a = a.parentElement) { const t = norm(a.textContent); if (t) return t; }
return '';
});
/** A checkbox / radio without an accessible name, matched by its nearest non-empty parent text (PARENT_TEXTS; one
* page call for all of them). */
async function byParentText(root: Locator, role: string, names: Name[]): Promise<Found> {
const all = vis(root.locator(role === 'checkbox' ? 'input[type="checkbox"], [role="checkbox"]' : 'input[type="radio"], [role="radio"]'));
const texts: string[] = await all.evaluateAll(PARENT_TEXTS).catch(() => []);
if (!texts.some(Boolean)) return { state: 'none' };
for (const name of names) {
const re = typeof name === 'string' ? quoteTolerant(name) : name;
const hits = texts.flatMap((t, i) => (t && re.test(t) ? [i] : []));
if (hits.length === 1) return { state: 'ok', loc: all.nth(hits[0]!) };
if (hits.length > 1) return { state: 'ambiguous' };
}
return { state: 'none' };
}
@ -109,7 +140,7 @@ export async function accName(loc: Locator): Promise<string> {
const ref = by ? by.split(/\s+/).map((id: string) => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' ') : '';
const labels = el.labels ? [...el.labels].map((l: any) => l.textContent).join(' ') : '';
return el.getAttribute('aria-label') || ref || labels || el.textContent || '';
})).trim().replace(/\s+/g, ' ');
})).trim().replace(/\s+/g, ' ') || ((await loc.evaluateAll(PARENT_TEXTS).catch(() => [])) as string[])[0] || '';
}
/**
@ -153,7 +184,11 @@ async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
if (f.kind === 'checkbox') return safeClick(r.loc, await accName(r.loc), v ? 'check' : 'uncheck');
if (f.kind === 'radio') {
const o = r.options?.[String(v)];
return o ? safeClick(o, await accName(o), 'check') : false;
if (!o) return false;
const name = await accName(o);
if (forbiddenTick(name)) return safeClick(o, name, 'check');
try { if (!await safeClick(o, name, 'check')) return false; } catch (e) { if (!await inMaterialRadio(o)) throw e; }
return materialRadio(o);
}
// select: only a native <select>; a custom dropdown is left to the owner (highlighted)
const idx: number = await r.loc.evaluate((el: any, want: string) => {
@ -174,6 +209,25 @@ async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
return true;
}
/** The radio sits in Play Console's <material-radio> (or is a [role=radio] itself). */
const inMaterialRadio = (o: Locator): Promise<boolean> => o.evaluate((el: any) => !!el.closest('material-radio')).catch(() => false);
/**
* Play Console's <material-radio> (verified 2026-10-12 on Create app): setting the native input, or a click that does
* not reach the right element (a background window), leaves the Angular model empty and the form keeps showing
* "Select app or game". Only a click on the inner [role=radio] updates the model, so it gets a DOM click when it is not
* aria-checked yet. Other radios are left as they are. true when the radio is checked afterwards.
*/
export async function materialRadio(o: Locator): Promise<boolean> {
return o.evaluate((el: any) => {
const host = el.closest('material-radio');
const inner = el.getAttribute('role') === 'radio' ? el : host?.querySelector('[role="radio"]');
if (!inner) return el.checked !== false;
if (inner.getAttribute('aria-checked') !== 'true') inner.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
return inner.getAttribute('aria-checked') === 'true' || el.checked === true;
}).catch(() => false);
}
async function mark(r: Resolved, text = CHECK): Promise<void> {
if (r.loc) return highlight(r.loc, text);
for (const o of Object.values(r.options ?? {})) await highlight(o, text);
@ -1211,6 +1265,14 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
progress(`the next part after "${s.title}"`);
if (o.debugEveryStep) await dump(p, `filled ${s.title}`);
say(`${s.title}: filled ${s.filled.length}, check ${s.highlighted.length}${s.highlighted.length ? `: ${s.highlighted.map(id => labels.get(id) ?? id).join('; ')}` : ''}`);
// Play Create app: the package name's read-only "Check availability" (verified 2026-10-12)
if (form === 'play-app-record' && s.filled.includes('packageName')) {
const check = await controlWhere(p, FLOW_NAMES.checkAvailability, () => true, dataTypes).catch(() => null);
if (check && await flowClick(check, form, dataTypes, say).catch(() => false)) {
const ok = await poll(5000, async () => ((await vis(p.getByText(/package name (is )?available/i)).count()) > 0 ? true : null)).catch(() => null);
say(ok ? 'App package name: "Package name available"' : 'App package name: Play Console did not say "Package name available": check the message next to the field');
}
}
anchor = s.anchor; filledAt = s.section;
// the last page of a Play / App Store form without a Next ends the run: its button is the owner's (Save, Submit, Create)
const ends = !privacy && s.section === last && !await nextControl(p).catch(() => null);