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:
parent
a9261a8052
commit
c9a7f095c4
34 changed files with 1430 additions and 113 deletions
|
|
@ -33,6 +33,13 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
|
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
|
||||||
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
|
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
|
||||||
and never ask for tester emails in chat (storebound masks them in its output).
|
and never ask for tester emails in chat (storebound masks them in its output).
|
||||||
|
10. **Console forms without an API: drive them yourself, agent-first.** `storebound console plan <form> --json` (MCP
|
||||||
|
`console_plan`) gives the start URL, steps, the answer sheet, the buttons you must never press and the verified UI
|
||||||
|
facts. Drive the page with your own browser tool, adapt to what it shows, run long flows as an in-page async loop
|
||||||
|
that writes `window.__log` (one evaluate call times out around 45 s), stop before the final button (Publish,
|
||||||
|
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||||
|
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||||
|
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
|
|
@ -54,7 +61,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
||||||
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
||||||
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
|
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
|
||||||
- `skills/console-autofill/SKILL.md`: AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser with storebound's own profile that the owner signs in to once (never a copy of the owner's Google cookies unless they opt in); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button
|
- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default
|
||||||
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
||||||
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
|
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
|
||||||
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`
|
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`
|
||||||
|
|
|
||||||
9
.github/copilot-instructions.md
vendored
9
.github/copilot-instructions.md
vendored
|
|
@ -33,6 +33,13 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
|
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
|
||||||
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
|
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
|
||||||
and never ask for tester emails in chat (storebound masks them in its output).
|
and never ask for tester emails in chat (storebound masks them in its output).
|
||||||
|
10. **Console forms without an API: drive them yourself, agent-first.** `storebound console plan <form> --json` (MCP
|
||||||
|
`console_plan`) gives the start URL, steps, the answer sheet, the buttons you must never press and the verified UI
|
||||||
|
facts. Drive the page with your own browser tool, adapt to what it shows, run long flows as an in-page async loop
|
||||||
|
that writes `window.__log` (one evaluate call times out around 45 s), stop before the final button (Publish,
|
||||||
|
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||||
|
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||||
|
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
|
|
@ -56,7 +63,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
||||||
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
||||||
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
|
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
|
||||||
| `console-autofill` | AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser with storebound's own profile that the owner signs in to once (never a copy of the owner's Google cookies unless they opt in); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button |
|
| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default |
|
||||||
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
||||||
| `listing-localization` | Translate the store listing of a mobile app or game into more languages |
|
| `listing-localization` | Translate the store listing of a mobile app or game into more languages |
|
||||||
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |
|
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,13 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
|
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
|
||||||
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
|
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
|
||||||
and never ask for tester emails in chat (storebound masks them in its output).
|
and never ask for tester emails in chat (storebound masks them in its output).
|
||||||
|
10. **Console forms without an API: drive them yourself, agent-first.** `storebound console plan <form> --json` (MCP
|
||||||
|
`console_plan`) gives the start URL, steps, the answer sheet, the buttons you must never press and the verified UI
|
||||||
|
facts. Drive the page with your own browser tool, adapt to what it shows, run long flows as an in-page async loop
|
||||||
|
that writes `window.__log` (one evaluate call times out around 45 s), stop before the final button (Publish,
|
||||||
|
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||||
|
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||||
|
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
|
|
@ -55,7 +62,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
|
||||||
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
|
||||||
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
|
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
|
||||||
- `skills/console-autofill/SKILL.md`: AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser with storebound's own profile that the owner signs in to once (never a copy of the owner's Google cookies unless they opt in); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button
|
- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default
|
||||||
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
||||||
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
|
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
|
||||||
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`
|
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`
|
||||||
|
|
|
||||||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -2,6 +2,30 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Agent-first console mode. `storebound console plan <form> [--json]` (read-only, offline; `--lookup` asks the App
|
||||||
|
Store Connect API for the app id) gives an AI agent everything to fill a console form with its own browser tool:
|
||||||
|
the start URL (deep links from apple.appId, play.developerId, the new play.appId and the remembered ids), ordered
|
||||||
|
steps (what to click by visible text), the answer sheet (each answer certain or ask-owner, with the console's exact
|
||||||
|
labels, curly quotes noted), the never-press list (Publish, Submit, Create, agreements, help panels), the known UI
|
||||||
|
facts per form and how to verify. `storebound console verify <form> [--from-text <file|->]` (read-only, exit 1 on a
|
||||||
|
mismatch) checks the result: App Privacy from the page text (per-type "Used for …", linked, tracking), Play Create
|
||||||
|
app from the input states, content rating and target audience from their summaries, the App Store record and age
|
||||||
|
rating from the API. MCP: `console_plan`, `console_verify` (read-only; `console fill` stays off MCP). The
|
||||||
|
console-autofill skill now puts the agent-driven mode first; `console fill` is the optional scripted fast path.
|
||||||
|
Pitfall 20.
|
||||||
|
- App Privacy, verified on a real console (2026-10-12): two information steps in each type dialog ("Let’s define two
|
||||||
|
important terms…", "To help put tracking into context…"), purpose checkboxes without an accessible name (matched by
|
||||||
|
the nearest parent text), "Definitions and Examples" never pressed. The scripted engine handles all of it.
|
||||||
|
- Play Create app, verified through Claude in Chrome (2026-10-12): the form's own URL
|
||||||
|
`/console/u/0/developers/<id>/create-new-app` (the engine opens it directly with a developer id); the App package
|
||||||
|
name field (from `play.packageName`, max 150) and its read-only "Check availability" ("Package name available"),
|
||||||
|
which storebound presses; the `<material-radio>` gotcha (the native input does not update the form's model: the
|
||||||
|
engine clicks the inner `[role=radio]`); the app dashboard `/developers/<id>/app/<appId>/app-dashboard` (the app id
|
||||||
|
is remembered and deep-links the App content forms); after Create app the owner adds the new app to the service
|
||||||
|
account (Users and permissions > the service account > Add app), never an agent. The old note "the first AAB
|
||||||
|
upload sets the package name" is gone.
|
||||||
|
- Confirmed (2026-10-12): the earlier default copy of the owner's Google cookies DID sign the owner out of Play
|
||||||
|
Console in their real Chrome (pitfall 18). The copy has been opt-in since the change below.
|
||||||
- Fix (`play images`): a phone-only project no longer aborts with "no sevenInchScreenshots ... (render first)". Play
|
- Fix (`play images`): a phone-only project no longer aborts with "no sevenInchScreenshots ... (render first)". Play
|
||||||
requires the 512 icon, the 1024 x 500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151);
|
requires the 512 icon, the 1024 x 500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151);
|
||||||
7" / 10" tablet screenshots are optional. `play images` (dry and real) uploads what exists (icon, feature graphic,
|
7" / 10" tablet screenshots are optional. `play images` (dry and real) uploads what exists (icon, feature graphic,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,13 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
|
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
|
||||||
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
|
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
|
||||||
and never ask for tester emails in chat (storebound masks them in its output).
|
and never ask for tester emails in chat (storebound masks them in its output).
|
||||||
|
10. **Console forms without an API: drive them yourself, agent-first.** `storebound console plan <form> --json` (MCP
|
||||||
|
`console_plan`) gives the start URL, steps, the answer sheet, the buttons you must never press and the verified UI
|
||||||
|
facts. Drive the page with your own browser tool, adapt to what it shows, run long flows as an in-page async loop
|
||||||
|
that writes `window.__log` (one evaluate call times out around 45 s), stop before the final button (Publish,
|
||||||
|
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||||
|
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||||
|
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
|
|
@ -55,7 +62,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
|
||||||
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
|
||||||
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
|
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
|
||||||
| `console-autofill` | AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser with storebound's own profile that the owner signs in to once (never a copy of the owner's Google cookies unless they opt in); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button |
|
| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default |
|
||||||
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
||||||
| `listing-localization` | Translate the store listing of a mobile app or game into more languages |
|
| `listing-localization` | Translate the store listing of a mobile app or game into more languages |
|
||||||
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |
|
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -485,7 +485,14 @@ a terminal first and are never offered over MCP.
|
||||||
- **RevenueCat (optional):** `storebound revenuecat diff | sync` mirrors products, subscriptions, entitlements and a
|
- **RevenueCat (optional):** `storebound revenuecat diff | sync` mirrors products, subscriptions, entitlements and a
|
||||||
default offering into a RevenueCat project (REST API v2, secret key from env / `.storebound/`). sync only creates and
|
default offering into a RevenueCat project (REST API v2, secret key from env / `.storebound/`). sync only creates and
|
||||||
attaches, never deletes. Runtime entitlements, receipts and paywalls stay RevenueCat's job. Skill: `revenuecat-sync`.
|
attaches, never deletes. Runtime entitlements, receipts and paywalls stay RevenueCat's job. Skill: `revenuecat-sync`.
|
||||||
- **Console autofill for forms without an API:** `storebound console fill <form>` (`apple-app-record`,
|
- **Console forms without an API, agent-first:** `storebound console plan <form> [--json]` gives your AI agent the
|
||||||
|
start URL (deep links when the ids are known), the steps, the answer sheet from the config (each answer certain or
|
||||||
|
"ask owner", with the console's exact labels), the buttons it must never press and the UI facts verified on the real
|
||||||
|
consoles; the agent drives the page with its own browser tool (Claude in Chrome, a built-in browser, Playwright),
|
||||||
|
stops before the final button and tells you what to press. `storebound console verify <form> [--from-text -]` then
|
||||||
|
checks the page against the config (App Privacy per-type summaries, Play Create app input states, summaries, or the
|
||||||
|
App Store Connect API) and exits 1 on a mismatch. Both are read-only MCP tools (`console_plan`, `console_verify`).
|
||||||
|
- **Scripted fast path:** `storebound console fill <form>` (`apple-app-record`,
|
||||||
`apple-age-rating`, `apple-app-privacy`, `play-app-record`, `play-content-rating`, `play-target-audience`) opens a
|
`apple-age-rating`, `apple-app-privacy`, `play-app-record`, `play-content-rating`, `play-target-audience`) opens a
|
||||||
visible browser, goes to the form by itself (Apps > + > New App, the app's App Privacy, Play All apps > Create app,
|
visible browser, goes to the form by itself (Apps > + > New App, the app's App Privacy, Play All apps > Create app,
|
||||||
Play app list > App content > Start / Manage;
|
Play app list > App content > Start / Manage;
|
||||||
|
|
@ -508,8 +515,11 @@ a terminal first and are never offered over MCP.
|
||||||
with several accounts and none set it stops and lists them. It fills App name (`play.name` or `app.name`, 30
|
with several accounts and none set it stops and lists them. It fills App name (`play.name` or `app.name`, 30
|
||||||
characters), Default language, App or game (the profile) and Free or paid (`play.free`, else Free for IAP / ads:
|
characters), Default language, App or game (the profile) and Free or paid (`play.free`, else Free for IAP / ads:
|
||||||
an owner check, a free app can never become paid). It never ticks the Developer Program Policies or US export laws
|
an owner check, a free app can never become paid). It never ticks the Developer Program Policies or US export laws
|
||||||
declarations and never presses the form's Create app: you do. The package name is set by the first AAB upload
|
declarations and never presses the form's Create app: you do. With `play.developerId` it opens the form's own URL
|
||||||
(`storebound play upload --track internal` or `storebound flow run release`).
|
(`/console/u/0/developers/<id>/create-new-app`); it fills App package name from `play.packageName` and presses the
|
||||||
|
read-only "Check availability", and clicks the inner `[role=radio]` of Play's material radios (the native input
|
||||||
|
does not update the form). After Create app, add the new app to your service account yourself (Users and
|
||||||
|
permissions > the service account > Add app): app-level permissions do not cover apps created later.
|
||||||
When a page has changed it falls back to step-by-step guidance with copyable answers. **Console pages change; this is AI-assisted: review every field and save it yourself.** Desktop: "Fill in
|
When a page has changed it falls back to step-by-step guidance with copyable answers. **Console pages change; this is AI-assisted: review every field and save it yourself.** Desktop: "Fill in
|
||||||
console" on the App info tab. Never over MCP. Skill: `console-autofill`.
|
console" on the App info tab. Never over MCP. Skill: `console-autofill`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
||||||
'appEvents_createInstance': 'apple/events',
|
'appEvents_createInstance': 'apple/events',
|
||||||
'appEvents_getInstance': 'apple/events',
|
'appEvents_getInstance': 'apple/events',
|
||||||
'appEvents_localizations_getToManyRelated': 'apple/events',
|
'appEvents_localizations_getToManyRelated': 'apple/events',
|
||||||
'appInfos_ageRatingDeclaration_getToOneRelated': 'apple/appinfo validate',
|
'appInfos_ageRatingDeclaration_getToOneRelated': 'apple/appinfo console/verify validate',
|
||||||
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing validate',
|
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing validate',
|
||||||
'appInfos_getInstance': 'apple/appinfo',
|
'appInfos_getInstance': 'apple/appinfo',
|
||||||
'appInfos_updateInstance': 'apple/appinfo',
|
'appInfos_updateInstance': 'apple/appinfo',
|
||||||
|
|
@ -77,7 +77,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
||||||
'apps_betaGroups_getToManyRelated': 'apple/testflight',
|
'apps_betaGroups_getToManyRelated': 'apple/testflight',
|
||||||
'apps_buildUploads_getToManyRelated': 'apple/upload',
|
'apps_buildUploads_getToManyRelated': 'apple/upload',
|
||||||
'apps_customerReviews_getToManyRelated': 'reviews',
|
'apps_customerReviews_getToManyRelated': 'reviews',
|
||||||
'apps_getCollection': 'apple/asc apple/bundle apps console/app-id lib/store-apps',
|
'apps_getCollection': 'apple/asc apple/bundle apps console/app-id console/verify lib/store-apps',
|
||||||
'apps_getInstance': 'apple/appinfo apple/status',
|
'apps_getInstance': 'apple/appinfo apple/status',
|
||||||
'apps_inAppPurchasesV2_getToManyRelated': 'apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate',
|
'apps_inAppPurchasesV2_getToManyRelated': 'apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate',
|
||||||
'apps_perfPowerMetrics_getToManyRelated': 'health',
|
'apps_perfPowerMetrics_getToManyRelated': 'health',
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,9 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
||||||
'ads apple': [() => import('./ads/apple'), 'Apple Ads (Search Ads): status | campaigns | report (spend, taps, installs, CPA per campaign / keyword, --csv); bid | budget | pause (dry run, asks first; never via MCP)'],
|
'ads apple': [() => import('./ads/apple'), 'Apple Ads (Search Ads): status | campaigns | report (spend, taps, installs, CPA per campaign / keyword, --csv); bid | budget | pause (dry run, asks first; never via MCP)'],
|
||||||
'web': [() => import('./web'), 'init (apple-app-site-association, assetlinks.json, Smart App Banner tag, optional landing page) | check <domain> (live files: content type, no redirects, ids match)'],
|
'web': [() => import('./web'), 'init (apple-app-site-association, assetlinks.json, Smart App Banner tag, optional landing page) | check <domain> (live files: content type, no redirects, ids match)'],
|
||||||
'revenuecat': [() => import('./revenuecat'), 'diff (catalog vs RevenueCat products, entitlements, default offering) | sync (dry run, asks first; never via MCP)'],
|
'revenuecat': [() => import('./revenuecat'), 'diff (catalog vs RevenueCat products, entitlements, default offering) | sync (dry run, asks first; never via MCP)'],
|
||||||
'console fill': [() => import('./console/fill'), '<form> [--store apple|play]: AI-assisted autofill of console forms without an API in a visible Chromium you sign in to yourself; stops before Save / Submit'],
|
'console plan': [() => import('./console/plan'), '<form> [--json]: agent-first console mode: start URL, steps, answer sheet (certain / ask owner, console labels), never-press list, UI facts and how to verify, for an AI agent that drives the browser itself; read-only'],
|
||||||
|
'console verify': [() => import('./console/verify'), '<form> [--from-text <file|->] [--json]: check a filled console form against the config (page text, input states or the App Store Connect API); read-only, exit 1 on a mismatch'],
|
||||||
|
'console fill': [() => import('./console/fill'), '<form> [--store apple|play]: optional scripted fast path: autofill in a visible Chromium you sign in to yourself; stops before Save / Submit'],
|
||||||
'reviews': [() => import('./reviews'), 'list | draft (local reply drafts in .storebound/replies) | send (approved drafts only, asks first)'],
|
'reviews': [() => import('./reviews'), 'list | draft (local reply drafts in .storebound/replies) | send (approved drafts only, asks first)'],
|
||||||
'originality': [() => import('./originality'), 'Guideline 4.3 check vs your other apps (--against <folders>): listing text, name, icon and screenshot perceptual hashes, project structure; scores, flags, suggestions; offline, exit 1 on a near-duplicate'],
|
'originality': [() => import('./originality'), 'Guideline 4.3 check vs your other apps (--against <folders>): listing text, name, icon and screenshot perceptual hashes, project structure; scores, flags, suggestions; offline, exit 1 on a near-duplicate'],
|
||||||
'listing': [() => import('./listing'), 'translate --to de-DE,ja [--from en-US] [--translate "<command>"]: listing translation drafts per language (App Store name, subtitle, promo text, keywords, description; Play title, short, full) with store limits and keyword rules, marked until reviewed | check | reviewed --lang <ids>; local files only'],
|
'listing': [() => import('./listing'), 'translate --to de-DE,ja [--from en-US] [--translate "<command>"]: listing translation drafts per language (App Store name, subtitle, promo text, keywords, description; Play title, short, full) with store limits and keyword rules, marked until reviewed | check | reviewed --lang <ids>; local files only'],
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,12 @@ import { homedir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import type { ShipConfig } from '../lib/config';
|
import type { ShipConfig } from '../lib/config';
|
||||||
import type { FormId } from '../lib/console-answers';
|
import type { FormId } from '../lib/console-answers';
|
||||||
import { hasToken, type Name, NAV_BASES, type NavBases, type NavStep, navPlan, playDeveloperId, switchAllowed } from '../lib/console-forms';
|
import { hasToken, type Name, NAV_BASES, type NavBases, type NavStep, navPlan, playAppIdOf, playDeveloperId, switchAllowed } from '../lib/console-forms';
|
||||||
import { ACTION_MS, accName, findAny, type Locator, navClick, navigate, type NavResult, type Page, poll, realRole, safeClick, signInPage, SignInSeen, type Stop, vis } from './fill';
|
import { ACTION_MS, accName, findAny, type Locator, navClick, navigate, type NavResult, type Page, poll, realRole, safeClick, signInPage, SignInSeen, type Stop, vis } from './fill';
|
||||||
|
|
||||||
// ---- the cache ----
|
// ---- the cache ----
|
||||||
|
|
||||||
export interface PlayEntry { developerId: string; account?: string; at: string }
|
export interface PlayEntry { developerId: string; account?: string; /** The Play Console app id (/app/<appId>/), read from the URL once the app is open. */ appId?: string; at: string }
|
||||||
export interface AppleEntry { team: string; teamId?: string; at: string }
|
export interface AppleEntry { team: string; teamId?: string; at: string }
|
||||||
export interface AccountsCache { version: 1; play: Record<string, PlayEntry>; apple: Record<string, AppleEntry> }
|
export interface AccountsCache { version: 1; play: Record<string, PlayEntry>; apple: Record<string, AppleEntry> }
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ export function readAccounts(file = accountsFile()): AccountsCache {
|
||||||
let j: any;
|
let j: any;
|
||||||
try { j = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; } catch { return out; }
|
try { j = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; } catch { return out; }
|
||||||
for (const [k, v] of Object.entries((j?.play ?? {}) as Record<string, any>)) {
|
for (const [k, v] of Object.entries((j?.play ?? {}) as Record<string, any>)) {
|
||||||
if (KEY.test(k) && typeof v?.developerId === 'string' && /^\d{1,25}$/.test(v.developerId)) out.play[k] = { developerId: v.developerId, ...(str(v.account) ? { account: v.account } : {}), at: str(v.at, 40) ? v.at : '' };
|
if (KEY.test(k) && typeof v?.developerId === 'string' && /^\d{1,25}$/.test(v.developerId)) out.play[k] = { developerId: v.developerId, ...(str(v.account) ? { account: v.account } : {}), ...(typeof v.appId === 'string' && /^\d{1,25}$/.test(v.appId) ? { appId: v.appId } : {}), at: str(v.at, 40) ? v.at : '' };
|
||||||
}
|
}
|
||||||
for (const [k, v] of Object.entries((j?.apple ?? {}) as Record<string, any>)) {
|
for (const [k, v] of Object.entries((j?.apple ?? {}) as Record<string, any>)) {
|
||||||
if (KEY.test(k) && str(v?.team)) out.apple[k] = { team: v.team, ...(typeof v.teamId === 'string' && /^[A-Z0-9]{10}$/.test(v.teamId) ? { teamId: v.teamId } : {}), at: str(v.at, 40) ? v.at : '' };
|
if (KEY.test(k) && str(v?.team)) out.apple[k] = { team: v.team, ...(typeof v.teamId === 'string' && /^[A-Z0-9]{10}$/.test(v.teamId) ? { teamId: v.teamId } : {}), at: str(v.at, 40) ? v.at : '' };
|
||||||
|
|
@ -55,7 +55,9 @@ export function readAccounts(file = accountsFile()): AccountsCache {
|
||||||
/** Remembers which account has the app (written atomically: the desktop app may read it at the same time). */
|
/** Remembers which account has the app (written atomically: the desktop app may read it at the same time). */
|
||||||
export function rememberPlay(pkg: string, e: Omit<PlayEntry, 'at'>, file = accountsFile()): void {
|
export function rememberPlay(pkg: string, e: Omit<PlayEntry, 'at'>, file = accountsFile()): void {
|
||||||
const c = readAccounts(file);
|
const c = readAccounts(file);
|
||||||
c.play[pkg] = { ...e, at: new Date().toISOString() };
|
// the app id stays when the same account is remembered again without one
|
||||||
|
const keep = c.play[pkg]?.developerId === e.developerId && c.play[pkg]?.appId && !e.appId ? { appId: c.play[pkg]!.appId! } : {};
|
||||||
|
c.play[pkg] = { ...e, ...keep, at: new Date().toISOString() };
|
||||||
write(c, file);
|
write(c, file);
|
||||||
}
|
}
|
||||||
export function rememberApple(bundleId: string, e: Omit<AppleEntry, 'at'>, file = accountsFile()): void {
|
export function rememberApple(bundleId: string, e: Omit<AppleEntry, 'at'>, file = accountsFile()): void {
|
||||||
|
|
@ -281,7 +283,18 @@ function notHere(r: NavResult, plan: NavStep[]): boolean {
|
||||||
* `signIn` (the caller waits and calls again).
|
* `signIn` (the caller waits and calls again).
|
||||||
*/
|
*/
|
||||||
export async function openAcross(page: Page, o: AcrossOpts): Promise<{ nav: NavResult; plan: NavStep[] }> {
|
export async function openAcross(page: Page, o: AcrossOpts): Promise<{ nav: NavResult; plan: NavStep[] }> {
|
||||||
try { return await across(page, o); } catch (e) {
|
try {
|
||||||
|
const r = await across(page, o);
|
||||||
|
// a Play form of an existing app is open: its URL carries the Play Console app id (/app/<appId>/), remembered
|
||||||
|
// next to the developer id so `console plan` can deep-link the App content forms
|
||||||
|
const pkg = o.config.play?.packageName;
|
||||||
|
if (r.nav.ok && pkg && o.form.startsWith('play-') && o.form !== 'play-app-record') {
|
||||||
|
const dev = playDeveloperId(page.url()), app = playAppIdOf(page.url());
|
||||||
|
const f = o.file ?? accountsFile(), account = readAccounts(f).play[pkg]?.account;
|
||||||
|
if (dev && app) rememberPlay(pkg, { developerId: dev, ...(account ? { account } : {}), appId: app }, f);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
} catch (e) {
|
||||||
if (e instanceof SignInSeen) return { nav: { ok: false, steps: [], failed: 'the account list', why: 'a sign-in page appeared', signIn: true }, plan: [] };
|
if (e instanceof SignInSeen) return { nav: { ok: false, steps: [], failed: 'the account list', why: 'a sign-in page appeared', signIn: true }, plan: [] };
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,37 @@ async function find(root: Locator, role: string, names: Name[], label = false):
|
||||||
const f = await first(tries);
|
const f = await first(tries);
|
||||||
if (f.state !== 'none') return f;
|
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' };
|
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 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(' ') : '';
|
const labels = el.labels ? [...el.labels].map((l: any) => l.textContent).join(' ') : '';
|
||||||
return el.getAttribute('aria-label') || ref || labels || el.textContent || '';
|
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 === 'checkbox') return safeClick(r.loc, await accName(r.loc), v ? 'check' : 'uncheck');
|
||||||
if (f.kind === 'radio') {
|
if (f.kind === 'radio') {
|
||||||
const o = r.options?.[String(v)];
|
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)
|
// 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) => {
|
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;
|
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> {
|
async function mark(r: Resolved, text = CHECK): Promise<void> {
|
||||||
if (r.loc) return highlight(r.loc, text);
|
if (r.loc) return highlight(r.loc, text);
|
||||||
for (const o of Object.values(r.options ?? {})) await highlight(o, 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}"`);
|
progress(`the next part after "${s.title}"`);
|
||||||
if (o.debugEveryStep) await dump(p, `filled ${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('; ')}` : ''}`);
|
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;
|
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)
|
// 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);
|
const ends = !privacy && s.section === last && !await nextControl(p).catch(() => null);
|
||||||
|
|
|
||||||
39
cli/console/plan.ts
Normal file
39
cli/console/plan.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// The agent-first console mode: storebound supplies the knowledge, the calling AI agent drives the browser.
|
||||||
|
// storebound console plan lists the forms
|
||||||
|
// storebound console plan <form> [--json] [--lookup]
|
||||||
|
// Read-only. Offline by default: the start URL, the ordered steps, the answer sheet (each answer certain or "ask
|
||||||
|
// owner", with the console's own labels), the never-press list, the known UI facts and how to verify. Deep links use
|
||||||
|
// apple.appId / play.developerId / play.appId, the ids cached by earlier runs (.storebound/console-app-ids.json,
|
||||||
|
// ~/.storebound/console-accounts.json) and the workspace's account ids; --lookup also asks the App Store Connect API
|
||||||
|
// for the app id by bundle id (a GET; needs the API key). Pure logic in cli/lib/console-plan.ts. MCP: console_plan.
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { FORM_IDS, formMeta, isForm } from '../lib/console-answers';
|
||||||
|
import { consolePlan, type PlanIds, planText } from '../lib/console-plan';
|
||||||
|
import { printJson } from '../lib/log';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const args = ctx.args, json = args.has('json');
|
||||||
|
const form = args._[0];
|
||||||
|
if (!form) {
|
||||||
|
const forms = FORM_IDS.map(id => { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable }; });
|
||||||
|
if (json) return printJson({ forms });
|
||||||
|
console.log('storebound console plan <form> [--json] [--lookup] (then drive the page yourself; `storebound console verify <form>` checks it)\n');
|
||||||
|
for (const f of forms) console.log(` ${f.id.padEnd(22)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? '' : ' (guide only)'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${FORM_IDS.join(', ')})`);
|
||||||
|
const { withAccountIds, readAccounts } = await import('./accounts');
|
||||||
|
const cfg = withAccountIds(config);
|
||||||
|
const pkg = cfg.play?.packageName;
|
||||||
|
const cached = pkg ? readAccounts().play[pkg] : undefined;
|
||||||
|
const ids: PlanIds = { playDeveloperId: cfg.play?.developerId ?? cached?.developerId ?? null, playAppId: cfg.play?.appId ?? cached?.appId ?? null };
|
||||||
|
if (form.startsWith('apple-') && !cfg.apple?.appId && cfg.apple?.bundleId) {
|
||||||
|
const { appleAppId } = await import('./app-id');
|
||||||
|
// without --lookup only the cache is read: the lookup refuses, so no request is made
|
||||||
|
ids.appleAppId = await appleAppId(root, cfg, { say: () => { /* the plan's start says what it found */ }, ...(args.has('lookup') ? {} : { lookup: async () => { throw new Error('offline (add --lookup to ask the API)'); } }) });
|
||||||
|
}
|
||||||
|
const plan = consolePlan(form, cfg, ids);
|
||||||
|
if (json) return printJson(plan);
|
||||||
|
console.log(planText(plan));
|
||||||
|
}
|
||||||
47
cli/console/verify.ts
Normal file
47
cli/console/verify.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Checks a console form against the config after the agent (or the owner) filled it. Read-only.
|
||||||
|
// storebound console verify <form> [--from-text <file|->] [--json]
|
||||||
|
// apple-app-privacy: the page text of the App Privacy page (the Data Types section's per-type summaries; the App Store
|
||||||
|
// Connect API has no App Privacy endpoint). play-app-record: the JSON of the plan's capture snippet (input states) or
|
||||||
|
// the page text. play-content-rating, play-target-audience: the summary page text. apple-app-record: the App Store
|
||||||
|
// Connect API (the app by bundle id; a GET) or, with --from-text, the page text. apple-age-rating: the API.
|
||||||
|
// Exit 1 on any mismatch, and when nothing could be matched. Pure logic in cli/lib/console-verify.ts. MCP: console_verify.
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { FORM_IDS, isForm } from '../lib/console-answers';
|
||||||
|
import { verifyAgeRatingApi, verifyAppRecordApi, type VerifyResult, verifyTable, verifyText } from '../lib/console-verify';
|
||||||
|
import { printJson } from '../lib/log';
|
||||||
|
|
||||||
|
async function readText(src: string): Promise<string> {
|
||||||
|
if (src === '-' || src === 'true') return Bun.stdin.text();
|
||||||
|
return readFileSync(src, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const args = ctx.args, json = args.has('json');
|
||||||
|
const form = args._[0];
|
||||||
|
if (!isForm(form)) throw new Error(`console verify <form> [--from-text <file|->]: one of ${FORM_IDS.join(', ')}`);
|
||||||
|
const from = args.get('from-text'), inline = args.get('text');
|
||||||
|
let r: VerifyResult;
|
||||||
|
if (from !== undefined || inline !== undefined) {
|
||||||
|
const text = inline ?? await readText(from!);
|
||||||
|
if (!text.trim()) throw new Error('console verify: the page text is empty');
|
||||||
|
r = verifyText(form, config, text);
|
||||||
|
} else if (form === 'apple-app-record' || form === 'apple-age-rating') {
|
||||||
|
const bundleId = config.apple?.bundleId;
|
||||||
|
if (!bundleId) throw new Error('console verify: apple.bundleId is not set');
|
||||||
|
const { ascClient } = await import('../apple/asc');
|
||||||
|
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||||
|
if (form === 'apple-app-record') {
|
||||||
|
const found = await c.asc('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`);
|
||||||
|
const hit = (found.data as { attributes: { bundleId: string; name?: string; sku?: string; primaryLocale?: string } }[]).find(a => a.attributes.bundleId === bundleId);
|
||||||
|
r = verifyAppRecordApi(config, hit?.attributes ?? null);
|
||||||
|
} else {
|
||||||
|
const info = await c.editableAppInfo();
|
||||||
|
const age = await c.maybe(`/v1/appInfos/${info.id}/ageRatingDeclaration`);
|
||||||
|
r = verifyAgeRatingApi(config, (age?.data?.attributes ?? null) as Record<string, unknown> | null);
|
||||||
|
}
|
||||||
|
} else throw new Error(`console verify ${form}: pass the page text with --from-text <file|-> (run \`storebound console plan ${form}\` for what to capture)`);
|
||||||
|
if (json) printJson(r); else console.log(verifyTable(r));
|
||||||
|
if (!r.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
@ -297,6 +297,26 @@
|
||||||
"store",
|
"store",
|
||||||
"use-chrome-session"
|
"use-chrome-session"
|
||||||
],
|
],
|
||||||
|
"console plan": [
|
||||||
|
"cdp",
|
||||||
|
"chrome-profile",
|
||||||
|
"debug-dump",
|
||||||
|
"fresh-profile",
|
||||||
|
"lookup",
|
||||||
|
"name",
|
||||||
|
"profile",
|
||||||
|
"sku",
|
||||||
|
"start-url",
|
||||||
|
"step-timeout",
|
||||||
|
"store",
|
||||||
|
"use-chrome-session"
|
||||||
|
],
|
||||||
|
"console verify": [
|
||||||
|
"from-text",
|
||||||
|
"name",
|
||||||
|
"sku",
|
||||||
|
"text"
|
||||||
|
],
|
||||||
"desktop": [
|
"desktop": [
|
||||||
"channel",
|
"channel",
|
||||||
"dir",
|
"dir",
|
||||||
|
|
@ -696,6 +716,7 @@
|
||||||
"fresh",
|
"fresh",
|
||||||
"fresh-profile",
|
"fresh-profile",
|
||||||
"from",
|
"from",
|
||||||
|
"from-text",
|
||||||
"godot",
|
"godot",
|
||||||
"group",
|
"group",
|
||||||
"halt",
|
"halt",
|
||||||
|
|
@ -722,6 +743,7 @@
|
||||||
"listing-text",
|
"listing-text",
|
||||||
"listings",
|
"listings",
|
||||||
"locale",
|
"locale",
|
||||||
|
"lookup",
|
||||||
"manual-release",
|
"manual-release",
|
||||||
"md",
|
"md",
|
||||||
"merge",
|
"merge",
|
||||||
|
|
@ -782,6 +804,7 @@
|
||||||
"seconds",
|
"seconds",
|
||||||
"since",
|
"since",
|
||||||
"size",
|
"size",
|
||||||
|
"sku",
|
||||||
"source",
|
"source",
|
||||||
"src",
|
"src",
|
||||||
"start",
|
"start",
|
||||||
|
|
@ -981,6 +1004,13 @@
|
||||||
"storebound console fill <form> --dry [--json] the answers and the step-by-step guidance; opens no browser",
|
"storebound console fill <form> --dry [--json] the answers and the step-by-step guidance; opens no browser",
|
||||||
"storebound console fill <form> [--store apple|play] [--json] [--profile <dir>] [--use-chrome-session google|apple]"
|
"storebound console fill <form> [--store apple|play] [--json] [--profile <dir>] [--use-chrome-session google|apple]"
|
||||||
],
|
],
|
||||||
|
"console plan": [
|
||||||
|
"storebound console plan lists the forms",
|
||||||
|
"storebound console plan <form> [--json] [--lookup]"
|
||||||
|
],
|
||||||
|
"console verify": [
|
||||||
|
"storebound console verify <form> [--from-text <file|->] [--json]"
|
||||||
|
],
|
||||||
"desktop": [
|
"desktop": [
|
||||||
"storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that",
|
"storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that",
|
||||||
"storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app",
|
"storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app",
|
||||||
|
|
|
||||||
|
|
@ -501,6 +501,9 @@ export interface ShipConfig {
|
||||||
* one Google login in several developer accounts, `storebound console fill` finds the app by package name and
|
* one Google login in several developer accounts, `storebound console fill` finds the app by package name and
|
||||||
* remembers the account (~/.storebound/console-accounts.json); this skips that search. */
|
* remembers the account (~/.storebound/console-accounts.json); this skips that search. */
|
||||||
developerId?: string;
|
developerId?: string;
|
||||||
|
/** The Play Console app id (the digits in /console/u/0/developers/<developerId>/app/<appId>/app-dashboard, shown
|
||||||
|
* after Create app). Optional: `storebound console plan` deep-links the App content forms with it. */
|
||||||
|
appId?: string;
|
||||||
/** The developer account's display name on Play Console's "Choose a developer account" page (e.g. an
|
/** The developer account's display name on Play Console's "Choose a developer account" page (e.g. an
|
||||||
* organisation account). `storebound console fill play-app-record` picks the account by it when there is no
|
* organisation account). `storebound console fill play-app-record` picks the account by it when there is no
|
||||||
* package in any account yet and no developerId; ambiguous or unknown -> it stops and lists the accounts. */
|
* package in any account yet and no developerId; ambiguous or unknown -> it stops and lists the accounts. */
|
||||||
|
|
@ -689,6 +692,7 @@ export function validateExtras(cfg: Partial<ShipConfig>): string[] {
|
||||||
const e: string[] = [];
|
const e: string[] = [];
|
||||||
if (cfg.apple?.teamId !== undefined && !/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)');
|
if (cfg.apple?.teamId !== undefined && !/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)');
|
||||||
if (cfg.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/<id>');
|
if (cfg.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/<id>');
|
||||||
|
if (cfg.play?.appId !== undefined && !/^\d{1,25}$/.test(cfg.play.appId)) e.push('play.appId: the digits of play.google.com/console/u/0/developers/<developerId>/app/<appId>/app-dashboard');
|
||||||
if (cfg.play?.developerAccount !== undefined && !(typeof cfg.play.developerAccount === 'string' && cfg.play.developerAccount.trim() && cfg.play.developerAccount.length <= 100))
|
if (cfg.play?.developerAccount !== undefined && !(typeof cfg.play.developerAccount === 'string' && cfg.play.developerAccount.trim() && cfg.play.developerAccount.length <= 100))
|
||||||
e.push('play.developerAccount: the developer account name as Play Console shows it (1-100 characters)');
|
e.push('play.developerAccount: the developer account name as Play Console shows it (1-100 characters)');
|
||||||
if (cfg.play?.name !== undefined && !(typeof cfg.play.name === 'string' && cfg.play.name.trim() && [...cfg.play.name].length <= 30)) e.push('play.name: 1-30 characters (Play app name)');
|
if (cfg.play?.name !== undefined && !(typeof cfg.play.name === 'string' && cfg.play.name.trim() && [...cfg.play.name].length <= 30)) e.push('play.name: 1-30 characters (Play app name)');
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,10 @@ export function formMeta(id: FormId, config: ShipConfig): FormMeta {
|
||||||
open: ['General > App Information > Age Ratings > Set Up Age Ratings (only to look: `storebound apple appinfo` sets it)'] };
|
open: ['General > App Information > Age Ratings > Set Up Age Ratings (only to look: `storebound apple appinfo` sets it)'] };
|
||||||
case 'apple-app-privacy': return { id, store: 'apple', title: 'App Store Connect: App Privacy', url: privacy, stopBefore: 'Publish', fillable: true,
|
case 'apple-app-privacy': return { id, store: 'apple', title: 'App Store Connect: App Privacy', url: privacy, stopBefore: 'Publish', fillable: true,
|
||||||
open: [...(config.apple?.appId ? [] : ['Apps > your app (set apple.appId so storebound opens it directly)']), 'App Privacy in the sidebar (storebound clicks Get Started, or Edit next to Data Types, and walks the questionnaire itself)'] };
|
open: [...(config.apple?.appId ? [] : ['Apps > your app (set apple.appId so storebound opens it directly)']), 'App Privacy in the sidebar (storebound clicks Get Started, or Edit next to Data Types, and walks the questionnaire itself)'] };
|
||||||
case 'play-app-record': return { id, store: 'play', title: 'Play Console: Create app', url: PLAY, stopBefore: 'Create app', fillable: true,
|
case 'play-app-record': return { id, store: 'play', title: 'Play Console: Create app', url: config.play?.developerId ? `${PLAY}/u/0/developers/${encodeURIComponent(config.play.developerId)}/create-new-app` : PLAY, stopBefore: 'Create app', fillable: true,
|
||||||
open: ['Pick the developer account if Play Console asks (play.developerId or play.developerAccount picks it)', 'Home (All apps) > Create app'],
|
open: config.play?.developerId ? ['the Create app form of the developer account opens by link (/developers/<play.developerId>/create-new-app)'] : ['Pick the developer account if Play Console asks (play.developerId or play.developerAccount picks it)', 'Home (All apps) > Create app (or /console/u/0/developers/<developer id>/create-new-app)'],
|
||||||
notes: [
|
notes: [
|
||||||
'The package name is not part of this form: the first AAB upload sets it for good (storebound play upload --track internal, or storebound flow run release).',
|
'The form has an App package name field (play.packageName, at most 150 characters): storebound fills it and presses "Check availability" (read-only), which should say "Package name available". The package name is fixed for good once the app is created.',
|
||||||
'Free or paid is an owner check: a free app can never become paid later on Google Play.',
|
'Free or paid is an owner check: a free app can never become paid later on Google Play.',
|
||||||
'The Developer Program Policies and US export laws boxes are agreements: storebound never ticks them. Read and tick them yourself, then press Create app.',
|
'The Developer Program Policies and US export laws boxes are agreements: storebound never ticks them. Read and tick them yourself, then press Create app.',
|
||||||
] };
|
] };
|
||||||
|
|
@ -138,6 +138,8 @@ export const playLanguageLabel = (code: string): string | null => (PLAY_LANGUAGE
|
||||||
|
|
||||||
/** The Play app name limit (Create app form and store listing title). */
|
/** The Play app name limit (Create app form and store listing title). */
|
||||||
export const PLAY_NAME_MAX = 30;
|
export const PLAY_NAME_MAX = 30;
|
||||||
|
/** The Create app form's App package name limit (verified 2026-10-12). */
|
||||||
|
export const PLAY_PACKAGE_MAX = 150;
|
||||||
|
|
||||||
function playAppRecord(config: ShipConfig): Answer[] {
|
function playAppRecord(config: ShipConfig): Answer[] {
|
||||||
const p = config.play;
|
const p = config.play;
|
||||||
|
|
@ -150,9 +152,12 @@ function playAppRecord(config: ShipConfig): Answer[] {
|
||||||
const profile = config.app.profile;
|
const profile = config.app.profile;
|
||||||
const sells = (config.products?.length ?? 0) > 0 || (config.subscriptions?.length ?? 0) > 0, ads = (config.ads?.appAdsTxt?.length ?? 0) > 0;
|
const sells = (config.products?.length ?? 0) > 0 || (config.subscriptions?.length ?? 0) > 0, ads = (config.ads?.appAdsTxt?.length ?? 0) > 0;
|
||||||
const once = 'A free app can never become paid later on Google Play (a paid app can become free).';
|
const once = 'A free app can never become paid later on Google Play (a paid app can become free).';
|
||||||
|
const pkg = p?.packageName;
|
||||||
const out: Answer[] = [
|
const out: Answer[] = [
|
||||||
len <= PLAY_NAME_MAX ? ok('name', 'App name', name, nameSrc, 'Shown on Google Play; the store listing can change it later.')
|
len <= PLAY_NAME_MAX ? ok('name', 'App name', name, nameSrc, 'Shown on Google Play; the store listing can change it later.')
|
||||||
: ask('name', 'App name', nameSrc, `"${name}" is ${len} characters; Play allows ${PLAY_NAME_MAX}. Set play.name to a shorter name.`),
|
: ask('name', 'App name', nameSrc, `"${name}" is ${len} characters; Play allows ${PLAY_NAME_MAX}. Set play.name to a shorter name.`),
|
||||||
|
pkg && pkg.length <= PLAY_PACKAGE_MAX ? ok('packageName', 'App package name', pkg, 'play.packageName', 'Fixed for good once the app is created. Press "Check availability" next to it (read-only): "Package name available".')
|
||||||
|
: ask('packageName', 'App package name', 'play.packageName', pkg ? `"${pkg}" is longer than ${PLAY_PACKAGE_MAX} characters.` : 'Set play.packageName (the applicationId of the Android build): it is fixed for good once the app is created.'),
|
||||||
lang ? ok('language', 'Default language', lang, langSrc)
|
lang ? ok('language', 'Default language', lang, langSrc)
|
||||||
: ask('language', 'Default language', 'play.defaultLanguage', code ? `Pick the language of code "${code}" (storebound does not know its Play Console name).` : 'Set play.defaultLanguage (e.g. en-US) or a play code on the first language.'),
|
: ask('language', 'Default language', 'play.defaultLanguage', code ? `Pick the language of code "${code}" (storebound does not know its Play Console name).` : 'Set play.defaultLanguage (e.g. en-US) or a play code on the first language.'),
|
||||||
profile ? ok('type', 'App or game', profile === 'game' ? 'Game' : 'App', 'app.profile', 'Changeable later in the store settings.')
|
profile ? ok('type', 'App or game', profile === 'game' ? 'Game' : 'App', 'app.profile', 'Changeable later in the store settings.')
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
|
||||||
case 'apple-age-rating': return { id, sections: [] };
|
case 'apple-age-rating': return { id, sections: [] };
|
||||||
case 'play-app-record': return { id, sections: [{ title: 'Create app', fields: [
|
case 'play-app-record': return { id, sections: [{ title: 'Create app', fields: [
|
||||||
{ id: 'name', kind: 'text', names: ['App name', 'Uygulama adı'] },
|
{ id: 'name', kind: 'text', names: ['App name', 'Uygulama adı'] },
|
||||||
|
{ id: 'packageName', kind: 'text', names: ['App package name', 'Package name', 'Uygulama paket adı', 'Paket adı'] },
|
||||||
{ id: 'language', kind: 'select', names: ['Default language', 'Varsayılan dil'] },
|
{ id: 'language', kind: 'select', names: ['Default language', 'Varsayılan dil'] },
|
||||||
{ id: 'type', kind: 'radio', names: ['App or game', 'Uygulama veya oyun'], options: { App: ['App', 'Uygulama'], Game: ['Game', 'Oyun'] } },
|
{ id: 'type', kind: 'radio', names: ['App or game', 'Uygulama veya oyun'], options: { App: ['App', 'Uygulama'], Game: ['Game', 'Oyun'] } },
|
||||||
{ id: 'price', kind: 'radio', names: ['Free or paid', 'Ücretsiz veya ücretli'], options: { Free: ['Free', 'Ücretsiz'], Paid: ['Paid', 'Ücretli'] } },
|
{ id: 'price', kind: 'radio', names: ['Free or paid', 'Ücretsiz veya ücretli'], options: { Free: ['Free', 'Ücretsiz'], Paid: ['Paid', 'Ücretli'] } },
|
||||||
|
|
@ -141,13 +142,19 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
|
||||||
* Turkish console wording). English words match whole words; Turkish stems match word starts (agglutinative suffixes).
|
* Turkish console wording). English words match whole words; Turkish stems match word starts (agglutinative suffixes).
|
||||||
*/
|
*/
|
||||||
const EN = ['save', 'saved', 'submit', 'publish', 'create', 'send', 'apply', 'confirm', 'done', 'release', 'rollout', 'roll out', 'start', 'delete',
|
const EN = ['save', 'saved', 'submit', 'publish', 'create', 'send', 'apply', 'confirm', 'done', 'release', 'rollout', 'roll out', 'start', 'delete',
|
||||||
'remove', 'next', 'continue', 'finish', 'agree', 'accept', 'upload', 'deploy', 'discard', 'update', 'add for review', 'review', 'sign', 'log in', 'login'];
|
'remove', 'next', 'continue', 'finish', 'agree', 'accept', 'upload', 'deploy', 'discard', 'update', 'add for review', 'review', 'sign', 'log in', 'login',
|
||||||
|
// App Privacy's tracking step: a help panel, never needed (verified on the real dialog, October 2026)
|
||||||
|
'definitions and examples'];
|
||||||
const TR = ['kaydet', 'gönder', 'yayınla', 'yayımla', 'oluştur', 'onayla', 'tamam', 'bitti', 'bitir', 'sil', 'kaldır', 'başlat', 'sürüm',
|
const TR = ['kaydet', 'gönder', 'yayınla', 'yayımla', 'oluştur', 'onayla', 'tamam', 'bitti', 'bitir', 'sil', 'kaldır', 'başlat', 'sürüm',
|
||||||
'ileri', 'devam', 'sonraki', 'kabul', 'yükle', 'güncelle', 'incelemeye', 'giriş'];
|
'ileri', 'devam', 'sonraki', 'kabul', 'yükle', 'güncelle', 'incelemeye', 'giriş'];
|
||||||
const L = '\\p{L}\\p{N}';
|
const L = '\\p{L}\\p{N}';
|
||||||
// "uygula" (apply) is refused in every form, but not "uygulama" (app: Yeni Uygulama, Uygulama içeriği)
|
// "uygula" (apply) is refused in every form, but not "uygulama" (app: Yeni Uygulama, Uygulama içeriği)
|
||||||
const FORBIDDEN = new RegExp(`(?<![${L}])(?:(?:${EN.map(esc).join('|')})(?![${L}])|(?:${TR.map(esc).join("|")}|uygula(?!ma)))`, 'iu');
|
const FORBIDDEN = new RegExp(`(?<![${L}])(?:(?:${EN.map(esc).join('|')})(?![${L}])|(?:${TR.map(esc).join("|")}|uygula(?!ma)))`, 'iu');
|
||||||
|
|
||||||
|
/** Controls never pressed by storebound or by an agent following `console plan`, whatever the form: help panels and
|
||||||
|
* the final buttons (the agent-facing never-press list is built from this and FORM_BUTTONS). */
|
||||||
|
export const NEVER_PRESS = ['Publish', 'Submit', 'Create', 'Create app', 'Send', 'Release', 'Definitions and Examples', '?', 'More information'];
|
||||||
|
|
||||||
/** true when storebound must not click a control with this accessible name. */
|
/** true when storebound must not click a control with this accessible name. */
|
||||||
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase());
|
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase());
|
||||||
|
|
||||||
|
|
@ -284,10 +291,12 @@ export function navPlan(id: FormId, config: ShipConfig, bases: NavBases = NAV_BA
|
||||||
{ kind: 'wait', label: 'the New App dialog', roles: ['dialog'], names: ['New App', 'Yeni Uygulama', /^new app\b/i, /^yeni uygulama\b/iu] },
|
{ kind: 'wait', label: 'the New App dialog', roles: ['dialog'], names: ['New App', 'Yeni Uygulama', /^new app\b/i, /^yeni uygulama\b/iu] },
|
||||||
];
|
];
|
||||||
case 'apple-age-rating': return [];
|
case 'apple-age-rating': return [];
|
||||||
case 'play-app-record': return [
|
case 'play-app-record': return config.play?.developerId ? [
|
||||||
config.play?.developerId
|
// verified 2026-10-12: the Create app form of one developer account has its own URL
|
||||||
? { kind: 'goto', label: 'open the app list of the developer account', url: playAppList(play, config.play.developerId) }
|
{ kind: 'goto', label: 'open the Create app form of the developer account', url: playCreateApp(play, config.play.developerId) },
|
||||||
: { kind: 'goto', label: 'open Play Console', url: play },
|
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
|
||||||
|
] : [
|
||||||
|
{ kind: 'goto', label: 'open Play Console', url: play },
|
||||||
{ kind: 'click', label: 'Create app on the app list', roles: ['button', 'link'], names: CREATE_APP_OPENERS, createApp: true },
|
{ kind: 'click', label: 'Create app on the app list', roles: ['button', 'link'], names: CREATE_APP_OPENERS, createApp: true },
|
||||||
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
|
{ kind: 'wait', label: 'the Create app form', roles: ['textbox'], names: CREATE_FORM_FIELD },
|
||||||
];
|
];
|
||||||
|
|
@ -359,12 +368,14 @@ const EDIT: Name[] = ['Edit', 'Düzenle'];
|
||||||
const SAVE: Name[] = ['Save', 'Kaydet'];
|
const SAVE: Name[] = ['Save', 'Kaydet'];
|
||||||
const SAVE_DRAFT: Name[] = ['Save draft', 'Taslağı kaydet', 'Taslak olarak kaydet'];
|
const SAVE_DRAFT: Name[] = ['Save draft', 'Taslağı kaydet', 'Taslak olarak kaydet'];
|
||||||
const DONE: Name[] = ['Done', 'Bitti', 'Tamam'];
|
const DONE: Name[] = ['Done', 'Bitti', 'Tamam'];
|
||||||
|
/** Play Create app: the read-only package name check next to App package name (verified 2026-10-12). */
|
||||||
|
const CHECK_AVAILABILITY: Name[] = ['Check availability', 'Kullanılabilirliği kontrol et', 'Uygunluğu kontrol et'];
|
||||||
/** The heading of App Privacy's Data Types section (Edit and the data types sit in it). */
|
/** The heading of App Privacy's Data Types section (Edit and the data types sit in it). */
|
||||||
export const DATA_TYPES_HEADING: Name[] = ['Data Types', 'Veri Türleri', 'Veri türleri'];
|
export const DATA_TYPES_HEADING: Name[] = ['Data Types', 'Veri Türleri', 'Veri türleri'];
|
||||||
/** The App Privacy page heading. */
|
/** The App Privacy page heading. */
|
||||||
export const APP_PRIVACY_HEADING: Name[] = ['App Privacy', 'Uygulama Gizliliği', /^app privacy$/i];
|
export const APP_PRIVACY_HEADING: Name[] = ['App Privacy', 'Uygulama Gizliliği', /^app privacy$/i];
|
||||||
/** Names the engine looks for. */
|
/** Names the engine looks for. */
|
||||||
export const FLOW_NAMES = { next: NEXT, save: SAVE, saveDraft: SAVE_DRAFT, getStarted: GET_STARTED, edit: EDIT };
|
export const FLOW_NAMES = { next: NEXT, save: SAVE, saveDraft: SAVE_DRAFT, getStarted: GET_STARTED, edit: EDIT, checkAvailability: CHECK_AVAILABILITY };
|
||||||
|
|
||||||
/** Every button of every console form, classified (the console-autofill skill's table is written from this). */
|
/** Every button of every console form, classified (the console-autofill skill's table is written from this). */
|
||||||
export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
|
export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
|
||||||
|
|
@ -376,6 +387,7 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
|
||||||
{ button: '? / More information', class: 'navigation', where: 'next to each heading and data type: help only (never needed)', auto: false },
|
{ button: '? / More information', class: 'navigation', where: 'next to each heading and data type: help only (never needed)', auto: false },
|
||||||
{ button: 'Next / Continue', class: 'navigation', where: 'questionnaire dialogs', auto: true },
|
{ button: 'Next / Continue', class: 'navigation', where: 'questionnaire dialogs', auto: true },
|
||||||
{ button: 'Back', class: 'navigation', where: 'questionnaire dialogs (never needed)', auto: false },
|
{ button: 'Back', class: 'navigation', where: 'questionnaire dialogs (never needed)', auto: false },
|
||||||
|
{ button: 'Definitions and Examples', class: 'navigation', where: 'a data type\'s tracking step: help only (never clicked)', auto: false },
|
||||||
{ button: 'Save', class: 'draft-save', where: 'questionnaire dialogs: stores the answers, not public until Publish', auto: true },
|
{ button: 'Save', class: 'draft-save', where: 'questionnaire dialogs: stores the answers, not public until Publish', auto: true },
|
||||||
{ button: 'Publish (and its confirmation dialog)', class: 'final-submit', where: 'top right of the App Privacy page', auto: false },
|
{ button: 'Publish (and its confirmation dialog)', class: 'final-submit', where: 'top right of the App Privacy page', auto: false },
|
||||||
],
|
],
|
||||||
|
|
@ -391,6 +403,7 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
|
||||||
],
|
],
|
||||||
'play-app-record': [
|
'play-app-record': [
|
||||||
{ button: 'Create app (app list)', class: 'navigation', where: 'Home / All apps: opens the Create app form', auto: true },
|
{ button: 'Create app (app list)', class: 'navigation', where: 'Home / All apps: opens the Create app form', auto: true },
|
||||||
|
{ button: 'Check availability', class: 'navigation', where: 'the Create app form, next to App package name: read-only check ("Package name available")', auto: true },
|
||||||
{ button: 'Developer Program Policies / US export laws boxes', class: 'final-submit', where: 'the Create app form: agreements, never ticked', auto: false },
|
{ button: 'Developer Program Policies / US export laws boxes', class: 'final-submit', where: 'the Create app form: agreements, never ticked', auto: false },
|
||||||
{ button: 'Create app (form)', class: 'final-submit', where: 'the Create app form: creates the app', auto: false },
|
{ button: 'Create app (form)', class: 'final-submit', where: 'the Create app form: creates the app', auto: false },
|
||||||
],
|
],
|
||||||
|
|
@ -430,6 +443,7 @@ export function flowClass(form: FormId, name: string, role: string, where: FlowW
|
||||||
if (!n || !['button', 'link'].includes(role)) return null;
|
if (!n || !['button', 'link'].includes(role)) return null;
|
||||||
if (FINAL.test(n.toLocaleLowerCase('tr')) || FINAL.test(n)) return 'final-submit';
|
if (FINAL.test(n.toLocaleLowerCase('tr')) || FINAL.test(n)) return 'final-submit';
|
||||||
if (nameIs(n, NEXT) || nameIs(n, BACK)) return 'navigation';
|
if (nameIs(n, NEXT) || nameIs(n, BACK)) return 'navigation';
|
||||||
|
if (form === 'play-app-record' && nameIs(n, CHECK_AVAILABILITY)) return 'navigation';
|
||||||
if (form === 'apple-app-privacy') {
|
if (form === 'apple-app-privacy') {
|
||||||
if (nameIs(n, GET_STARTED) && where.privacyPage && !where.inDialog) return 'navigation';
|
if (nameIs(n, GET_STARTED) && where.privacyPage && !where.inDialog) return 'navigation';
|
||||||
if (nameIs(n, EDIT) && where.inDataTypes && !where.inDialog) return 'navigation';
|
if (nameIs(n, EDIT) && where.inDataTypes && !where.inDialog) return 'navigation';
|
||||||
|
|
@ -479,9 +493,19 @@ export function flowAllowed(form: FormId, name: string, role: string, where: Flo
|
||||||
// the team menu (exact name), is a link / menu item / option / row / button, and is never a sign-out or a deny-listed
|
// the team menu (exact name), is a link / menu item / option / row / button, and is never a sign-out or a deny-listed
|
||||||
// control (forbiddenControl stays absolute, so a studio named "Next Games" is not clicked: the owner picks it).
|
// control (forbiddenControl stays absolute, so a studio named "Next Games" is not clicked: the owner picks it).
|
||||||
|
|
||||||
|
/** Play Console's Create app form of one developer account (verified 2026-10-12). */
|
||||||
|
export const playCreateApp = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/create-new-app`;
|
||||||
|
|
||||||
/** Play Console's app list of one developer account. */
|
/** Play Console's app list of one developer account. */
|
||||||
export const playAppList = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app-list`;
|
export const playAppList = (playBase: string, developerId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app-list`;
|
||||||
|
|
||||||
|
/** The Play Console app id in a URL (/developers/<id>/app/<digits>/...), or null. */
|
||||||
|
export function playAppIdOf(url: string): string | null {
|
||||||
|
try { return new URL(url).pathname.match(/\/developers\/\d{1,25}\/app\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }
|
||||||
|
}
|
||||||
|
/** An app's Play Console dashboard (verified 2026-10-12: where Create app lands). */
|
||||||
|
export const playAppDashboard = (playBase: string, developerId: string, appId: string): string => `${playBase.replace(/\/$/, '')}/u/0/developers/${encodeURIComponent(developerId)}/app/${encodeURIComponent(appId)}/app-dashboard`;
|
||||||
|
|
||||||
/** The developer account id in a Play Console URL (/developers/<digits>), or null. */
|
/** The developer account id in a Play Console URL (/developers/<digits>), or null. */
|
||||||
export function playDeveloperId(url: string): string | null {
|
export function playDeveloperId(url: string): string | null {
|
||||||
try { return new URL(url).pathname.match(/\/developers\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }
|
try { return new URL(url).pathname.match(/\/developers\/(\d{1,25})(?:\/|$)/)?.[1] ?? null; } catch { return null; }
|
||||||
|
|
|
||||||
343
cli/lib/console-plan.ts
Normal file
343
cli/lib/console-plan.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
// `storebound console plan <form>`: everything an AI agent needs to fill a console form that has no API with its OWN
|
||||||
|
// browser tool (Claude in Chrome, a built-in browser, Playwright), adapting to whatever the page shows. storebound
|
||||||
|
// supplies the knowledge (where the form is, the steps, the answer sheet from the config, the buttons never to press,
|
||||||
|
// the UI facts verified on the real consoles) and the check afterwards (`console verify`); the agent drives the page.
|
||||||
|
// The scripted engine (`console fill`) uses the same answers and click rules and stays an optional fast path. Pure.
|
||||||
|
// UI facts marked "verified" were read on a real console (App Privacy, 2026-10-12, /apps/<id>/distribution/privacy);
|
||||||
|
// everything else is modeled on Apple's and Google's help pages (sources in console-answers.ts): the agent adapts.
|
||||||
|
import type { ShipConfig } from './config';
|
||||||
|
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, type Store, valueText } from './console-answers';
|
||||||
|
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, playAppDashboard, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
|
||||||
|
import { PLAY_CREATE_CAPTURE } from './console-verify';
|
||||||
|
import { APPLE_PURPOSES } from './console-answers';
|
||||||
|
|
||||||
|
export type PlanStatus = 'certain' | 'ask-owner' | 'owner-check' | 'agreement';
|
||||||
|
|
||||||
|
export interface PlanAnswer {
|
||||||
|
id: string;
|
||||||
|
/** The question in storebound's words. */
|
||||||
|
question: string;
|
||||||
|
/** The answer (null: the owner decides; never filled by the agent). */
|
||||||
|
value: AnswerValue;
|
||||||
|
/** Yes / No / the text, as said to the owner. */
|
||||||
|
text: string;
|
||||||
|
status: PlanStatus;
|
||||||
|
source: string;
|
||||||
|
note?: string;
|
||||||
|
control?: 'text' | 'select' | 'checkbox' | 'radio';
|
||||||
|
/** The control's label as the console shows it (curly quotes kept: "Developer’s Advertising or Marketing"). For a
|
||||||
|
* radio, the label of the option to pick; for a checkbox, the box to tick or untick. "…" stands for any text. */
|
||||||
|
label?: string;
|
||||||
|
/** checkbox: tick | untick; radio: pick; text / select: type / choose. */
|
||||||
|
action?: 'tick' | 'untick' | 'pick' | 'type' | 'choose';
|
||||||
|
/** Where on the page (the dialog and its step). */
|
||||||
|
where?: string;
|
||||||
|
/** The label has a typographic quote (’ “ ”): match straight and curly quotes alike. */
|
||||||
|
curlyQuotes?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanStep {
|
||||||
|
n: number;
|
||||||
|
/** Plain language. */
|
||||||
|
text: string;
|
||||||
|
/** Visible texts to click, in order (never anything from neverPress). */
|
||||||
|
click?: string[];
|
||||||
|
/** Answer ids set on this step (see answers). */
|
||||||
|
answers?: string[];
|
||||||
|
/** App Privacy: the data type this step belongs to. */
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsolePlan {
|
||||||
|
form: FormId;
|
||||||
|
store: Store;
|
||||||
|
title: string;
|
||||||
|
/** Where to start: a deep link when the ids are known, else the console and how to reach the form. */
|
||||||
|
start: { url: string; deepLink: boolean; reach: string[] };
|
||||||
|
steps: PlanStep[];
|
||||||
|
answers: PlanAnswer[];
|
||||||
|
/** Answers the agent must ask the owner before filling (status ask-owner), and owner checks to point out. */
|
||||||
|
askOwner: string[];
|
||||||
|
buttons: {
|
||||||
|
/** Never press these (whole visible text, any case): final buttons, help panels, agreements. */
|
||||||
|
neverPress: string[];
|
||||||
|
/** The final button the owner presses; the agent stops before it and tells the owner. */
|
||||||
|
final: string;
|
||||||
|
/** Buttons the agent may press: navigation and draft saves (nothing public, nothing sent). */
|
||||||
|
allowed: { button: string; class: string; where: string }[];
|
||||||
|
/** The rule in one line. */
|
||||||
|
rule: string;
|
||||||
|
};
|
||||||
|
/** Known UI facts of this form (verified ones say so). */
|
||||||
|
uiFacts: string[];
|
||||||
|
/** How to drive long flows from a browser tool. */
|
||||||
|
driving: string[];
|
||||||
|
verify: { command: string; source: 'page-text' | 'api' | 'api-or-page-text' | 'none'; capture: string; expect: string[] };
|
||||||
|
/** False: guide only (the API sets it; `storebound apple appinfo`). */
|
||||||
|
fillable: boolean;
|
||||||
|
guidance: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids known outside the config (an API lookup or the account cache): they make deep links. */
|
||||||
|
export interface PlanIds { appleAppId?: string | null; playDeveloperId?: string | null; playAppId?: string | null }
|
||||||
|
|
||||||
|
const ASC = 'https://appstoreconnect.apple.com';
|
||||||
|
const PLAY = 'https://play.google.com/console';
|
||||||
|
const CURLY = /[‘’“”]/;
|
||||||
|
|
||||||
|
/** A Name (string or the engine's RegExp) as a readable label: "Yes, … linked to the user". */
|
||||||
|
export function nameText(n: Name): string {
|
||||||
|
if (typeof n === 'string') return n;
|
||||||
|
return n.source
|
||||||
|
.replace(/^\^/, '').replace(/\$$/, '')
|
||||||
|
.replace(/\(\?![^)]*\)/g, '')
|
||||||
|
.replace(/\['\\u2018\\u2019\]|\['‘’\]/g, '’')
|
||||||
|
.replace(/\\b/g, ' ').replace(/\.\*/g, '…').replace(/\\s\*/g, ' ').replace(/\\s\+/g, ' ')
|
||||||
|
.replace(/\(([^()|]*)\|[^()]*\)\??/g, '$1').replace(/\?/g, '')
|
||||||
|
.replace(/\\(.)/g, '$1').replace(/\s+/g, ' ').replace(/ ,/g, ',').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App Privacy labels verified on the real dialog (2026-10-12). */
|
||||||
|
const linkedLabel = (t: string, yes: boolean) => `${yes ? 'Yes' : 'No'}, ${t.toLowerCase()} data collected from this app ${yes ? 'is' : 'is not'} linked to the user’s identity`;
|
||||||
|
const trackingLabel = (t: string, yes: boolean) => (yes ? `Yes, we use ${t.toLowerCase()} data for tracking purposes` : `No, we do not use ${t.toLowerCase()} data for tracking purposes`);
|
||||||
|
/** Purpose labels as the console shows them (curly ’ in Developer’s). */
|
||||||
|
const purposeLabel = (p: string) => p.replace(/'/g, '’');
|
||||||
|
|
||||||
|
function status(a: Answer): PlanStatus {
|
||||||
|
if (a.agreement) return 'agreement';
|
||||||
|
if (!a.certain) return 'ask-owner';
|
||||||
|
return a.ownerCheck ? 'owner-check' : 'certain';
|
||||||
|
}
|
||||||
|
|
||||||
|
function base(a: Answer): PlanAnswer {
|
||||||
|
return { id: a.id, question: a.label, value: a.agreement ? null : a.value, text: a.agreement ? 'the owner ticks it (an agreement)' : valueText(a), status: status(a), source: a.source, ...(a.note ? { note: a.note } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withLabel(p: PlanAnswer, label: string, extra: Partial<PlanAnswer>): PlanAnswer {
|
||||||
|
return { ...p, label, ...(CURLY.test(label) ? { curlyQuotes: true } : {}), ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The answer sheet: every answer with the console label of its control and what to do with it. */
|
||||||
|
function sheet(form: FormId, answers: Answer[]): PlanAnswer[] {
|
||||||
|
const def = formDef(form, answers);
|
||||||
|
const fieldOf = new Map(def.sections.flatMap(s => s.fields.map(f => [f.id, { f, s }] as const)));
|
||||||
|
return answers.map(a => {
|
||||||
|
const p = base(a);
|
||||||
|
if (form === 'apple-app-privacy') {
|
||||||
|
const [kind, t, purpose] = a.id.split(':') as [string, string, string | undefined];
|
||||||
|
const v = a.value;
|
||||||
|
if (a.id === 'collects') return withLabel(p, v === false ? 'No, we do not collect data from this app' : 'Yes, we collect data from this app', { control: 'radio', action: 'pick', where: 'the Data Collection question (Get Started)' });
|
||||||
|
if (kind === 'type') return withLabel(p, typeLabels(t)[typeLabels(t).length - 1]!, { control: 'checkbox', action: v === true ? 'tick' : 'untick', where: 'the Data Collection dialog (Edit next to Data Types): the data type list' });
|
||||||
|
if (kind === 'purpose') return withLabel(p, purposeLabel(purpose!), { control: 'checkbox', action: v === true ? 'tick' : 'untick', where: `the "${t}" dialog, step 1 (purposes)` });
|
||||||
|
if (kind === 'linked') return withLabel(p, linkedLabel(t, v !== false), { control: 'radio', action: 'pick', where: `the "${t}" dialog, step 2 (linked to the user)` });
|
||||||
|
if (kind === 'tracking') return withLabel(p, trackingLabel(t, v !== false), { control: 'radio', action: 'pick', where: `the "${t}" dialog, step 5 (tracking)` });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const hit = fieldOf.get(a.id);
|
||||||
|
if (!hit) return p;
|
||||||
|
const { f, s } = hit;
|
||||||
|
const first = (ns: Name[] | undefined) => (ns?.length ? nameText(ns.find(n => typeof n === 'string') ?? ns[0]!) : undefined);
|
||||||
|
if (f.kind === 'radio') {
|
||||||
|
const opt = a.value === null ? undefined : f.options?.[String(a.value)] ?? f.options?.[a.value === true ? 'true' : a.value === false ? 'false' : String(a.value).toLowerCase()];
|
||||||
|
const group = first(f.names);
|
||||||
|
const label = opt ? first(opt)! : group ?? a.label;
|
||||||
|
return withLabel(p, label, { control: 'radio', action: 'pick', where: `${s.title}${group ? ` (${group})` : ''}` });
|
||||||
|
}
|
||||||
|
const label = first(f.names) ?? a.label;
|
||||||
|
if (f.kind === 'checkbox') return withLabel(p, label, { control: 'checkbox', action: a.agreement || f.agreement ? 'untick' : a.value === true ? 'tick' : 'untick', where: s.title });
|
||||||
|
return withLabel(p, label, { control: f.kind, action: f.kind === 'text' ? 'type' : 'choose', where: s.title });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = (answers: PlanAnswer[], pred: (a: PlanAnswer) => boolean) => answers.filter(pred).map(a => a.id);
|
||||||
|
|
||||||
|
const DRIVING = [
|
||||||
|
'Use your own browser tool (Claude in Chrome, a built-in browser, Playwright). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps (the console changes without notice), but never press anything in buttons.neverPress.',
|
||||||
|
'For a long flow, start an in-page async loop and poll it: one Runtime.evaluate / javascript call times out around 45 s. Pattern: window.__log = []; window.__run = (async () => { ... window.__log.push("step ..."); ... })(); then read window.__log every few seconds until it says done or stopped.',
|
||||||
|
'Wait for each step to render (poll for the next heading or input, up to ~10 s) before acting; never act on a step you have not read.',
|
||||||
|
'An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.',
|
||||||
|
'Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.',
|
||||||
|
'Never sign in, type a password, accept an agreement or tick a declaration: the owner does that in the browser.',
|
||||||
|
];
|
||||||
|
|
||||||
|
function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined): Pick<ConsolePlan, 'steps' | 'uiFacts' | 'verify' | 'start'> {
|
||||||
|
const types = answers.filter(a => a.id.startsWith('type:'));
|
||||||
|
const collected = types.filter(a => a.value === true).map(a => a.id.slice(5));
|
||||||
|
const steps: PlanStep[] = [];
|
||||||
|
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
|
||||||
|
add({ text: appId ? 'Open the App Privacy page of the app (deep link).' : 'Open App Store Connect > Apps > the app > App Privacy (sidebar).' });
|
||||||
|
add({ text: 'First time: click "Get Started" and answer the Data Collection question; then Next.', click: ['Get Started'], answers: ['collects'] });
|
||||||
|
add({ text: 'Data types: when the Data Types section does not list exactly the collected types below, click the "Edit" next to the "Data Types" heading (never the Privacy Policy Edit), tick exactly these types and untick the rest, then Save (a draft: nothing is public before Publish). Console labels: Purchase History shows as "Purchases", Other Data Types as "Other Data".', click: ['Edit', 'Save'], answers: ids(answers, a => a.id.startsWith('type:')) });
|
||||||
|
for (const t of collected) {
|
||||||
|
const set = setUpTexts(t).filter(x => !/ ayarla$/.test(x));
|
||||||
|
add({ type: t, text: `"${t}": click the text "${set.join('" or "')}" in the Data Types section (a plain <p> with a click handler, not a button). A dialog opens whose h2 is "${typeLabels(t).join('" or "')}". (Set up before: its row shows Edit and a summary; open it with that row's Edit only when the summary differs from the answers.)`, click: set });
|
||||||
|
add({ type: t, text: `"${t}" step 1, purposes: tick and untick the 6 checkboxes as answered, then Next.`, click: ['Next'], answers: ids(answers, a => a.id.startsWith(`purpose:${t}:`)) });
|
||||||
|
add({ type: t, text: `"${t}" step 2, linked to the user: pick the radio, then Next.`, click: ['Next'], answers: [`linked:${t}`] });
|
||||||
|
add({ type: t, text: `"${t}" steps 3 and 4: two information pages ("Let’s define two important terms…", then "To help put tracking into context, here are a few examples:"), no inputs: press Next on each.`, click: ['Next', 'Next'] });
|
||||||
|
add({ type: t, text: `"${t}" step 5, tracking: pick the radio, then Save (a draft). Never "Definitions and Examples". The dialog closes; the row shows Edit and the summary.`, click: ['Save'], answers: [`tracking:${t}`] });
|
||||||
|
}
|
||||||
|
add({ text: 'Stop. Do NOT press Publish: tell the owner to review the summary on the App Privacy page and press Publish (it puts the answers on the App Store product page).' });
|
||||||
|
add({ text: 'Verify: read the Data Types section as text (document.querySelector("main").innerText) and pipe it to `storebound console verify apple-app-privacy --from-text -`.' });
|
||||||
|
return {
|
||||||
|
start: appId ? { url: `${ASC}/apps/${appId}/distribution/privacy`, deepLink: true, reach: [] }
|
||||||
|
: { url: `${ASC}/apps`, deepLink: false, reach: ['Apps > the app (by name or bundle id) > App Privacy in the sidebar (set apple.appId, or give storebound App Store Connect API keys, for a deep link)'] },
|
||||||
|
steps,
|
||||||
|
uiFacts: [
|
||||||
|
'Verified 2026-10-12 on /apps/<id>/distribution/privacy:',
|
||||||
|
'Data Types section: each collected type has "Set Up <type>", a plain <p> inside a div with the click handler (no button or link role). Click it by its exact text. It opens a <dialog> whose h2 is the type name.',
|
||||||
|
`Step 1, purposes: 6 checkboxes in this order: ${APPLE_PURPOSES.map(purposeLabel).join(', ')} (Developer’s with a curly ’). An input has no aria label; its label is the nearest non-empty parent text (the label and its help text run together, e.g. "AnalyticsSuch as ..."): match by "starts with the label". Buttons: Cancel, Next.`,
|
||||||
|
'Step 2, linked: 2 radios, "Yes, <type in lower case> data collected from this app is linked to the user’s identity" / "No, …" (curly ’). Buttons: Back, Cancel, Next.',
|
||||||
|
'Step 3: an information page "Let’s define two important terms…" (Tracking / Third-Party Data). No inputs: press Next.',
|
||||||
|
'Step 4: an information page "To help put tracking into context, here are a few examples:". No inputs: press Next. (Two information steps in a row: keep pressing Next while the dialog shows no inputs.)',
|
||||||
|
'Step 5, tracking: 2 radios, "Yes, we use <type> data for tracking purposes" / "No, we do not …". Buttons: "Definitions and Examples" (help: never press), Back, Cancel, Save.',
|
||||||
|
'Save closes the dialog. The type then shows "Edit" and a summary: "Used for <purposes>", "Linked to the user’s identity" (or not), "Used for tracking purposes" (or not). That summary is what `console verify` reads.',
|
||||||
|
'Labels use typographic quotes (’): match straight and curly quotes alike.',
|
||||||
|
'The "?" buttons ("More information") are help only. Publish (top right) is the owner\'s.',
|
||||||
|
],
|
||||||
|
verify: {
|
||||||
|
command: 'storebound console verify apple-app-privacy --from-text -',
|
||||||
|
source: 'page-text',
|
||||||
|
capture: 'document.querySelector("main").innerText (the App Privacy page with every dialog closed; the Data Types section is enough)',
|
||||||
|
expect: [
|
||||||
|
`Every collected type (${collected.join(', ') || 'none'}) shows Edit and a summary, no "Set Up <type>" left.`,
|
||||||
|
'Each summary lists the purposes of the answer sheet ("Used for …"), linked and tracking as answered.',
|
||||||
|
'No other data type is listed. The App Store Connect API has no App Privacy endpoint, so the page text is the only source.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function playStart(form: FormId, config: ShipConfig, p: PlanIds): ConsolePlan['start'] {
|
||||||
|
const dev = p.playDeveloperId ?? config.play?.developerId ?? null;
|
||||||
|
const app = p.playAppId ?? null;
|
||||||
|
const entry = form === 'play-content-rating' ? 'Content rating' : 'Target audience and content';
|
||||||
|
if (form === 'play-app-record') {
|
||||||
|
return dev ? { url: playCreateApp(PLAY, dev), deepLink: true, reach: [] }
|
||||||
|
: { url: PLAY, deepLink: false, reach: ['Pick the developer account on the chooser (set play.developerId for a deep link)', 'then /console/u/0/developers/<developer id>/create-new-app, or Home (All apps) > Create app'] };
|
||||||
|
}
|
||||||
|
if (dev && app) return { url: playAppDashboard(PLAY, dev, app), deepLink: true, reach: ['Policy and programs > App content', `${entry} > Start (or Manage)`] };
|
||||||
|
return {
|
||||||
|
url: dev ? playAppList(PLAY, dev) : PLAY, deepLink: !!dev,
|
||||||
|
reach: [...(dev ? [] : ['Pick the developer account if asked (set play.developerId for a deep link)']),
|
||||||
|
`the app (${config.play?.packageName ?? config.app.name}) in the app list`, 'Policy and programs > App content', `${entry} > Start (or Manage)`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The service account permission path (verified 2026-10-12): an owner step after Create app. */
|
||||||
|
export const PLAY_SA_PERMISSIONS = ['View app information and download bulk reports (read-only)', 'Manage store presence', 'Manage production releases', 'Release to testing tracks', 'Manage testing tracks and edit tester lists', 'Manage policy declarations (Data safety)', 'Manage orders and subscriptions', 'View financial data, orders, and cancellation survey responses (sales / earnings reports only)'];
|
||||||
|
const PLAY_SA_PATH = `Users and permissions > <the service account> > Add app > the new app > Admin (all permissions), or at least: ${PLAY_SA_PERMISSIONS.join('; ')} > Apply > Save changes`;
|
||||||
|
const PLAY_DASHBOARD_FACT = 'An app\'s Play Console home is /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified 2026-10-12); with play.appId (or the id storebound remembered) the plan starts there.';
|
||||||
|
const PAGE_TEXT = 'document.querySelector("main")?.innerText ?? document.body.innerText';
|
||||||
|
|
||||||
|
function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: PlanIds): Pick<ConsolePlan, 'steps' | 'uiFacts' | 'verify' | 'start'> {
|
||||||
|
const steps: PlanStep[] = [];
|
||||||
|
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
|
||||||
|
const all = (pred: (a: PlanAnswer) => boolean) => ids(answers, pred);
|
||||||
|
switch (form) {
|
||||||
|
case 'apple-app-record':
|
||||||
|
add({ text: 'Open App Store Connect > Apps.' });
|
||||||
|
add({ text: 'Click the add button (+, labelled "Add Apps") at the top left, then "New App" in the menu. The New App dialog opens.', click: ['+', 'New App'] });
|
||||||
|
add({ text: 'Fill the New App dialog: tick iOS, type the Name, choose the Primary Language and the Bundle ID, type the SKU, pick User Access.', answers: all(() => true) });
|
||||||
|
add({ text: 'Stop. Do NOT press Create: tell the owner to review and press Create (it creates the app record for good; the bundle id and SKU cannot change).' });
|
||||||
|
add({ text: 'Verify: `storebound console verify apple-app-record` reads the new record from the App Store Connect API (or pass the App Information page text with --from-text -).' });
|
||||||
|
return {
|
||||||
|
start: { url: `${ASC}/apps`, deepLink: false, reach: ['the + button > New App'] }, steps,
|
||||||
|
uiFacts: ['Modeled on Apple\'s "Add a new app" help page, not recorded: adapt to the page.', 'User Access is a plain heading with two radios (Full Access, Limited Access), not an ARIA group.', 'The Bundle ID list only has ids registered in Certificates, Identifiers & Profiles (`storebound apple bundle` registers it).', 'The name must be unique on the App Store; "already being used": ask the owner for a variant and set apple.name.'],
|
||||||
|
verify: { command: 'storebound console verify apple-app-record', source: 'api-or-page-text', capture: PAGE_TEXT, expect: ['An app with the bundle id exists, with the name, primary language and SKU of the answer sheet.'] },
|
||||||
|
};
|
||||||
|
case 'apple-age-rating':
|
||||||
|
add({ text: 'Nothing to fill in the browser: `storebound apple appinfo --dry`, then (after the owner agrees) `storebound apple appinfo` sets every age rating answer through the API.' });
|
||||||
|
add({ text: 'Verify: `storebound console verify apple-age-rating` reads the age rating declaration from the API.' });
|
||||||
|
return {
|
||||||
|
start: { url: config.apple?.appId ? `${ASC}/apps/${config.apple.appId}` : `${ASC}/apps`, deepLink: !!config.apple?.appId, reach: ['General > App Information > Age Ratings (only to look)'] }, steps,
|
||||||
|
uiFacts: ['The age rating has an API (ageRatingDeclarations): never fill it by hand when storebound can set it.'],
|
||||||
|
verify: { command: 'storebound console verify apple-age-rating', source: 'api', capture: '', expect: ['Every question of the API declaration equals the answer sheet.'] },
|
||||||
|
};
|
||||||
|
case 'play-app-record':
|
||||||
|
add({ text: 'Open the Create app form: /console/u/0/developers/<developer id>/create-new-app (deep link), or the developer account\'s app list (Home / All apps) and its page-level "Create app" button (it only opens the form; inside the form the same words are the final submit).', click: p.playDeveloperId ?? config.play?.developerId ? [] : ['Create app'] });
|
||||||
|
add({ text: 'Fill App name (max 30) and App package name (max 150), then press "Check availability" (read-only): it should say "Package name available".', click: ['Check availability'], answers: all(a => a.id === 'name' || a.id === 'packageName') });
|
||||||
|
add({ text: 'Choose the Default language; pick App or game and Free or paid. These are <material-radio> elements: click the inner [role=radio] (a DOM click from a script works in a background window), then check that aria-checked is "true" and that the "Select app or game" / "Select free or paid" hint is gone.', answers: all(a => ['language', 'type', 'price'].includes(a.id)) });
|
||||||
|
add({ text: 'Leave the two declarations (Developer Program Policies, US export laws) unticked: they are agreements the owner reads and ticks.', answers: all(a => a.status === 'agreement') });
|
||||||
|
add({ text: 'Stop. Do NOT press Create app: tell the owner to tick the declarations, check Free or paid (a free app can never become paid) and press Create app.' });
|
||||||
|
add({ text: 'After Create app the app opens at /console/u/0/developers/<developer id>/app/<app id>/app-dashboard: put that app id in play.appId (the other Play forms deep-link from it).' });
|
||||||
|
add({ text: `Owner step, never done by an agent or storebound (a permission grant): app-level permissions of the service account do NOT cover apps created later. The owner opens ${PLAY_SA_PATH}, then runs \`storebound apps check --store play\` to confirm the API sees the app.` });
|
||||||
|
add({ text: 'Verify before the owner presses Create app: run the capture snippet (verify.capture) in the page and pipe its JSON to `storebound console verify play-app-record --from-text -`.' });
|
||||||
|
return {
|
||||||
|
start: playStart(form, config, p), steps,
|
||||||
|
uiFacts: [
|
||||||
|
'Verified 2026-10-12 (Claude in Chrome on the real form):',
|
||||||
|
'URL: /console/u/0/developers/<developerId>/create-new-app (after the account chooser).',
|
||||||
|
'Fields: App name (textbox, max 30); App package name (textbox, max 150) with a "Check availability" button (read-only, safe to press) that reports "Package name available"; Default language (dropdown, default "English (United States) – en-US"); App or game (material-radio App / Game); Free or paid (material-radio Free / Paid); Declarations: two checkboxes (Developer Program Policies, US export laws), owner only.',
|
||||||
|
'Gotcha: in a background window, computer-tool clicks on the material radios did nothing, and setting the native inputs (form_input) did not update the Angular model: the form kept showing "Select app or game". Only a click on the inner [role=radio] of each material-radio (via JS: el.click()) updated it. Then verify aria-checked="true" and that no "Select …" hint is left.',
|
||||||
|
'Text inputs set with form_input update the character counters correctly.',
|
||||||
|
'After Create app the app lives at /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified): the app id deep-links the other Play forms (play.appId; storebound also remembers it).',
|
||||||
|
`A service account's app-level permissions do NOT cover apps created later: the owner adds the new app (${PLAY_SA_PATH}). Agents never do this (a permission grant; auto mode blocks it too).`,
|
||||||
|
'The package name is fixed for good once the app is created. Free can never become Paid.',
|
||||||
|
],
|
||||||
|
verify: { command: 'storebound console verify play-app-record --from-text -', source: 'page-text', capture: PLAY_CREATE_CAPTURE,
|
||||||
|
expect: ['App name and App package name equal the answer sheet.', 'Game / App and Free / Paid are aria-checked="true" as answered.', 'No "Select …" hint text is left on the form.', '"Package name available" after Check availability.'] },
|
||||||
|
};
|
||||||
|
case 'play-content-rating':
|
||||||
|
add({ text: 'Open the app > Policy and programs > App content > Content rating > Start (or Start new questionnaire).', click: ['Start'] });
|
||||||
|
add({ text: 'Page 1: type the email address and pick the category. Save draft, then Next.', click: ['Save draft', 'Next'], answers: ['email', 'category'] });
|
||||||
|
add({ text: 'Questionnaire: answer each question Yes or No as the sheet says; leave ask-owner questions to the owner. Save draft, then Next.', click: ['Save draft', 'Next'], answers: all(a => a.id.startsWith('q:')) });
|
||||||
|
add({ text: 'Summary: stop. Do NOT press Submit: tell the owner to review the summary and press Submit (it sends the questionnaire to IARC).' });
|
||||||
|
add({ text: 'Verify: read the Summary page as text and pipe it to `storebound console verify play-content-rating --from-text -`.' });
|
||||||
|
return {
|
||||||
|
start: playStart(form, config, p), steps,
|
||||||
|
uiFacts: ['Modeled on Google\'s "Content ratings" help page, not recorded: the IARC question wording differs per category; match questions by their topic (violence, fear, gambling …) and adapt.', '"Save draft" stores the one draft of the app: nothing goes to IARC before Submit.', 'The email address field gets the full app.contactEmail from the config (printed masked here).', PLAY_DASHBOARD_FACT],
|
||||||
|
verify: { command: 'storebound console verify play-content-rating --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The Summary shows each question with the Yes / No of the answer sheet.'] },
|
||||||
|
};
|
||||||
|
case 'play-target-audience':
|
||||||
|
add({ text: 'Open the app > Policy and programs > App content > Target audience and content > Start (or Manage).', click: ['Start'] });
|
||||||
|
add({ text: 'Target age: tick exactly the age groups of the sheet. Next.', click: ['Next'], answers: all(a => a.id.startsWith('age:')) });
|
||||||
|
if (answers.some(a => a.id === 'appeal')) add({ text: 'Appeal to children: pick the answer. Next.', click: ['Next'], answers: ['appeal'] });
|
||||||
|
add({ text: 'Ads (also App content > Ads): pick whether the app contains ads.', answers: ['ads'] });
|
||||||
|
add({ text: 'Stop. Do NOT press the last Save: tell the owner to review and press Save (Google reviews the declaration).' });
|
||||||
|
add({ text: 'Verify: read the section\'s summary as text and pipe it to `storebound console verify play-target-audience --from-text -`.' });
|
||||||
|
return {
|
||||||
|
start: playStart(form, config, p), steps,
|
||||||
|
uiFacts: ['Modeled on Google\'s "Target audience and content" help page, not recorded: adapt to the page.', 'Any age group under 13 brings the Families policy.', 'Save on the last page is final (Google reviews it).', PLAY_DASHBOARD_FACT],
|
||||||
|
verify: { command: 'storebound console verify play-target-audience --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The target age groups and the ads answer equal the answer sheet.'] },
|
||||||
|
};
|
||||||
|
case 'apple-app-privacy': throw new Error('unreachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consolePlan(form: FormId, config: ShipConfig, p: PlanIds = {}): ConsolePlan {
|
||||||
|
const cfg = p.appleAppId && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: p.appleAppId } } : config;
|
||||||
|
const meta = formMeta(form, cfg);
|
||||||
|
const raw = consoleAnswers(form, cfg);
|
||||||
|
const answers = sheet(form, raw);
|
||||||
|
const part = form === 'apple-app-privacy' ? appPrivacyPlan(answers, cfg.apple?.appId) : otherPlan(form, cfg, answers, p);
|
||||||
|
const rules = FORM_BUTTONS[form];
|
||||||
|
const neverPress = [...new Set([...rules.filter(r => !r.auto && r.class === 'final-submit').flatMap(r => r.button.replace(/ \((?:form|and its confirmation dialog)\)$/, '').split(' / ')), ...NEVER_PRESS])];
|
||||||
|
const askOwner = answers.filter(a => a.status === 'ask-owner' || a.status === 'owner-check' || a.status === 'agreement')
|
||||||
|
.map(a => `${a.question}: ${a.status === 'agreement' ? 'an agreement, the owner ticks it' : a.status === 'owner-check' ? `${a.text} (owner check: ${a.note ?? ''})` : `ask the owner${a.note ? ` (${a.note})` : ''}`}`);
|
||||||
|
return {
|
||||||
|
form, store: meta.store, title: meta.title, ...part, answers, askOwner,
|
||||||
|
buttons: {
|
||||||
|
neverPress, final: meta.stopBefore,
|
||||||
|
allowed: rules.filter(r => r.auto).map(r => ({ button: r.button, class: r.class, where: r.where })),
|
||||||
|
rule: `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (${rules.filter(r => r.class === 'draft-save').map(r => r.button).join(', ') || 'none in this form'}). Never press ${meta.stopBefore} or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.`,
|
||||||
|
},
|
||||||
|
driving: DRIVING,
|
||||||
|
fillable: meta.fillable,
|
||||||
|
guidance: [...meta.open.map(o => `Open: ${o}`), ...(meta.notes ?? []).map(n => `Note: ${n}`)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plan as text for the terminal. */
|
||||||
|
export function planText(pl: ConsolePlan): string {
|
||||||
|
const L: string[] = [`${pl.title}${pl.fillable ? '' : ' (guide only)'}`, `start: ${pl.start.url}${pl.start.deepLink ? ' (deep link)' : ''}`, ...pl.start.reach.map(r => ` then: ${r}`), '', 'Steps:'];
|
||||||
|
for (const s of pl.steps) L.push(` ${s.n}. ${s.text}${s.click?.length ? ` [click: ${s.click.map(c => `"${c}"`).join(', ')}]` : ''}`);
|
||||||
|
L.push('', 'Answer sheet:');
|
||||||
|
for (const a of pl.answers) {
|
||||||
|
const tag = a.status === 'certain' ? '[ok] ' : a.status === 'owner-check' ? '[owner]' : a.status === 'agreement' ? '[you] ' : '[ask] ';
|
||||||
|
L.push(` ${tag} ${a.question}: ${a.text}${a.label ? ` -> ${a.action ?? ''} "${a.label}"${a.curlyQuotes ? ' (curly quotes)' : ''}` : ''}${a.where ? ` @ ${a.where}` : ''}`);
|
||||||
|
if (a.status !== 'certain' && a.note) L.push(` ${a.note}`);
|
||||||
|
}
|
||||||
|
L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (the owner's): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`), '', `Verify: ${pl.verify.command}`, ...pl.verify.expect.map(e => ` - ${e}`));
|
||||||
|
if (pl.askOwner.length) L.push('', 'Ask the owner first:', ...pl.askOwner.map(a => ` - ${a}`));
|
||||||
|
return L.join('\n');
|
||||||
|
}
|
||||||
321
cli/lib/console-verify.ts
Normal file
321
cli/lib/console-verify.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
// `storebound console verify <form>`: checks what a console form shows against the config's answers, read-only. Pure
|
||||||
|
// (the command in cli/console/verify.ts feeds it page text, an input-state JSON or API attributes).
|
||||||
|
// Sources per form:
|
||||||
|
// apple-app-privacy page text only (the App Store Connect API has no App Privacy endpoint): the Data Types
|
||||||
|
// section after each type was saved shows "Edit" and a summary per type: "Used for <purposes>",
|
||||||
|
// "Linked to the user’s identity" / "Not linked …", "Used for tracking purposes" / "Not used …"
|
||||||
|
// (verified 2026-10-12). A summary without a linked / tracking line is read as No (marked).
|
||||||
|
// play-app-record the Create app form's input states (JSON from the plan's capture snippet: text fields, the
|
||||||
|
// checked [role=radio] labels, the page text for "Select …" hints) or plain page text
|
||||||
|
// apple-app-record the API (the app by bundle id: name, primary locale, SKU) or page text
|
||||||
|
// apple-age-rating the API (ageRatingDeclaration)
|
||||||
|
// play-content-rating, play-target-audience page text (summary pages: a label line, its answer on the same or the
|
||||||
|
// next line). Modeled, not recorded: rows that cannot be found are "not found", never a match.
|
||||||
|
import type { ShipConfig } from './config';
|
||||||
|
import { type Answer, consoleAnswers, type FormId, valueText } from './console-answers';
|
||||||
|
import { APPLE_PRIVACY_TYPES } from './app-privacy';
|
||||||
|
import { typeLabels } from './console-forms';
|
||||||
|
import { APPLE_PURPOSES } from './console-answers';
|
||||||
|
|
||||||
|
export type RowStatus = 'match' | 'mismatch' | 'not-found' | 'ask-owner';
|
||||||
|
export interface VerifyRow { item: string; expected: string; seen: string; status: RowStatus; note?: string }
|
||||||
|
export interface VerifyResult {
|
||||||
|
form: FormId;
|
||||||
|
source: 'page-text' | 'inputs' | 'api';
|
||||||
|
rows: VerifyRow[];
|
||||||
|
mismatches: number;
|
||||||
|
matched: number;
|
||||||
|
/** true when nothing mismatches and at least one row matched. */
|
||||||
|
ok: boolean;
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = (text: string): string[] => text.normalize('NFC').split(/\r?\n/).map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
||||||
|
/** Straight and curly quotes as one, case and spaces aside: for comparing labels. */
|
||||||
|
export const fold = (s: string): string => s.normalize('NFC').replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||||
|
|
||||||
|
function result(form: FormId, source: VerifyResult['source'], rows: VerifyRow[], notes: string[] = []): VerifyResult {
|
||||||
|
const mismatches = rows.filter(r => r.status === 'mismatch').length, matched = rows.filter(r => r.status === 'match').length;
|
||||||
|
return { form, source, rows, mismatches, matched, ok: mismatches === 0 && matched > 0, notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
const yn = (v: boolean | null | undefined): string => (v === true ? 'Yes' : v === false ? 'No' : '?');
|
||||||
|
|
||||||
|
// ---- App Privacy ----
|
||||||
|
|
||||||
|
export interface PrivacyTypeSeen {
|
||||||
|
/** "Set Up <type>" is still shown: not answered yet. */
|
||||||
|
setUp: boolean;
|
||||||
|
/** Purposes from "Used for …" (Apple's names), null when no such line. */
|
||||||
|
purposes: string[] | null;
|
||||||
|
linked: boolean | null;
|
||||||
|
tracking: boolean | null;
|
||||||
|
/** linked / tracking were not shown and are read as No. */
|
||||||
|
inferred: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every console label of a data type -> Apple's name (Purchases -> Purchase History, Other Data -> Other Data Types). */
|
||||||
|
const TYPE_OF = new Map<string, string>(Object.values(APPLE_PRIVACY_TYPES).flatMap(([, n]) => typeLabels(n).map(l => [fold(l), n] as [string, string])));
|
||||||
|
const PURPOSES_FOLDED = APPLE_PURPOSES.map(p => [fold(p), p] as const);
|
||||||
|
|
||||||
|
/** The per-type summaries of App Privacy's Data Types section (innerText of the page or of the section). */
|
||||||
|
export function parseAppPrivacy(text: string): Map<string, PrivacyTypeSeen> {
|
||||||
|
let ls = lines(text);
|
||||||
|
const at = ls.findIndex(l => fold(l) === 'data types');
|
||||||
|
if (at >= 0) ls = ls.slice(at + 1);
|
||||||
|
const out = new Map<string, PrivacyTypeSeen>();
|
||||||
|
const entry = (t: string) => { let e = out.get(t); if (!e) { e = { setUp: false, purposes: null, linked: null, tracking: null, inferred: [] }; out.set(t, e); } return e; };
|
||||||
|
let cur: string | null = null;
|
||||||
|
for (const l of ls) {
|
||||||
|
const f = fold(l);
|
||||||
|
const set = /^set up (.+)$/.exec(f);
|
||||||
|
if (set && TYPE_OF.has(set[1]!)) { cur = TYPE_OF.get(set[1]!)!; entry(cur).setUp = true; continue; }
|
||||||
|
if (TYPE_OF.has(f)) { cur = TYPE_OF.get(f)!; continue; }
|
||||||
|
if (!cur) continue;
|
||||||
|
if (/^used for (?!tracking)/.test(f)) {
|
||||||
|
const rest = f.slice('used for '.length);
|
||||||
|
entry(cur).purposes = PURPOSES_FOLDED.filter(([k]) => rest.includes(k)).map(([, p]) => p);
|
||||||
|
} else if (/^not linked to the user/.test(f)) entry(cur).linked = false;
|
||||||
|
else if (/^linked to the user/.test(f)) entry(cur).linked = true;
|
||||||
|
else if (/^not used for tracking/.test(f)) entry(cur).tracking = false;
|
||||||
|
else if (/^used for tracking/.test(f)) entry(cur).tracking = true;
|
||||||
|
}
|
||||||
|
// a type with a summary but without a linked / tracking line: the console shows only what applies
|
||||||
|
for (const e of out.values()) {
|
||||||
|
if (e.purposes === null || e.setUp) continue;
|
||||||
|
if (e.linked === null) { e.linked = false; e.inferred.push('linked'); }
|
||||||
|
if (e.tracking === null) { e.tracking = false; e.inferred.push('tracking'); }
|
||||||
|
}
|
||||||
|
// a label followed by nothing (a category line equal to a type name, "Purchases" above "Set Up Purchase History")
|
||||||
|
for (const [t, e] of [...out]) if (!e.setUp && e.purposes === null && e.linked === null && e.tracking === null) out.delete(t);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAppPrivacy(config: ShipConfig, text: string): VerifyResult {
|
||||||
|
const answers = consoleAnswers('apple-app-privacy', config);
|
||||||
|
const by = new Map(answers.map(a => [a.id, a]));
|
||||||
|
const seen = parseAppPrivacy(text);
|
||||||
|
const rows: VerifyRow[] = [];
|
||||||
|
const notes = ['The App Store Connect API has no App Privacy endpoint: checked from the page text.'];
|
||||||
|
const types = answers.filter(a => a.id.startsWith('type:'));
|
||||||
|
for (const a of types) {
|
||||||
|
const t = a.id.slice(5), s = seen.get(t);
|
||||||
|
if (a.value === false) {
|
||||||
|
if (s) rows.push({ item: t, expected: 'not collected', seen: s.setUp ? 'listed (Set Up)' : 'listed with a summary', status: a.certain ? 'mismatch' : 'ask-owner' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (a.value === null) { if (s) rows.push({ item: t, expected: '(owner decides)', seen: 'listed', status: 'ask-owner', ...(a.note ? { note: a.note } : {}) }); continue; }
|
||||||
|
if (!s) { rows.push({ item: t, expected: 'collected', seen: 'not on the page', status: a.certain ? 'mismatch' : 'ask-owner' }); continue; }
|
||||||
|
if (s.setUp) { rows.push({ item: t, expected: 'set up', seen: `"Set Up ${t}" still shown`, status: 'mismatch' }); continue; }
|
||||||
|
const purposes = answers.filter(x => x.id.startsWith(`purpose:${t}:`));
|
||||||
|
const want = purposes.filter(x => x.value === true).map(x => x.id.split(':')[2]!);
|
||||||
|
const unsure = purposes.some(x => !x.certain);
|
||||||
|
const got = s.purposes ?? [];
|
||||||
|
const same = want.length === got.length && want.every(p => got.includes(p));
|
||||||
|
rows.push({ item: `${t}: purposes`, expected: want.join(', ') || '(none)', seen: s.purposes ? got.join(', ') || '(none)' : 'no "Used for" line', status: s.purposes === null ? 'not-found' : unsure && !same ? 'ask-owner' : same ? 'match' : 'mismatch' });
|
||||||
|
for (const k of ['linked', 'tracking'] as const) {
|
||||||
|
const x = by.get(`${k}:${t}`);
|
||||||
|
const v = s[k];
|
||||||
|
const label = k === 'linked' ? 'linked to the user' : 'used for tracking';
|
||||||
|
const inferred = s.inferred.includes(k) ? 'not shown, read as No' : undefined;
|
||||||
|
if (!x || x.value === null) rows.push({ item: `${t}: ${label}`, expected: '(owner decides)', seen: yn(v), status: 'ask-owner', ...(inferred ? { note: inferred } : {}) });
|
||||||
|
else rows.push({ item: `${t}: ${label}`, expected: yn(x.value as boolean), seen: yn(v), status: v === null ? 'not-found' : v === x.value ? 'match' : 'mismatch', ...(inferred ? { note: inferred } : {}) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result('apple-app-privacy', 'page-text', rows, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Play Create app ----
|
||||||
|
|
||||||
|
/** The input states the plan's capture snippet returns. */
|
||||||
|
export interface InputStates {
|
||||||
|
/** Text inputs by their label (or aria-label). */
|
||||||
|
fields?: Record<string, string>;
|
||||||
|
/** Labels of the [role=radio] elements with aria-checked=true (and checked native radios). */
|
||||||
|
checked?: string[];
|
||||||
|
/** The visible page text (for "Select …" hints and availability messages). */
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The capture snippet an agent runs in the page for `console verify play-app-record --from-text -` (prints JSON). */
|
||||||
|
export const PLAY_CREATE_CAPTURE = `(() => { const t = e => (e?.innerText ?? e?.textContent ?? '').trim().replace(/\\s+/g, ' ');
|
||||||
|
const name = e => e.getAttribute('aria-label') || t(e.labels?.[0]) || (e.getAttribute('aria-labelledby') || '').split(/\\s+/).map(id => t(document.getElementById(id))).join(' ').trim() || t(e.closest('label')) || t(e.parentElement);
|
||||||
|
const fields = {}; for (const i of document.querySelectorAll('input:not([type]), input[type=text], textarea')) if (i.offsetParent) fields[name(i)] = i.value;
|
||||||
|
const checked = [...document.querySelectorAll('[role=radio][aria-checked=true], input[type=radio]:checked')].map(r => name(r) || t(r.closest('material-radio')));
|
||||||
|
return JSON.stringify({ fields, checked, text: (document.querySelector('main') ?? document.body).innerText }); })()`;
|
||||||
|
|
||||||
|
export function parseInputStates(raw: string): InputStates | null {
|
||||||
|
const s = raw.trim();
|
||||||
|
if (!s.startsWith('{')) return null;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(s) as Record<string, unknown>;
|
||||||
|
const fields = j.fields && typeof j.fields === 'object' ? Object.fromEntries(Object.entries(j.fields as Record<string, unknown>).filter(([, v]) => typeof v === 'string')) as Record<string, string> : undefined;
|
||||||
|
const checked = Array.isArray(j.checked) ? j.checked.filter((x): x is string => typeof x === 'string') : undefined;
|
||||||
|
return { ...(fields ? { fields } : {}), ...(checked ? { checked } : {}), ...(typeof j.text === 'string' ? { text: j.text } : {}) };
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A "Select …" hint the Create app form shows while a radio group has no value in its model ("Select app or game"). */
|
||||||
|
export const SELECT_HINT = /^select (app or game|free or paid|an? .+)$/i;
|
||||||
|
|
||||||
|
export function verifyPlayAppRecord(config: ShipConfig, raw: string): VerifyResult {
|
||||||
|
const answers = consoleAnswers('play-app-record', config).filter(a => !a.agreement);
|
||||||
|
const st = parseInputStates(raw);
|
||||||
|
const text = st?.text ?? (st ? '' : raw);
|
||||||
|
const ls = lines(text);
|
||||||
|
const rows: VerifyRow[] = [];
|
||||||
|
const fieldValue = (labels: string[]): string | null => {
|
||||||
|
if (st?.fields) for (const [k, v] of Object.entries(st.fields)) if (labels.some(l => fold(k).startsWith(fold(l)))) return v;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const checked = (st?.checked ?? []).map(fold);
|
||||||
|
for (const a of answers) {
|
||||||
|
const exp = a.value === null ? null : String(a.value);
|
||||||
|
if (a.id === 'name' || a.id === 'packageName') {
|
||||||
|
const labels = a.id === 'name' ? ['App name', 'Uygulama adı'] : ['App package name', 'Package name', 'Uygulama paket adı'];
|
||||||
|
const v = fieldValue(labels);
|
||||||
|
const inText = v === null && exp !== null && ls.some(l => l === exp || l.endsWith(` ${exp}`));
|
||||||
|
const seen = v ?? (inText ? exp! : null);
|
||||||
|
rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: seen ?? 'not found', status: exp === null ? 'ask-owner' : seen === null ? 'not-found' : seen === exp ? 'match' : 'mismatch' });
|
||||||
|
} else if (a.id === 'type' || a.id === 'price') {
|
||||||
|
const opts = a.id === 'type' ? ['App', 'Game'] : ['Free', 'Paid'];
|
||||||
|
const got = st?.checked ? opts.filter(o => checked.some(c => c === fold(o) || c.startsWith(`${fold(o)} `))) : [];
|
||||||
|
const seen = got.length === 1 ? got[0]! : got.length ? got.join(' + ') : null;
|
||||||
|
rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: seen ?? (st?.checked ? 'nothing selected' : 'not readable from page text: pass the input states (plan capture)'), status: exp === null ? 'ask-owner' : seen === null ? (st?.checked ? 'mismatch' : 'not-found') : seen === exp ? 'match' : 'mismatch' });
|
||||||
|
} else if (a.id === 'language') {
|
||||||
|
const v = fieldValue(['Default language', 'Varsayılan dil']);
|
||||||
|
const inText = exp !== null && ls.some(l => l.includes(exp));
|
||||||
|
rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: v ?? (inText ? exp! : 'not found'), status: exp === null ? 'ask-owner' : v !== null ? (v.includes(exp) ? 'match' : 'mismatch') : inText ? 'match' : 'not-found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hints = ls.filter(l => SELECT_HINT.test(l));
|
||||||
|
rows.push({ item: '"Select …" hints (a radio set on the input but not in the form\'s model)', expected: 'none', seen: hints.length ? hints.join('; ') : 'none', status: hints.length ? 'mismatch' : text ? 'match' : 'not-found',
|
||||||
|
...(hints.length ? { note: 'click the inner [role=radio] of that material-radio (a JS click), then check again' } : {}) });
|
||||||
|
if (ls.some(l => /package name (is )?available/i.test(l))) rows.push({ item: 'Check availability', expected: 'Package name available', seen: 'Package name available', status: 'match' });
|
||||||
|
return result('play-app-record', st ? 'inputs' : 'page-text', rows, st ? [] : ['Page text only: radios cannot be read from text. For a full check pass the JSON of the plan\'s capture snippet.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- label / answer pairs from page text (summaries) ----
|
||||||
|
|
||||||
|
/** The answer next to a label: on the same line after the label, or the next line. */
|
||||||
|
function valueAfter(ls: string[], label: RegExp): string | null {
|
||||||
|
for (let i = 0; i < ls.length; i++) {
|
||||||
|
const l = ls[i]!;
|
||||||
|
const m = label.exec(l);
|
||||||
|
if (!m) continue;
|
||||||
|
const rest = l.slice(m.index + m[0].length).replace(/^[\s:–-]+/, '').trim();
|
||||||
|
return rest || ls[i + 1] || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const yesNoOf = (s: string | null): boolean | null => (s === null ? null : /^(yes|evet)\b/i.test(s) ? true : /^(no|hayır)\b/i.test(s) ? false : null);
|
||||||
|
|
||||||
|
function labelRows(answers: Answer[], ls: string[], labelOf: (a: Answer) => RegExp | null): VerifyRow[] {
|
||||||
|
const rows: VerifyRow[] = [];
|
||||||
|
for (const a of answers) {
|
||||||
|
if (a.agreement) continue;
|
||||||
|
const re = labelOf(a);
|
||||||
|
if (!re) continue;
|
||||||
|
const raw = valueAfter(ls, re);
|
||||||
|
if (typeof a.value === 'boolean' || a.value === null && raw !== null && yesNoOf(raw) !== null) {
|
||||||
|
const v = yesNoOf(raw);
|
||||||
|
rows.push({ item: a.label, expected: a.value === null ? '(owner decides)' : yn(a.value as boolean), seen: v === null ? (raw ?? 'not found') : yn(v), status: a.value === null ? 'ask-owner' : v === null ? 'not-found' : v === a.value ? 'match' : 'mismatch' });
|
||||||
|
} else {
|
||||||
|
const exp = a.value === null ? null : String(a.value);
|
||||||
|
rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: raw ?? 'not found', status: exp === null ? 'ask-owner' : raw === null ? 'not-found' : fold(raw).includes(fold(exp)) ? 'match' : 'mismatch' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
export function verifyContentRating(config: ShipConfig, text: string): VerifyResult {
|
||||||
|
const answers = consoleAnswers('play-content-rating', config).filter(a => a.id !== 'email');
|
||||||
|
const TOPIC: Record<string, RegExp> = {
|
||||||
|
violence: /violen/i, fear: /(fear|horror|scary)/i, sexuality: /(sexual|nudity)/i, language: /(profan|crude language)/i, controlledSubstances: /(drug|alcohol|tobacco|controlled substance)/i,
|
||||||
|
crudeHumor: /crude humou?r/i, gambling: /real[- ](money|currency)/i, simulatedGambling: /simulated gambling/i, lootBoxes: /(loot box|random(ized)? (paid )?items)/i,
|
||||||
|
userInteraction: /(interact|communicate|exchange)/i, sharesLocation: /location/i, digitalPurchases: /digital (goods|purchases)|purchase digital/i, unrestrictedInternet: /(unrestricted (internet|web)|web browser)/i,
|
||||||
|
};
|
||||||
|
const rows = labelRows(answers, lines(text), a => (a.id === 'category' ? /^(category|app category)\b/i : a.id.startsWith('q:') ? TOPIC[a.id.slice(2)] ?? null : null));
|
||||||
|
return result('play-content-rating', 'page-text', rows, ['Modeled summary wording: a question that is not found is reported, never counted as a match.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyTargetAudience(config: ShipConfig, text: string): VerifyResult {
|
||||||
|
const answers = consoleAnswers('play-target-audience', config);
|
||||||
|
const ls = lines(text);
|
||||||
|
const rows: VerifyRow[] = [];
|
||||||
|
const ages = answers.filter(a => a.id.startsWith('age:'));
|
||||||
|
const blob = fold(ls.join('\n'));
|
||||||
|
if (ages.length) {
|
||||||
|
const want = ages.filter(a => a.value === true).map(a => a.label.replace(/^Target age: /, ''));
|
||||||
|
const shown = ages.map(a => a.label.replace(/^Target age: /, '')).filter(l => blob.includes(fold(l)) || blob.includes(fold(l.replace(/^Ages /, ''))));
|
||||||
|
const unsure = ages.some(a => !a.certain);
|
||||||
|
const same = want.length === shown.length && want.every(w => shown.includes(w));
|
||||||
|
rows.push({ item: 'Target age groups', expected: unsure ? '(owner decides)' : want.join(', '), seen: shown.join(', ') || 'not found', status: unsure ? 'ask-owner' : !shown.length ? 'not-found' : same ? 'match' : 'mismatch' });
|
||||||
|
}
|
||||||
|
rows.push(...labelRows(answers.filter(a => a.id === 'appeal'), ls, () => /appeal to children/i));
|
||||||
|
const ads = answers.find(a => a.id === 'ads');
|
||||||
|
if (ads) {
|
||||||
|
const no = ls.some(l => /(does not|doesn't) contain ads|no,? (my app )?(does not|doesn't) contain ads|^no ads$/i.test(l));
|
||||||
|
const yes = !no && ls.some(l => /contains ads|^ads$|yes,? my app contains ads/i.test(l));
|
||||||
|
const seen = no ? false : yes ? true : null;
|
||||||
|
rows.push({ item: ads.label, expected: ads.value === null ? '(owner decides)' : yn(ads.value as boolean), seen: yn(seen), status: ads.value === null ? 'ask-owner' : seen === null ? 'not-found' : seen === ads.value ? 'match' : 'mismatch' });
|
||||||
|
}
|
||||||
|
return result('play-target-audience', 'page-text', rows, ['Modeled summary wording: rows that are not found are reported, never counted as a match.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAppRecordText(config: ShipConfig, text: string): VerifyResult {
|
||||||
|
const answers = consoleAnswers('apple-app-record', config).filter(a => ['name', 'language', 'bundleId', 'sku'].includes(a.id));
|
||||||
|
const rows = labelRows(answers, lines(text), a => new RegExp(`^${escRe(a.label)}\\b`, 'i'));
|
||||||
|
return result('apple-app-record', 'page-text', rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** apple-app-record against the API's app attributes (GET /v1/apps?filter[bundleId]). null: no such app. */
|
||||||
|
export function verifyAppRecordApi(config: ShipConfig, app: { name?: string; bundleId?: string; sku?: string; primaryLocale?: string } | null): VerifyResult {
|
||||||
|
const answers = consoleAnswers('apple-app-record', config);
|
||||||
|
const by = new Map(answers.map(a => [a.id, a]));
|
||||||
|
if (!app) return result('apple-app-record', 'api', [{ item: 'App record', expected: `an app with bundle id ${config.apple?.bundleId ?? '?'}`, seen: 'none', status: 'mismatch' }]);
|
||||||
|
const locale = config.apple?.primaryLocale ?? config.languages.find(l => l.apple)?.apple ?? null;
|
||||||
|
const row = (item: string, exp: string | null, seen: string | undefined, certain = true): VerifyRow => ({ item, expected: exp ?? '(owner decides)', seen: seen ?? 'not set', status: exp === null || !certain ? 'ask-owner' : seen === undefined ? 'not-found' : seen === exp ? 'match' : 'mismatch' });
|
||||||
|
const name = by.get('name'), sku = by.get('sku');
|
||||||
|
return result('apple-app-record', 'api', [
|
||||||
|
row('Bundle ID', config.apple?.bundleId ?? null, app.bundleId),
|
||||||
|
row('Name', name?.value === null || name === undefined ? null : String(name.value), app.name),
|
||||||
|
row('Primary Language (locale)', locale, app.primaryLocale),
|
||||||
|
row('SKU', sku?.certain ? String(sku.value) : null, app.sku),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** apple-age-rating against the API's ageRatingDeclaration attributes. */
|
||||||
|
export function verifyAgeRatingApi(config: ShipConfig, attrs: Record<string, unknown> | null): VerifyResult {
|
||||||
|
const answers = consoleAnswers('apple-age-rating', config);
|
||||||
|
if (!attrs) return result('apple-age-rating', 'api', [{ item: 'Age rating declaration', expected: 'set', seen: 'none', status: 'mismatch' }]);
|
||||||
|
const rows = answers.map(a => {
|
||||||
|
const seen = attrs[a.id];
|
||||||
|
const s = seen === undefined || seen === null ? undefined : String(seen);
|
||||||
|
return { item: a.id, expected: a.certain ? valueText(a) : '(owner decides)', seen: s ?? 'not set', status: (!a.certain ? 'ask-owner' : s === undefined ? 'not-found' : s === String(a.value) ? 'match' : 'mismatch') as RowStatus };
|
||||||
|
});
|
||||||
|
return result('apple-age-rating', 'api', rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page-text verification of any form that has one. */
|
||||||
|
export function verifyText(form: FormId, config: ShipConfig, text: string): VerifyResult {
|
||||||
|
switch (form) {
|
||||||
|
case 'apple-app-privacy': return verifyAppPrivacy(config, text);
|
||||||
|
case 'play-app-record': return verifyPlayAppRecord(config, text);
|
||||||
|
case 'play-content-rating': return verifyContentRating(config, text);
|
||||||
|
case 'play-target-audience': return verifyTargetAudience(config, text);
|
||||||
|
case 'apple-app-record': return verifyAppRecordText(config, text);
|
||||||
|
case 'apple-age-rating': throw new Error('console verify apple-age-rating reads the App Store Connect API (no --from-text)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyTable(r: VerifyResult): string {
|
||||||
|
const w = Math.min(48, Math.max(4, ...r.rows.map(x => x.item.length)));
|
||||||
|
const L = [`${r.form} (${r.source})`, ''];
|
||||||
|
for (const x of r.rows) L.push(` ${x.status.padEnd(9)} ${x.item.padEnd(w)} expected ${x.expected} | page ${x.seen}${x.note ? ` (${x.note})` : ''}`);
|
||||||
|
L.push('', `${r.matched} match, ${r.mismatches} mismatch, ${r.rows.filter(x => x.status === 'not-found').length} not found, ${r.rows.filter(x => x.status === 'ask-owner').length} for the owner`, ...r.notes.map(n => `note: ${n}`));
|
||||||
|
if (!r.ok) L.push(r.mismatches ? 'MISMATCH: fix the page (or the config), then verify again.' : 'Nothing could be matched: pass the page text of the finished form.');
|
||||||
|
return L.join('\n');
|
||||||
|
}
|
||||||
|
|
@ -390,6 +390,15 @@ export const TOOLS: ToolDef[] = [
|
||||||
{ name: 'apple_events_upload', title: 'Upload in-app event images', command: ['apple', 'events', 'upload'], write: true, destructive: true, network: true, idempotent: true,
|
{ name: 'apple_events_upload', title: 'Upload in-app event images', command: ['apple', 'events', 'upload'], write: true, destructive: true, network: true, idempotent: true,
|
||||||
description: 'Upload the event card (16:9, 1920x1080 to 3840x2160) and details page (9:16) images of one draft in-app event per locale, size-checked first.',
|
description: 'Upload the event card (16:9, 1920x1080 to 3840x2160) and details page (9:16) images of one draft in-app event per locale, size-checked first.',
|
||||||
props: { name: { type: 'string', description: 'Reference name from apple.events' } }, required: ['name'], args: i => opt('name', i.name) },
|
props: { name: { type: 'string', description: 'Reference name from apple.events' } }, required: ['name'], args: i => opt('name', i.name) },
|
||||||
|
{ name: 'console_plan', title: 'Console form plan (agent-first)', command: ['console', 'plan'], write: false, network: false,
|
||||||
|
description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form, how to drive long flows (an in-page async loop writing window.__log) and how to verify. Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.',
|
||||||
|
props: { form: { type: 'string', enum: ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] } },
|
||||||
|
required: ['form'], args: i => [String(i.form), '--json'] },
|
||||||
|
{ name: 'console_verify', title: 'Verify a console form', command: ['console', 'verify'], write: false, network: true,
|
||||||
|
description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text). apple-app-record and apple-age-rating without text read the App Store Connect API. Returns a per-item match / mismatch table; exit code 1 on a mismatch.',
|
||||||
|
props: { form: { type: 'string', enum: ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] },
|
||||||
|
text: { type: 'string', description: 'The page text (or the capture JSON) of the finished form' } },
|
||||||
|
required: ['form'], args: i => [String(i.form), ...(typeof i.text === 'string' && i.text ? [`--text=${i.text}`] : []), '--json'] },
|
||||||
{ name: 'apple_preorder_status', title: 'App Store pre-order status', command: ['apple', 'preorder', 'status'], write: false, network: true,
|
{ name: 'apple_preorder_status', title: 'App Store pre-order status', command: ['apple', 'preorder', 'status'], write: false, network: true,
|
||||||
description: 'Read-only: per territory whether the app is available, its release date, pre-order on / off and its content status. Enabling or ending a pre-order is an owner-only terminal command.', args: () => ['--json'] },
|
description: 'Read-only: per territory whether the app is available, its release date, pre-order on / off and its content status. Enabling or ending a pre-order is an owner-only terminal command.', args: () => ['--json'] },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,10 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
|
||||||
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
|
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
|
||||||
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
|
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
|
||||||
and never ask for tester emails in chat (storebound masks them in its output).
|
and never ask for tester emails in chat (storebound masks them in its output).
|
||||||
|
10. **Console forms without an API: drive them yourself, agent-first.** `storebound console plan <form> --json` (MCP
|
||||||
|
`console_plan`) gives the start URL, steps, the answer sheet, the buttons you must never press and the verified UI
|
||||||
|
facts. Drive the page with your own browser tool, adapt to what it shows, run long flows as an in-page async loop
|
||||||
|
that writes `window.__log` (one evaluate call times out around 45 s), stop before the final button (Publish,
|
||||||
|
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||||
|
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||||
|
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
||||||
| `appInfoLocalizations_getInstance` | GET | `/v1/appInfoLocalizations/{id}` | read | |
|
| `appInfoLocalizations_getInstance` | GET | `/v1/appInfoLocalizations/{id}` | read | |
|
||||||
| `appInfoLocalizations_updateInstance` | PATCH | `/v1/appInfoLocalizations/{id}` | write (dry by default) | |
|
| `appInfoLocalizations_updateInstance` | PATCH | `/v1/appInfoLocalizations/{id}` | write (dry by default) | |
|
||||||
| **appInfos** (20) | | | | |
|
| **appInfos** (20) | | | | |
|
||||||
| `appInfos_ageRatingDeclaration_getToOneRelated` | GET | `/v1/appInfos/{id}/ageRatingDeclaration` | read | apple/appinfo, validate |
|
| `appInfos_ageRatingDeclaration_getToOneRelated` | GET | `/v1/appInfos/{id}/ageRatingDeclaration` | read | apple/appinfo, console/verify, validate |
|
||||||
| `appInfos_ageRatingDeclaration_getToOneRelationship` | GET | `/v1/appInfos/{id}/relationships/ageRatingDeclaration` | read | |
|
| `appInfos_ageRatingDeclaration_getToOneRelationship` | GET | `/v1/appInfos/{id}/relationships/ageRatingDeclaration` | read | |
|
||||||
| `appInfos_appInfoLocalizations_getToManyRelated` | GET | `/v1/appInfos/{id}/appInfoLocalizations` | read | apple/listing, validate |
|
| `appInfos_appInfoLocalizations_getToManyRelated` | GET | `/v1/appInfos/{id}/appInfoLocalizations` | read | apple/listing, validate |
|
||||||
| `appInfos_appInfoLocalizations_getToManyRelationship` | GET | `/v1/appInfos/{id}/relationships/appInfoLocalizations` | read | |
|
| `appInfos_appInfoLocalizations_getToManyRelationship` | GET | `/v1/appInfos/{id}/relationships/appInfoLocalizations` | read | |
|
||||||
|
|
@ -458,7 +458,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
||||||
| `apps_gameCenterDetail_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/gameCenterDetail` | read | |
|
| `apps_gameCenterDetail_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/gameCenterDetail` | read | |
|
||||||
| `apps_gameCenterEnabledVersions_getToManyRelated` | GET | `/v1/apps/{id}/gameCenterEnabledVersions` | read | |
|
| `apps_gameCenterEnabledVersions_getToManyRelated` | GET | `/v1/apps/{id}/gameCenterEnabledVersions` | read | |
|
||||||
| `apps_gameCenterEnabledVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/gameCenterEnabledVersions` | read | |
|
| `apps_gameCenterEnabledVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/gameCenterEnabledVersions` | read | |
|
||||||
| `apps_getCollection` | GET | `/v1/apps` | read | apple/asc, apple/bundle, apps, console/app-id, lib/store-apps |
|
| `apps_getCollection` | GET | `/v1/apps` | read | apple/asc, apple/bundle, apps, console/app-id, console/verify, lib/store-apps |
|
||||||
| `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/appinfo, apple/status |
|
| `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/appinfo, apple/status |
|
||||||
| `apps_inAppPurchasesV2_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchasesV2` | read | apple/iap, apple/iap-review, apple/iap-submit, apple/offer-codes, apple/promoted, apple/status, catalog, prices, validate |
|
| `apps_inAppPurchasesV2_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchasesV2` | read | apple/iap, apple/iap-review, apple/iap-submit, apple/offer-codes, apple/promoted, apple/status, catalog, prices, validate |
|
||||||
| `apps_inAppPurchasesV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/inAppPurchasesV2` | read | |
|
| `apps_inAppPurchasesV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/inAppPurchasesV2` | read | |
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,15 @@ fails without the fix.
|
||||||
|
|
||||||
| # | date | lesson | guard |
|
| # | date | lesson | guard |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 20 | 2026-10 | Hours of selector-fix cycles of the scripted console engine (`console fill`): the real console kept differing from the model (App Privacy "Set Up" as plain text, purpose checkboxes without an accessible name, two information steps in a type dialog, Play's material radios that ignore the native input). | Agent-first console mode: `console plan <form>` gives an AI agent the start URL, steps, answer sheet with the console's labels, never-press list and verified UI facts; the agent drives the page with its own browser tool and stops before the final button; `console verify <form>` checks the result (App Privacy summaries from the page text, Play Create app from input states, API for the App Store record and age rating). The scripted fill stays a fast path (and now handles the verified facts). MCP: `console_plan`, `console_verify` (read-only). `tests/console-plan.test.ts`, `tests/console-flow.test.ts`. |
|
||||||
| 19 | 2026-10 | `play images --dry` aborted with "en-US: no sevenInchScreenshots ... (render first)" on a phone-only project: Play's 7" and 10" tablet screenshots are optional (required: 512 icon, 1024x500 feature graphic, at least 2 screenshots; Play Console Help answer 9866151), but every image type was treated as required. | `play images` uploads what exists (icon, feature graphic, phone screenshots, tablet / TV / Wear only when rendered), prints an info line for missing optional sets, never deletes what Play has for them without `--prune`; `play.tablets` (default: tablet renders exist) switches tablets for `shots render` / `shots check` / `shots gallery` / `validate` / `play images`, and `true` with missing renders is an error (`cli/play/images.ts` `langImages`, `resolvedTablets` in `cli/shots/report.ts`, `tests/play-post-reads.test.ts`). |
|
| 19 | 2026-10 | `play images --dry` aborted with "en-US: no sevenInchScreenshots ... (render first)" on a phone-only project: Play's 7" and 10" tablet screenshots are optional (required: 512 icon, 1024x500 feature graphic, at least 2 screenshots; Play Console Help answer 9866151), but every image type was treated as required. | `play images` uploads what exists (icon, feature graphic, phone screenshots, tablet / TV / Wear only when rendered), prints an info line for missing optional sets, never deletes what Play has for them without `--prune`; `play.tablets` (default: tablet renders exist) switches tablets for `shots render` / `shots check` / `shots gallery` / `validate` / `play images`, and `true` with missing renders is an error (`cli/play/images.ts` `langImages`, `resolvedTablets` in `cli/shots/report.ts`, `tests/play-post-reads.test.ts`). |
|
||||||
| 18 | 2026-10 | `console fill play-app-record` with a copy of the owner's Chrome cookies reached the Play account chooser once, then Google answered `/v3/signin/rejected` and the chooser: Google session cookies are device-bound (Chrome's Device Bound Session Credentials) and Google signs out sessions whose cookies look stolen, which can also sign the owner out of their real Chrome. | Never copy Google cookies by default: storebound's own profile is the default (the owner signs in once by hand), started by storebound as a normal Chrome (no `--enable-automation`, `navigator.webdriver` false) and attached over CDP; `--use-chrome-session google\|apple` is an explicit opt-in that warns; `/signin/rejected` is said plainly (`cli/console/session.ts`, `tests/console-session.test.ts`). |
|
| 18 | 2026-10 | `console fill play-app-record` with a copy of the owner's Chrome cookies reached the Play account chooser once, then Google answered `/v3/signin/rejected` and the chooser: Google session cookies are device-bound (Chrome's Device Bound Session Credentials) and Google signs out sessions whose cookies look stolen, which can also sign the owner out of their real Chrome. | Never copy Google cookies by default: storebound's own profile is the default (the owner signs in once by hand), started by storebound as a normal Chrome (no `--enable-automation`, `navigator.webdriver` false) and attached over CDP; `--use-chrome-session google\|apple` is an explicit opt-in that warns; `/signin/rejected` is said plainly (`cli/console/session.ts`, `tests/console-session.test.ts`). Confirmed 2026-10-12: the earlier default copy DID sign the owner out of Play Console in their real Chrome. |
|
||||||
| 17 | 2026-10 | After the data types were saved, the real App Privacy page shows each type's "Set Up <type>" as a plain `<p>` in a div with a click handler (no button role, no link) and puts the types in a box after the Data Types heading's box: role-based locators and the "nearest heading" check found nothing, so no type was set up. A "?" (More information) button sits next to each type. | `setUpAllowed` / `setUpTexts` (`cli/lib/console-forms.ts`: exact text "Set Up <type>" of a type storebound answers, in the Data Types section, outside dialogs) and `setUpClick` / `setUpType` / `flowWhere` (last heading before the control) in `cli/console/fill.ts`; the per-type dialog steps are `page` sections, so an unknown step dumps and pauses instead of ending the run; `tests/fixtures/console/apple-app-privacy-setup.html` (structure of the real page) and `tests/console-flow.test.ts`. |
|
| 17 | 2026-10 | After the data types were saved, the real App Privacy page shows each type's "Set Up <type>" as a plain `<p>` in a div with a click handler (no button role, no link) and puts the types in a box after the Data Types heading's box: role-based locators and the "nearest heading" check found nothing, so no type was set up. A "?" (More information) button sits next to each type. | `setUpAllowed` / `setUpTexts` (`cli/lib/console-forms.ts`: exact text "Set Up <type>" of a type storebound answers, in the Data Types section, outside dialogs) and `setUpClick` / `setUpType` / `flowWhere` (last heading before the control) in `cli/console/fill.ts`; the per-type dialog steps are `page` sections, so an unknown step dumps and pauses instead of ending the run; `tests/fixtures/console/apple-app-privacy-setup.html` (structure of the real page) and `tests/console-flow.test.ts`. |
|
||||||
| 16 | 2026-10 | `console fill play-app-record` with the copied Chrome cookies printed only "still waiting for the sign-in" for 5+ minutes on a signed-in Play Console: Google pages embed `accounts.google.com` frames (the account widget, cookie rotation), and the detector counted any frame on a sign-in host. | `signInReason`: a sign-in page only by its top-level URL, a visible password / email-entry form in the main frame, or a visible Apple sign-in frame (idmsa / appleid); other frames never count. Each detection logs "sign-in detected because: <rule> at <url>"; heartbeats name the page; a copied session that lands on a sign-in URL is said plainly ("did not carry over"); the watchdog dumps the sign-in wait once after `--step-timeout`, `--debug-dump` up to 3 times (`tests/console-fill.test.ts`, chooser / home with the widget frames, a top-level accounts.google.com page). |
|
| 16 | 2026-10 | `console fill play-app-record` with the copied Chrome cookies printed only "still waiting for the sign-in" for 5+ minutes on a signed-in Play Console: Google pages embed `accounts.google.com` frames (the account widget, cookie rotation), and the detector counted any frame on a sign-in host. | `signInReason`: a sign-in page only by its top-level URL, a visible password / email-entry form in the main frame, or a visible Apple sign-in frame (idmsa / appleid); other frames never count. Each detection logs "sign-in detected because: <rule> at <url>"; heartbeats name the page; a copied session that lands on a sign-in URL is said plainly ("did not carry over"); the watchdog dumps the sign-in wait once after `--step-timeout`, `--debug-dump` up to 3 times (`tests/console-fill.test.ts`, chooser / home with the widget frames, a top-level accounts.google.com page). |
|
||||||
| 15 | 2026-10 | The real App Privacy "Data Collection" dialog names each data type checkbox by its label run together with its help text ("NameIncluding first or last name"), and labels some types differently from Apple's help page ("Purchases", "Other Data"): exact-name matching found none. | `labelNames` (exact name, then the label as a prefix that ends where the help text starts) and `TYPE_ALIASES` in `cli/lib/console-forms.ts`; the flow fixture mirrors the real dialog's structure (`tests/console-flow.test.ts`). |
|
| 15 | 2026-10 | The real App Privacy "Data Collection" dialog names each data type checkbox by its label run together with its help text ("NameIncluding first or last name"), and labels some types differently from Apple's help page ("Purchases", "Other Data"): exact-name matching found none. | `labelNames` (exact name, then the label as a prefix that ends where the help text starts) and `TYPE_ALIASES` in `cli/lib/console-forms.ts`; the flow fixture mirrors the real dialog's structure (`tests/console-flow.test.ts`). |
|
||||||
| 14 | 2026-10 | `console fill apple-app-privacy` clicked Next onto a real console page it did not recognise and then waited 8 hours without a single log line. | Every wait of the console engine is bounded: a step watchdog (`--step-timeout`, 30 s) writes a sanitized debug dump to `.storebound/console-debug/` and prints the page's buttons and the manual steps; a line a minute while waiting; no progress for 20 min stops the run with exit 1; `--debug-dump` dumps every step (`cli/console/debug.ts`, `tests/console-flow.test.ts`, which also scans the engine for waits without a deadline). |
|
| 14 | 2026-10 | `console fill apple-app-privacy` clicked Next onto a real console page it did not recognise and then waited 8 hours without a single log line. | Every wait of the console engine is bounded: a step watchdog (`--step-timeout`, 30 s) writes a sanitized debug dump to `.storebound/console-debug/` and prints the page's buttons and the manual steps; a line a minute while waiting; no progress for 20 min stops the run with exit 1; `--debug-dump` dumps every step (`cli/console/debug.ts`, `tests/console-flow.test.ts`, which also scans the engine for waits without a deadline). |
|
||||||
| 13 | 2026-10 | App Privacy purposes were over-mapped: Play "advertising" became both Third-Party Advertising and Developer's Advertising or Marketing (Device ID, Coarse Location, Product Interaction, Performance Data). | `applePurpose` maps one Play purpose to one Apple purpose (advertising: third-party only); Developer's Advertising only from `appPrivacy.types`; `check` compares `appPrivacy.types` with Info.plist / PrivacyInfo.xcprivacy (`tests/app-privacy.test.ts`). |
|
| 13 | 2026-10 | App Privacy purposes were over-mapped: Play "advertising" became both Third-Party Advertising and Developer's Advertising or Marketing (Device ID, Coarse Location, Product Interaction, Performance Data). | `applePurpose` maps one Play purpose to one Apple purpose (advertising: third-party only); Developer's Advertising only from `appPrivacy.types`; `check` compares `appPrivacy.types` with Info.plist / PrivacyInfo.xcprivacy (`tests/app-privacy.test.ts`). |
|
||||||
| 12 | 2026-10 | A new Play app has no API: Play Console's "Create app" is the only way, its page-level button carries the same words as the form's final submit, and Free cannot become Paid later. | `console fill play-app-record`: `createAppAllowed` lets the opener through only on the app list, never in a form or dialog (EN + TR); declarations never ticked; Free / Paid outlined as an owner check (`tests/console-fill.test.ts`). |
|
| 12 | 2026-10 | A new Play app has no API: Play Console's "Create app" is the only way, its page-level button carries the same words as the form's final submit, and Free cannot become Paid later. | `console fill play-app-record`: `createAppAllowed` lets the opener through only on the app list, never in a form or dialog (EN + TR); declarations never ticked; Free / Paid outlined as an owner check (`tests/console-fill.test.ts`). Verified 2026-10-12: the form is at `/developers/<id>/create-new-app` and has an App package name field with a read-only "Check availability" (filled from `play.packageName`; the package name no longer comes from the first AAB upload); App or game / Free or paid are `<material-radio>`s whose model only updates on a click of the inner `[role=radio]` (the engine clicks it; `console verify play-app-record` checks aria-checked and the "Select …" hints). After Create app the owner adds the new app to the service account (app-level permissions do not cover later apps). |
|
||||||
| 11 | 2026-10 | Google Play has no public search API, so there is no Play keyword rank or Play competitor list to read without scraping. | `aso` reads App Store search only (iTunes Search API); `aso audit` covers Play through the listing text (short description, density); the aso-basics skill says so. |
|
| 11 | 2026-10 | Google Play has no public search API, so there is no Play keyword rank or Play competitor list to read without scraping. | `aso` reads App Store search only (iTunes Search API); `aso audit` covers Play through the listing text (short description, density); the aso-basics skill says so. |
|
||||||
| 10 | 2026-10 | The iTunes Search API order is close to, not equal to, App Store search (no personalisation, no search ads, no in-app event rows) and it has no subtitle field. | `aso rank` / `aso competitors` / `aso audit` label ranks as a trend and show no competitor subtitles; popularity only from Apple Ads, never estimated (`cli/lib/aso-intel.ts`). |
|
| 10 | 2026-10 | The iTunes Search API order is close to, not equal to, App Store search (no personalisation, no search ads, no in-app event rows) and it has no subtitle field. | `aso rank` / `aso competitors` / `aso audit` label ranks as a trend and show no competitor subtitles; popularity only from Apple Ads, never estimated (`cli/lib/aso-intel.ts`). |
|
||||||
| 9 | 2026-10 | A retried or repeated write can duplicate on a live store: a POST create retried after a timeout or 5xx, a re-run during the list lag right after a create, two runs at once, a Play edit redone after an unclear commit, a crash in the middle of a screenshot set. | Every write is an upsert (`cli/lib/upsert.ts`: findOrCreate with a fresh re-check and a journal of recent creates, createOnce treating 409 as found, no blind retry of a create); API clients retry only requests that are safe twice; Play edits are all or nothing; asset sets keep the matching prefix; one lock per app and store (`cli/lib/lock.ts`, `--force-unlock`). Table: `docs/idempotency.md`; `tests/idempotency.test.ts` runs every write twice and asserts zero writes on the second run. |
|
| 9 | 2026-10 | A retried or repeated write can duplicate on a live store: a POST create retried after a timeout or 5xx, a re-run during the list lag right after a create, two runs at once, a Play edit redone after an unclear commit, a crash in the middle of a screenshot set. | Every write is an upsert (`cli/lib/upsert.ts`: findOrCreate with a fresh re-check and a journal of recent creates, createOnce treating 409 as found, no blind retry of a create); API clients retry only requests that are safe twice; Play edits are all or nothing; asset sets keep the matching prefix; one lock per app and store (`cli/lib/lock.ts`, `--force-unlock`). Table: `docs/idempotency.md`; `tests/idempotency.test.ts` runs every write twice and asserts zero writes on the second run. |
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,82 @@
|
||||||
---
|
---
|
||||||
name: console-autofill
|
name: console-autofill
|
||||||
description: AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser with storebound's own profile that the owner signs in to once (never a copy of the owner's Google cookies unless they opt in); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button. Use when the user wants help filling one of these console forms, asks what to answer in them, or wants the answers as a checklist.
|
description: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default - `storebound console plan <form> --json` (MCP console_plan) gives the start URL, steps, answer sheet with the console's exact labels, never-press list and verified UI facts; you drive the page with your own browser tool (Claude in Chrome, a built-in browser, Playwright), stop before the final button and tell the owner what to press, then `storebound console verify <form>` (MCP console_verify). `storebound console fill` is the optional scripted fast path. Answers come only from storebound.config.ts. Never sign in, submit, publish, accept agreements or grant permissions. Use when the user wants help filling one of these console forms, asks what to answer in them, or wants the answers as a checklist.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Console autofill (forms without an API)
|
# Console forms without an API
|
||||||
|
|
||||||
CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolchain>/cli/index.ts`).
|
CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolchain>/cli/index.ts`).
|
||||||
|
|
||||||
**Read this to the owner first.** Console pages change without notice. This is AI-assisted autofill and it may put an
|
**Read this to the owner first.** Console pages change without notice. You (the agent) fill the form from storebound's
|
||||||
answer in the wrong place. The owner reviews every field and presses the final button. storebound never types a
|
answer sheet and the owner reviews every field and presses the final button. Nobody types a password or touches a
|
||||||
password, never touches a sign-in page, never prints or sends a cookie, and never clicks Submit, Publish, Create, a
|
sign-in page except the owner; nothing that submits, publishes, creates, accepts an agreement or grants a permission
|
||||||
final Save or anything that sends a form for review (English or Turkish console). It clicks only what moves through
|
is pressed by you or by storebound.
|
||||||
the form (see "What storebound clicks" below). It works in its own
|
|
||||||
browser profile where the owner signs in once by hand. It never copies the owner's Google cookies unless the owner
|
## Default: agent-first (you drive the browser)
|
||||||
opts in (`--use-chrome-session google`), because Google binds them to the device and may sign the owner out of their
|
|
||||||
real Chrome (step 3).
|
storebound supplies the knowledge and the check; you drive the page with your own browser tool and adapt to whatever
|
||||||
It is not available over MCP.
|
the UI shows. This is the primary mode.
|
||||||
|
|
||||||
|
1. `storebound console plan <form> --json` (MCP: `console_plan`; read-only, offline; `--lookup` asks the App Store
|
||||||
|
Connect API for the app id). It returns:
|
||||||
|
- `start`: the URL (a deep link when `apple.appId`, `play.developerId` or `play.appId` is known, or the ids
|
||||||
|
storebound remembered) and how to reach the form otherwise;
|
||||||
|
- `steps`: plain language plus what to `click` (by visible text) and which `answers` go on each step;
|
||||||
|
- `answers`: every answer with `status` (`certain`, `ask-owner`, `owner-check`, `agreement`), the console's own
|
||||||
|
`label` (curly quotes as shown, `curlyQuotes: true`), `action` (tick / untick / pick / type / choose) and `where`;
|
||||||
|
- `buttons.neverPress` (Publish, Submit, Create, Create app, Send, Release, "Definitions and Examples", "?", the
|
||||||
|
final Save of a form ...), `buttons.final` and `buttons.allowed` (navigation and draft saves);
|
||||||
|
- `uiFacts` (verified facts per form, see below), `driving` and `verify` (the command and what to capture).
|
||||||
|
2. Show the owner the `ask-owner` answers and ask (AGENTS.md rule 5). Never guess them; leave them empty if the owner
|
||||||
|
wants to answer in the page.
|
||||||
|
3. Open `start.url` in your browser tool. If a sign-in page shows, the owner signs in; you wait.
|
||||||
|
4. Follow `steps`. Read the page (text or accessibility tree) before each action, click by visible text, set each
|
||||||
|
answer as the sheet says, wait for the next step to render. If the page differs from the plan, adapt (the console
|
||||||
|
changes), but never press anything in `buttons.neverPress` and never tick an agreement.
|
||||||
|
5. Long flows (App Privacy with many data types): one evaluate call times out around 45 s, so start an in-page async
|
||||||
|
loop and poll it:
|
||||||
|
```js
|
||||||
|
window.__log = []; window.__run = (async () => { /* for each step: wait for it, act, */ window.__log.push('Device ID: purposes set'); /* ... */ window.__log.push('done'); })().catch(e => window.__log.push('stopped: ' + e.message));
|
||||||
|
```
|
||||||
|
then read `window.__log` every few seconds until it says `done` or `stopped`. Never click past a step you have not
|
||||||
|
read; on an unknown step, stop the loop and look.
|
||||||
|
6. Stop before `buttons.final` and tell the owner exactly what to press (App Privacy: review the summary, press
|
||||||
|
Publish; Play Create app: tick the two declarations, press Create app; content rating: Submit; target audience:
|
||||||
|
Save; New App: Create).
|
||||||
|
7. `storebound console verify <form> --from-text -` (MCP: `console_verify` with `text`) with what `verify.capture`
|
||||||
|
says: App Privacy the page's `innerText`, Play Create app the JSON of the capture snippet. Exit 1 / mismatches:
|
||||||
|
fix them in the page (or the config), verify again. `apple-app-record` and `apple-age-rating` verify from the API
|
||||||
|
without text.
|
||||||
|
|
||||||
|
### Verified UI facts
|
||||||
|
|
||||||
|
- **App Privacy** (`/apps/<id>/distribution/privacy`, 2026-10-12): "Set Up <type>" is a plain `<p>` with a click
|
||||||
|
handler (click it by exact text); it opens a `<dialog>` whose h2 is the type. Step 1: 6 purpose checkboxes in the
|
||||||
|
order Third-Party Advertising, Developer’s Advertising or Marketing (curly ’), Analytics, Product Personalization,
|
||||||
|
App Functionality, Other Purposes; an input has no aria label, its label is the nearest non-empty parent text
|
||||||
|
(label and help text run together). Step 2: radios "Yes, <type> data collected from this app is linked to the
|
||||||
|
user’s identity" / "No, …". Steps 3 and 4: two information pages ("Let’s define two important terms…", "To help put
|
||||||
|
tracking into context, here are a few examples:"), no inputs: Next on each. Step 5: radios "Yes, we use <type> data
|
||||||
|
for tracking purposes" / "No, …", plus "Definitions and Examples" (never press), Back, Cancel, Save. Save closes the
|
||||||
|
dialog; the type then shows Edit and a summary ("Used for …", linked, tracking), which `console verify` reads.
|
||||||
|
- **Play Create app** (2026-10-12, through Claude in Chrome): `/console/u/0/developers/<developerId>/create-new-app`.
|
||||||
|
App name (max 30), App package name (max 150) with a read-only "Check availability" ("Package name available";
|
||||||
|
press it), Default language (default "English (United States) – en-US"), App or game and Free or paid as
|
||||||
|
`<material-radio>`s, two declarations (the owner's). In a background window computer-tool clicks on the material
|
||||||
|
radios did nothing and `form_input` on the native inputs did not update the form ("Select app or game" stayed):
|
||||||
|
click the inner `[role=radio]` with a JS click, then check `aria-checked="true"` and that no "Select …" hint is
|
||||||
|
left. Text inputs set by `form_input` are fine. After Create app the app lives at
|
||||||
|
`/console/u/0/developers/<developerId>/app/<appId>/app-dashboard`: put the id in `play.appId`.
|
||||||
|
- **After Play Create app, an owner step:** a service account's app-level permissions do NOT cover apps created
|
||||||
|
later. The owner opens Users and permissions > the service account > Add app > the new app > Admin (or the minimal
|
||||||
|
set the plan lists) > Apply > Save changes, then `storebound apps check --store play`. Never do it yourself (a
|
||||||
|
permission grant; auto mode blocks it too).
|
||||||
|
|
||||||
|
## Optional: the scripted fast path (`storebound console fill`)
|
||||||
|
|
||||||
|
`console fill` drives a visible browser by itself with the same answers and click rules. It is fast when the page
|
||||||
|
matches its model; when it stalls on a changed page, switch to the agent-first mode above instead of waiting for a
|
||||||
|
fix. It is not available over MCP (the owner runs it in a terminal or the desktop app).
|
||||||
|
|
||||||
## Forms
|
## Forms
|
||||||
|
|
||||||
|
|
@ -28,7 +89,7 @@ It is not available over MCP.
|
||||||
| `play-content-rating` | Policy > App content > Content rating | app.contactEmail, app.profile, products, console.contentRating |
|
| `play-content-rating` | Policy > App content > Content rating | app.contactEmail, app.profile, products, console.contentRating |
|
||||||
| `play-target-audience` | Policy > App content > Target audience and content (+ Ads) | console.targetAudience, ads.appAdsTxt |
|
| `play-target-audience` | Policy > App content > Target audience and content (+ Ads) | console.targetAudience, ads.appAdsTxt |
|
||||||
|
|
||||||
## Steps
|
## Steps (scripted fast path)
|
||||||
|
|
||||||
1. `storebound console fill` lists the forms. `storebound console fill <form> --dry` prints every answer with its
|
1. `storebound console fill` lists the forms. `storebound console fill <form> --dry` prints every answer with its
|
||||||
source; `[check]` lines are not proven by the config. Show them to the owner.
|
source; `[check]` lines are not proven by the config. Show them to the owner.
|
||||||
|
|
@ -127,7 +188,7 @@ It is not available over MCP.
|
||||||
for the owner to pick).
|
for the owner to pick).
|
||||||
6. Multi-page forms: storebound fills a page and clicks its Next itself (App Privacy: Get Started or Edit next to
|
6. Multi-page forms: storebound fills a page and clicks its Next itself (App Privacy: Get Started or Edit next to
|
||||||
Data Types, Next, the data types' Save, then "Set Up <type>" for each collected type in the Data Types section:
|
Data Types, Next, the data types' Save, then "Set Up <type>" for each collected type in the Data Types section:
|
||||||
purposes > Next > linked > Next > tracking > Save; a dialog step it does not know: a debug dump and
|
purposes > Next > linked > Next > two information pages > Next > Next > tracking > Save; a dialog step it does not know: a debug dump and
|
||||||
`paused on "<type>"` until the owner answered it; content rating: Save draft, then Next). It stops on a page only when an answer there is uncertain: it logs
|
`paused on "<type>"` until the owner answered it; content rating: Save draft, then Next). It stops on a page only when an answer there is uncertain: it logs
|
||||||
`answer needed on "<page>": <the answers>`, leaves them outlined, and goes on by itself once the owner has set
|
`answer needed on "<page>": <the answers>`, leaves them outlined, and goes on by itself once the owner has set
|
||||||
each one (a box that should stay empty: click it twice). It never clicks Next past an open question.
|
each one (a box that should stay empty: click it twice). It never clicks Next past an open question.
|
||||||
|
|
@ -160,8 +221,11 @@ It is not available over MCP.
|
||||||
owner before they press Create app.
|
owner before they press Create app.
|
||||||
- **Declarations** (Developer Program Policies, US export laws) are agreements: storebound never ticks them, it
|
- **Declarations** (Developer Program Policies, US export laws) are agreements: storebound never ticks them, it
|
||||||
outlines them. The owner reads and ticks them and presses Create app.
|
outlines them. The owner reads and ticks them and presses Create app.
|
||||||
- The package name is not in this form: the first AAB upload sets it for good (`storebound play upload --track
|
- App package name (`play.packageName`, max 150) is in the form: storebound fills it and presses the read-only
|
||||||
internal`, or `storebound flow run release`).
|
"Check availability" ("Package name available"). It is fixed for good once the app is created.
|
||||||
|
- App or game and Free or paid are `<material-radio>`s: storebound clicks the inner `[role=radio]` (the native input
|
||||||
|
does not update the form's model). With `play.developerId` the form opens by its own URL (`.../create-new-app`).
|
||||||
|
- After Create app: the owner adds the new app to the service account (see "Verified UI facts" above).
|
||||||
|
|
||||||
## What storebound clicks, and what stays with the owner
|
## What storebound clicks, and what stays with the owner
|
||||||
|
|
||||||
|
|
@ -171,10 +235,10 @@ content", "Create and set up your app"):
|
||||||
|
|
||||||
| form | storebound clicks (navigation) | stays with the owner, and why |
|
| form | storebound clicks (navigation) | stays with the owner, and why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| App Privacy | Get Started; Edit next to Data Types (never the Privacy Policy Edit; skipped when the section already lists exactly the config's types); "Set Up <type>" for each collected type (a plain text, clicked by its exact text); a type's own Edit only when its purposes differ from the config; Next; **Save inside the questionnaire dialogs** (a draft: Apple publishes the answers only after Publish) | **Publish** and its confirmation: it puts the answers on the App Store product page; the "?" help buttons are never clicked |
|
| App Privacy | Get Started; Edit next to Data Types (never the Privacy Policy Edit; skipped when the section already lists exactly the config's types); "Set Up <type>" for each collected type (a plain text, clicked by its exact text); a type's own Edit only when its purposes differ from the config; Next; **Save inside the questionnaire dialogs** (a draft: Apple publishes the answers only after Publish) | **Publish** and its confirmation: it puts the answers on the App Store product page; the "?" help buttons and "Definitions and Examples" are never clicked |
|
||||||
| New App (App Store) | + and New App (Cancel only to leave a team without the bundle id) | **Create**: creates the app record for good |
|
| New App (App Store) | + and New App (Cancel only to leave a team without the bundle id) | **Create**: creates the app record for good |
|
||||||
| Age rating (guide only) | nothing (`storebound apple appinfo` sets it by API) | Set Up / Next / Done, if the owner wants to look |
|
| Age rating (guide only) | nothing (`storebound apple appinfo` sets it by API) | Set Up / Next / Done, if the owner wants to look |
|
||||||
| Create app (Play) | Create app on the app list (opens the form) | the two **declarations** (agreements, never ticked) and **Create app** in the form: creates the app |
|
| Create app (Play) | Create app on the app list (opens the form); Check availability next to the package name (read-only) | the two **declarations** (agreements, never ticked) and **Create app** in the form: creates the app |
|
||||||
| Content rating (Play) | Start; **Save draft** then Next on each page (a draft: IARC gets nothing before Submit; the owner agreed) | **Submit** on the Summary: sends the questionnaire to IARC |
|
| Content rating (Play) | Start; **Save draft** then Next on each page (a draft: IARC gets nothing before Submit; the owner agreed) | **Submit** on the Summary: sends the questionnaire to IARC |
|
||||||
| Target audience (Play) | Start / Manage, Next between the pages | **Save** on the last page: stores the declaration, which Google then reviews |
|
| Target audience (Play) | Start / Manage, Next between the pages | **Save** on the last page: stores the declaration, which Google then reviews |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,20 @@ deleting products. Sending a first release for review is the owner's action in t
|
||||||
|
|
||||||
## Steps (API = storebound does it, UI = Play Console by hand)
|
## Steps (API = storebound does it, UI = Play Console by hand)
|
||||||
|
|
||||||
1. **UI · Create the app.** Play Console > Home > Create app: name, default language, App or Game, Free or Paid, declarations.
|
1. **UI · Create the app.** `storebound console plan play-app-record` (then drive the form with your browser tool,
|
||||||
The package name is fixed by the first uploaded AAB.
|
or the scripted `storebound console fill play-app-record`). The form is
|
||||||
|
`/console/u/0/developers/<developerId>/create-new-app`: App name (max 30), App package name (`play.packageName`,
|
||||||
|
max 150; press the read-only "Check availability": "Package name available"; fixed for good), default language,
|
||||||
|
App or Game and Free or Paid (material radios: click the inner `[role=radio]`, then no "Select …" hint may be
|
||||||
|
left), and the declarations, which the owner ticks before pressing Create app. Free can never become Paid.
|
||||||
|
`storebound console verify play-app-record` checks the form first. Afterwards the app lives at
|
||||||
|
`/console/u/0/developers/<developerId>/app/<appId>/app-dashboard`: put that app id in `play.appId` (the App
|
||||||
|
content forms deep-link from it).
|
||||||
|
**Owner step after every Create app:** a service account's app-level permissions do NOT cover apps created later.
|
||||||
|
The owner opens Users and permissions > the service account > Add app > the new app > Admin (or at least: view app
|
||||||
|
information, manage store presence, release to testing tracks, manage testing tracks, manage production releases,
|
||||||
|
manage policy declarations, manage orders and subscriptions) > Apply > Save changes. Never do this yourself: it is
|
||||||
|
a permission grant (agents in auto mode are blocked from it too). Then `storebound apps check --store play`.
|
||||||
2. **UI · API access.** Google Cloud project (any) > enable "Google Play Android Developer API" > create a service
|
2. **UI · API access.** Google Cloud project (any) > enable "Google Play Android Developer API" > create a service
|
||||||
account > JSON key (keep it outside the repo). Play Console > Users and permissions > Invite new users > the
|
account > JSON key (keep it outside the repo). Play Console > Users and permissions > Invite new users > the
|
||||||
service account email > app permissions: view app information, manage store presence, release to testing tracks,
|
service account email > app permissions: view app information, manage store presence, release to testing tracks,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: store-pitfalls
|
name: store-pitfalls
|
||||||
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page (bounded wait and a debug dump), a signed-in console read as a sign-in page (Google account widget frames). Read it before any store task, and when a store command does something you did not expect.
|
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page (bounded wait and a debug dump), a signed-in console read as a sign-in page (Google account widget frames), console forms driven agent-first. Read it before any store task, and when a store command does something you did not expect.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Store pitfalls
|
# Store pitfalls
|
||||||
|
|
@ -103,11 +103,19 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
|
||||||
- **Symptom:** an agent cannot create the Play app through the API, or clicks "Create app" without knowing whether it
|
- **Symptom:** an agent cannot create the Play app through the API, or clicks "Create app" without knowing whether it
|
||||||
opens the form or submits it; or an app goes out as Free and the owner later wants it paid.
|
opens the form or submits it; or an app goes out as Free and the owner later wants it paid.
|
||||||
- **Cause:** the Play Developer API cannot create apps. Play Console's Home has a "Create app" button that opens the
|
- **Cause:** the Play Developer API cannot create apps. Play Console's Home has a "Create app" button that opens the
|
||||||
form, and the form's final button has the same words. Free cannot become Paid later on Google Play. The package
|
form, and the form's final button has the same words. Free cannot become Paid later on Google Play. The form
|
||||||
name is not in this form: the first AAB upload sets it.
|
(`/console/u/0/developers/<developerId>/create-new-app`, verified 2026-10-12) has an App package name field (max
|
||||||
- **Do:** `storebound console fill play-app-record` (pick the account with `play.developerId` or
|
150) with a read-only "Check availability" button ("Package name available"); the package name is fixed for good.
|
||||||
`play.developerAccount`); tell the owner that Free is final; the owner ticks the declarations and presses Create
|
App or game and Free or paid are `<material-radio>`s: setting the native input leaves the form saying "Select app
|
||||||
app; then `storebound play upload --track internal` (or `flow run release`) sets the package name.
|
or game" until the inner `[role=radio]` is clicked (a JS click works in a background window).
|
||||||
|
- **Do:** `storebound console plan play-app-record` and drive the form with your browser tool (or the scripted
|
||||||
|
`console fill play-app-record`); fill the package name and press Check availability; click the inner
|
||||||
|
`[role=radio]`s and check that no "Select …" hint is left (`console verify play-app-record`); tell the owner that
|
||||||
|
Free is final; the owner ticks the declarations and presses Create app. Afterwards the app lives at
|
||||||
|
`/console/u/0/developers/<developerId>/app/<appId>/app-dashboard` (set `play.appId`). The service account's
|
||||||
|
app-level permissions do NOT cover the new app: the OWNER adds it (Users and permissions > the service account >
|
||||||
|
Add app > Admin, or the minimal set > Apply > Save changes; never an agent: it is a permission grant), then
|
||||||
|
`storebound apps check --store play`.
|
||||||
- **Guard:** `createAppAllowed` lets the opener through only on the app list, never inside a form or dialog (English
|
- **Guard:** `createAppAllowed` lets the opener through only on the app list, never inside a form or dialog (English
|
||||||
and Turkish); agreement checkboxes are never ticked; Free or paid is outlined as an owner check.
|
and Turkish); agreement checkboxes are never ticked; Free or paid is outlined as an owner check.
|
||||||
|
|
||||||
|
|
@ -183,6 +191,8 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
|
||||||
opt-in that prints the warning; an agent never adds it. Apple publishes no such binding, but `--use-chrome-session
|
opt-in that prints the warning; an agent never adds it. Apple publishes no such binding, but `--use-chrome-session
|
||||||
apple` is opt-in too. If Google says "Couldn't sign you in" in storebound's window, storebound prints "Google refused
|
apple` is opt-in too. If Google says "Couldn't sign you in" in storebound's window, storebound prints "Google refused
|
||||||
sign-in in this window (/signin/rejected)": try again, or sign in at accounts.google.com first in that window.
|
sign-in in this window (/signin/rejected)": try again, or sign in at accounts.google.com first in that window.
|
||||||
|
- **Confirmed (2026-10-12):** the earlier default copy of the Google cookies DID sign the owner out of Play Console
|
||||||
|
in their real Chrome.
|
||||||
- **Guard:** `browserPlan` / `openConsoleBrowser` in `cli/console/session.ts` (no copy without the flag, the copy keeps
|
- **Guard:** `browserPlan` / `openConsoleBrowser` in `cli/console/session.ts` (no copy without the flag, the copy keeps
|
||||||
only the form's own provider), Chrome started by storebound without `--enable-automation` and with
|
only the form's own provider), Chrome started by storebound without `--enable-automation` and with
|
||||||
`navigator.webdriver` false, `googleRejected`; `tests/console-session.test.ts`.
|
`navigator.webdriver` false, `googleRejected`; `tests/console-session.test.ts`.
|
||||||
|
|
@ -206,6 +216,20 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
|
||||||
`tests/play-post-reads.test.ts` (phone-only fixture: dry plan, info line, exit 0; `tablets: true` without renders is
|
`tests/play-post-reads.test.ts` (phone-only fixture: dry plan, info line, exit 0; `tablets: true` without renders is
|
||||||
an error; no tablet DELETE without `--prune`).
|
an error; no tablet DELETE without `--prune`).
|
||||||
|
|
||||||
|
## 20. Drive consoles agent-first; the scripted fill is a fast path
|
||||||
|
- **Symptom:** hours went into fix cycles of the scripted engine's selectors (`console fill`) while the console UI
|
||||||
|
kept differing from the model (App Privacy's "Set Up" as plain text, unlabeled purpose checkboxes, two information
|
||||||
|
steps, Play's material radios), each one a stop until a new release.
|
||||||
|
- **Cause:** a script only knows the page it was written for. An AI agent with a browser tool reads the page as it is
|
||||||
|
and adapts.
|
||||||
|
- **Do:** run `storebound console plan <form> --json` and drive the page yourself (Claude in Chrome, a built-in
|
||||||
|
browser, Playwright) from its answer sheet, never-press list and UI facts; long flows as an in-page async loop that
|
||||||
|
writes `window.__log` (one evaluate call times out around 45 s); stop before the final button and tell the owner
|
||||||
|
what to press; then `storebound console verify <form>`. `console fill` is the optional scripted fast path; when it
|
||||||
|
stalls, switch to the plan instead of waiting for a selector fix.
|
||||||
|
- **Guard:** `console plan` / `console verify` (CLI and the read-only MCP tools `console_plan`, `console_verify`), the
|
||||||
|
console-autofill skill puts them first; `tests/console-plan.test.ts`.
|
||||||
|
|
||||||
## Adding a lesson
|
## Adding a lesson
|
||||||
Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test
|
Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test
|
||||||
that fails without the fix (see CONTRIBUTING.md).
|
that fails without the fix (see CONTRIBUTING.md).
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,8 @@ beforeAll(() => {
|
||||||
const dev = path.match(/^\/playm\/u\/0\/developers(?:\/(\d+)\/app-list)?$/);
|
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
|
// 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)?$/);
|
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
|
// 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]!]!)
|
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')
|
: 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('type')?.certain).toBe(false);
|
||||||
expect(bare.get('price')).toMatchObject({ value: null, certain: 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 r = consoleResult('play-app-record', config);
|
||||||
const g = r.guidance.join('\n');
|
const g = r.guidance.join('\n');
|
||||||
expect(g).toContain('first AAB upload');
|
expect(g).not.toContain('first AAB upload');
|
||||||
expect(g).toContain('play upload --track internal');
|
expect(g).toContain('App package name: com.example.game');
|
||||||
expect(g).toContain('flow run release');
|
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('Free or paid: Free (check: Owner check');
|
||||||
expect(g).toContain('Declarations: Developer Program Policies: you tick it');
|
expect(g).toContain('Declarations: Developer Program Policies: you tick it');
|
||||||
expect(r.guidance.at(-1)).toContain('press Create app yourself');
|
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');
|
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);
|
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);
|
const p = navPlan('play-app-record', config);
|
||||||
expect(p.map(s => s.kind)).toEqual(['goto', 'click', 'wait']);
|
expect(p.map(s => s.kind)).toEqual(['goto', 'click', 'wait']);
|
||||||
expect(p[1]).toMatchObject({ createApp: true, names: CREATE_APP_OPENERS });
|
expect(p[1]).toMatchObject({ createApp: true, names: CREATE_APP_OPENERS });
|
||||||
expect(navPlan('play-app-record', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig)[0])
|
const direct = navPlan('play-app-record', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig);
|
||||||
.toMatchObject({ url: 'https://play.google.com/console/u/0/developers/4444/app-list' });
|
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)', () => {
|
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 };
|
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]);
|
expect(await navClicks(page)).toEqual([opener]);
|
||||||
const s = await fillPage(page, def(), ans());
|
const s = await fillPage(page, def(), ans());
|
||||||
expect(s.fallback).toBe(false);
|
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(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('textbox', { name: nameField, exact: true }).inputValue()).toBe('Example Game');
|
||||||
expect(await page.getByRole('combobox').evaluate((e: HTMLSelectElement) => e.selectedOptions[0]!.textContent)).toMatch(/– en-US$/);
|
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);
|
const s = await fillPage(page, def(), ans(), new Set(), true);
|
||||||
expect(s.fallback).toBe(false);
|
expect(s.fallback).toBe(false);
|
||||||
expect(s.missing).toEqual(['language']);
|
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(s.highlighted).toContain('language');
|
||||||
expect(await agreed(page)).toBe(0);
|
expect(await agreed(page)).toBe(0);
|
||||||
expect(await clicks(page)).toEqual([]);
|
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 = []));
|
({ r, page } = await go({ ...config, play: { ...config.play!, developerAccount: 'example games ltd' } } as ShipConfig, file, said = []));
|
||||||
expect(r.nav.ok).toBe(true);
|
expect(r.nav.ok).toBe(true);
|
||||||
expect(said).toContain('creating the app in developer account "Example Games Ltd" (4444, play.developerAccount)');
|
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(new URL(page.url()).pathname).toMatch(/\/developers\/4444\/create-new-app$/);
|
||||||
expect(await navClicks(page)).toEqual(['Create 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' });
|
expect(readAccounts(file).play['com.example.game']).toMatchObject({ developerId: '4444', account: 'Example Games Ltd' });
|
||||||
await page.close();
|
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' }));
|
({ r, page } = await go(config, tmpAccounts(), said = [], { STOREBOUND_PLAY_DEVELOPER_ID: '2222' }));
|
||||||
expect(r.nav.ok).toBe(true);
|
expect(r.nav.ok).toBe(true);
|
||||||
expect(said).toContain('creating the app in developer account 2222 (play.developerId)');
|
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();
|
await page.close();
|
||||||
}, SLOW_TEST_MS);
|
}, SLOW_TEST_MS);
|
||||||
|
|
||||||
|
|
@ -911,7 +916,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
||||||
let { r, page } = await go('Example AI', said);
|
let { r, page } = await go('Example AI', said);
|
||||||
expect(r.nav.ok).toBe(true);
|
expect(r.nav.ok).toBe(true);
|
||||||
expect(said).toContain('creating the app in developer account "Example AI" (7000000005, play.developerAccount)');
|
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();
|
await page.close();
|
||||||
// spaces and case aside
|
// spaces and case aside
|
||||||
({ r, page } = await go(' example ai ', said = []));
|
({ r, page } = await go(' example ai ', said = []));
|
||||||
|
|
@ -951,17 +956,22 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
||||||
}, SLOW_TEST_MS);
|
}, SLOW_TEST_MS);
|
||||||
|
|
||||||
test('fillSession: notes logged, form filled, the run ends without any submit', async () => {
|
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 log = logLines(), said = log.lines;
|
||||||
const c = { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig;
|
const c = { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig;
|
||||||
const done = scope.run(fillSession(b, { form: 'play-app-record', config: c, ...fast(log) }));
|
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)));
|
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('first AAB upload'))).toBe(true);
|
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);
|
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')));
|
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();
|
await b.context.close();
|
||||||
expect((await done).filled).toBe(4);
|
expect((await done).filled).toBe(5);
|
||||||
}, SLOW_TEST_MS);
|
}, 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 === '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.hostname !== 'play.google.com') return null;
|
||||||
if (u.pathname.endsWith('/app-list')) return 'play-home-ogb.html';
|
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;
|
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; } });
|
const frameOn = (page: any, host: string): boolean => page.frames().some((f: any) => { try { return new URL(f.url()).hostname === host; } catch { return false; } });
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,9 @@ const shared = await launchTestBrowser('playwright');
|
||||||
const { browser, skipWhy } = shared;
|
const { browser, skipWhy } = shared;
|
||||||
if (!browser) console.warn(`console-flow browser tests skipped: no Chromium installed (${skipWhy}). Run \`bunx playwright install chromium\`.`);
|
if (!browser) console.warn(`console-flow browser tests skipped: no Chromium installed (${skipWhy}). Run \`bunx playwright install chromium\`.`);
|
||||||
const scope = testScope(() => browser);
|
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 server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
let base = '';
|
let base = '';
|
||||||
|
|
@ -141,7 +143,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
|
||||||
const b = await local();
|
const b = await local();
|
||||||
const log = logLines();
|
const log = logLines();
|
||||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
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);
|
const c = await clicks(b);
|
||||||
expect(c).not.toContain('Publish');
|
expect(c).not.toContain('Publish');
|
||||||
expect(c).not.toContain('See Details');
|
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 log = logLines();
|
||||||
const config = privacy({ COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS'], linked: false, tracking: false } });
|
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) }));
|
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((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1);
|
||||||
expect(log.lines.some(m => m.startsWith('Data collection was answered before'))).toBe(true);
|
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 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 linked.isVisible()).toBe(true);
|
||||||
expect(await b.page.getByRole('radio', { name: /^No, we do not use Device ID/ }).isVisible()).toBe(false);
|
expect(await b.page.getByRole('radio', { name: /^No, we do not use Device ID/ }).isVisible()).toBe(false);
|
||||||
await linked.check(); // the owner answers
|
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(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 b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
||||||
expect(await clicks(b)).not.toContain('Publish');
|
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 types = Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }]));
|
||||||
const config = { ...base0, dataSafety: undefined, appPrivacy: { types } } as unknown as ShipConfig;
|
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) }));
|
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 b.page.evaluate(() => (window as any).__saves)).toBe(1);
|
||||||
expect(await clicks(b)).not.toContain('Next');
|
expect(await clicks(b)).not.toContain('Next');
|
||||||
expect(await clicks(b)).not.toContain('Publish');
|
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);
|
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)', () => {
|
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-'));
|
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
||||||
try {
|
try {
|
||||||
const b = await local();
|
const b = await local();
|
||||||
const log = logLines();
|
const log = logLines();
|
||||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: base + SETUP, ...fast(log), debugDir: dir, debugEveryStep: true }));
|
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).__setups)).toEqual(SIX_NAMES.map(n => `Set Up ${n}`));
|
||||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
|
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
|
||||||
const got = await answersOf(b);
|
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(got['Product Interaction']).toEqual({ purposes: ['Analytics', 'Product Personalization'], linked: 'no', tracking: 'no' });
|
||||||
expect(Object.keys(got).sort()).toEqual([...SIX_NAMES].sort());
|
expect(Object.keys(got).sort()).toEqual([...SIX_NAMES].sort());
|
||||||
const c = await clicks(b);
|
const c = await clicks(b);
|
||||||
for (const never of ['?', 'Publish', 'Edit', 'Cancel', 'Back']) expect(c).not.toContain(never);
|
for (const never of ['?', 'Publish', 'Edit', 'Cancel', 'Back', 'Definitions and Examples']) expect(c).not.toContain(never);
|
||||||
expect(c.filter(x => x === 'Next')).toHaveLength(18);
|
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(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('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).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 yes.isVisible()).toBe(true);
|
||||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
||||||
await yes.check(); // the owner answers
|
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(log.lines).toContain('"Purchase History: linked to the user" answered: storebound goes on');
|
||||||
expect((await answersOf(b))['Purchase History'].linked).toBe('yes');
|
expect((await answersOf(b))['Purchase History'].linked).toBe('yes');
|
||||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
|
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 });
|
expect(pj.controls).toContainEqual({ role: 'switch', name: 'Account identity', checked: false });
|
||||||
await Bun.sleep(500);
|
await Bun.sleep(500);
|
||||||
const c = await clicks(b);
|
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('Publish');
|
||||||
expect(c).not.toContain('?');
|
expect(c).not.toContain('?');
|
||||||
await b.context.close();
|
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 log = logLines();
|
||||||
const url = `${base}${SETUP}?preset=${encodeURIComponent('Crash Data:App Functionality')}&preset=${encodeURIComponent('Device ID:Analytics')}`;
|
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) }));
|
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('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');
|
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);
|
const got = await answersOf(b);
|
||||||
|
|
@ -365,7 +368,8 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
|
||||||
const log = logLines();
|
const log = logLines();
|
||||||
const run = scope.run(fillSession(b, { form, config: base0, startUrl: base + file, ...fast(log) }));
|
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`)));
|
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([]);
|
expect(navAsked(log.lines)).toEqual([]);
|
||||||
await b.context.close();
|
await b.context.close();
|
||||||
await run;
|
await run;
|
||||||
|
|
|
||||||
197
tests/console-plan.test.ts
Normal file
197
tests/console-plan.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,12 +6,16 @@
|
||||||
row per data type as plain <p> texts: the type label, a "?" help button (aria-label "More information") and
|
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
|
"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).
|
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"
|
The per-type DIALOG follows the flow verified on a real console (October 2026, structure only): a <dialog> whose h2
|
||||||
(https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy: for each data type,
|
is the type name; step 1 purposes: 6 checkboxes in Apple's order with NO accessible name (the label and its help
|
||||||
the purposes, whether it is linked to the user's identity, whether it is used for tracking; Next between, Save at the
|
text sit in a sibling <label> without for=, run together), Cancel / Next; step 2 linked: radios "Yes, <type lower>
|
||||||
end) in the markup of the real Data Collection dialog: <dialog role="dialog"> titled by <h2><span>, checkboxes whose
|
data collected from this app is linked to the user’s identity" / "No, ...", Back / Cancel / Next; step 3 an info
|
||||||
<label> holds the label and its help text as two <p>, radios "<strong>Yes</strong>, ..." / "<strong>No</strong>, ...",
|
page "Let’s define two important terms..." and step 4 an info page "To help put tracking into context, here are a
|
||||||
Back / Cancel / Next / Save. Saving shows the type's purposes in its row and an Edit instead of "Set Up".
|
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>|...
|
?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.
|
(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. -->
|
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) {
|
function setUpDone(type, purposes) {
|
||||||
const a = document.querySelector(`[data-type="${type}"] .action`);
|
const a = document.querySelector(`[data-type="${type}"] .action`);
|
||||||
a.onclick = null;
|
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));
|
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) {
|
function openType(type) {
|
||||||
const low = type.toLowerCase();
|
const low = type.toLowerCase();
|
||||||
const d = document.createElement('dialog');
|
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 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
|
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>`
|
? `<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>`
|
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>`
|
+ `<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><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="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="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>`
|
+ `<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>`
|
||||||
+ 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 data-p="5" hidden>`
|
||||||
+ `<div><button type="button" data-go="3">Back</button><button type="button">Cancel</button><button type="button" data-save>Save</button></div></div></div>`;
|
+ 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.setAttribute('open', '');
|
||||||
d.addEventListener('click', e => {
|
d.addEventListener('click', e => {
|
||||||
const go = e.target.closest('[data-go]')?.getAttribute('data-go');
|
const go = e.target.closest('[data-go]')?.getAttribute('data-go');
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<h1>Create app</h1>
|
<h1>Create app</h1>
|
||||||
<form onsubmit="return false">
|
<form onsubmit="return false">
|
||||||
<p><label>App name <input type="text" maxlength="30"></label></p>
|
<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>
|
<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>
|
<fieldset><legend>App or game</legend>
|
||||||
<label><input type="radio" name="type"> App</label> <label><input type="radio" name="type"> Game</label>
|
<label><input type="radio" name="type"> App</label> <label><input type="radio" name="type"> Game</label>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<form onsubmit="return false">
|
<form onsubmit="return false">
|
||||||
<h2>Uygulama ayrıntıları</h2>
|
<h2>Uygulama ayrıntıları</h2>
|
||||||
<p><label>Uygulama adı <input type="text" maxlength="30"></label></p>
|
<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>
|
<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>
|
<fieldset><legend>Uygulama veya oyun</legend>
|
||||||
<label><input type="radio" name="type"> Uygulama</label> <label><input type="radio" name="type"> Oyun</label>
|
<label><input type="radio" name="type"> Uygulama</label> <label><input type="radio" name="type"> Oyun</label>
|
||||||
|
|
|
||||||
46
tests/fixtures/console/play-create-app.html
vendored
46
tests/fixtures/console/play-create-app.html
vendored
|
|
@ -1,21 +1,30 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<!-- MODELED, not recorded: Play Console > Create app (App details: App name, Default language; App or game; Free or
|
<!-- Play Console > Create app (/console/u/0/developers/<id>/create-new-app). Structure verified on a real console
|
||||||
paid; Declarations: Developer Program Policies, US export laws), written from Google's public help "Create and set
|
(2026-10-12, driven through Claude in Chrome; structure only, no data copied): App name (textbox, 30 max), App
|
||||||
up your app". The declarations are agreements storebound never ticks; the final "Create app" is the owner's click.
|
package name (textbox, 150 max) with a read-only "Check availability" button that answers "Package name
|
||||||
Placeholder data only. -->
|
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>
|
<html lang="en"><head><meta charset="utf-8"><title>Create app - Play Console</title><script src="/_common.js"></script></head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Create app</h1>
|
<h1>Create app</h1>
|
||||||
<form onsubmit="return false">
|
<form onsubmit="return false">
|
||||||
<h2>App details</h2>
|
<h2>App details</h2>
|
||||||
<p><label>App name <input type="text" maxlength="30"></label></p>
|
<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>
|
<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>
|
<div role="radiogroup" aria-label="App or game">
|
||||||
<label><input type="radio" name="type"> App</label> <label><input type="radio" name="type"> Game</label>
|
<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>
|
||||||
</fieldset>
|
<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>
|
||||||
<fieldset><legend>Free or paid</legend>
|
<p class="hint" data-group="type">Select app or game</p>
|
||||||
<label><input type="radio" name="price"> Free</label> <label><input type="radio" name="price"> Paid</label>
|
</div>
|
||||||
</fieldset>
|
<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>
|
<h2>Declarations</h2>
|
||||||
<fieldset><legend>Developer Program Policies</legend>
|
<fieldset><legend>Developer Program Policies</legend>
|
||||||
<label><input type="checkbox"> Confirm app meets the Developer Program Policies</label>
|
<label><input type="checkbox"> Confirm app meets the Developer Program Policies</label>
|
||||||
|
|
@ -25,4 +34,21 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button type="button">Cancel</button> <button type="submit">Create app</button>
|
<button type="button">Cancel</button> <button type="submit">Create app</button>
|
||||||
</form>
|
</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>
|
</body></html>
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ describe('growth extras config', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('growth extras over MCP', () => {
|
describe('growth extras over MCP', () => {
|
||||||
test('no console tool exists in MCP', () => {
|
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))).toEqual([]);
|
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', () => {
|
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')
|
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')
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
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(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(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);
|
expect('console.fill').not.toMatch(FORBIDDEN_METHOD_WORDS);
|
||||||
let now = 9_000_000;
|
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: [] };
|
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: [] };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue