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
|
|
@ -194,6 +194,8 @@ beforeAll(() => {
|
|||
const dev = path.match(/^\/playm\/u\/0\/developers(?:\/(\d+)\/app-list)?$/);
|
||||
// playn: the same chooser; every account's app list has a Create app button that opens the Create app form
|
||||
const devNew = path.match(/^\/play([nrs])\/u\/0\/developers(?:\/(\d+)\/app-list)?$/);
|
||||
// the Create app form of one developer account has its own URL (verified 2026-10-12)
|
||||
if (/^\/play[nrs]\/u\/0\/developers\/\d+\/create-new-app$/.test(path)) return new Response(Bun.file(join(FIX, 'play-create-app.html')));
|
||||
// playn: the modeled chooser; playr: the chooser as the owner's screenshot shows it; plays: one account only
|
||||
const name = path.endsWith('/_common.js') ? '_common.js' : devNew ? (devNew[2] ? 'play-home.html' : ({ n: 'play-chooser.html', r: 'play-chooser-real.html', s: 'play-chooser-one.html' } as Record<string, string>)[devNew[1]!]!)
|
||||
: dev ? (!dev[1] ? 'play-chooser.html' : dev[1] === '4444' ? 'play-apps.html' : 'play-apps-other.html')
|
||||
|
|
@ -744,12 +746,13 @@ describe('play-app-record (pure)', () => {
|
|||
expect(bare.get('type')?.certain).toBe(false);
|
||||
expect(bare.get('price')).toMatchObject({ value: null, certain: false });
|
||||
});
|
||||
test('guidance and notes: package name from the first AAB upload, free is final, declarations are yours', () => {
|
||||
test('guidance and notes: the package name field and its Check availability, free is final, declarations are yours', () => {
|
||||
const r = consoleResult('play-app-record', config);
|
||||
const g = r.guidance.join('\n');
|
||||
expect(g).toContain('first AAB upload');
|
||||
expect(g).toContain('play upload --track internal');
|
||||
expect(g).toContain('flow run release');
|
||||
expect(g).not.toContain('first AAB upload');
|
||||
expect(g).toContain('App package name: com.example.game');
|
||||
expect(g).toContain('"Check availability" (read-only)');
|
||||
expect(g).toContain('Package name available');
|
||||
expect(g).toContain('Free or paid: Free (check: Owner check');
|
||||
expect(g).toContain('Declarations: Developer Program Policies: you tick it');
|
||||
expect(r.guidance.at(-1)).toContain('press Create app yourself');
|
||||
|
|
@ -760,12 +763,14 @@ describe('play-app-record (pure)', () => {
|
|||
const e = validateExtras({ play: { packageName: 'com.example.game', developerAccount: ' ', name: 'x'.repeat(31), free: 'yes' as unknown as boolean } }).join('\n');
|
||||
for (const k of ['play.developerAccount', 'play.name', 'play.free']) expect(e).toContain(k);
|
||||
});
|
||||
test('navigation plan: the app list (of play.developerId) > Create app > the form', () => {
|
||||
test('navigation plan: the app list > Create app > the form; with play.developerId the form\'s own URL', () => {
|
||||
const p = navPlan('play-app-record', config);
|
||||
expect(p.map(s => s.kind)).toEqual(['goto', 'click', 'wait']);
|
||||
expect(p[1]).toMatchObject({ createApp: true, names: CREATE_APP_OPENERS });
|
||||
expect(navPlan('play-app-record', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig)[0])
|
||||
.toMatchObject({ url: 'https://play.google.com/console/u/0/developers/4444/app-list' });
|
||||
const direct = navPlan('play-app-record', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig);
|
||||
expect(direct.map(s => s.kind)).toEqual(['goto', 'wait']);
|
||||
expect(direct[0]).toMatchObject({ url: 'https://play.google.com/console/u/0/developers/4444/create-new-app' });
|
||||
expect(formMeta('play-app-record', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig).url).toBe('https://play.google.com/console/u/0/developers/4444/create-new-app');
|
||||
});
|
||||
test('the Create app allowlist: only the page-level opener on the app list, never inside the form (EN and TR)', () => {
|
||||
const list = { appList: true, formOpen: false, inForm: false };
|
||||
|
|
@ -803,7 +808,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
expect(await navClicks(page)).toEqual([opener]);
|
||||
const s = await fillPage(page, def(), ans());
|
||||
expect(s.fallback).toBe(false);
|
||||
expect(s.filled.sort()).toEqual(['language', 'name', 'price', 'type']);
|
||||
expect(s.filled.sort()).toEqual(['language', 'name', 'packageName', 'price', 'type']);
|
||||
expect(s.highlighted.sort()).toEqual(['agree:exportLaws', 'agree:policies']);
|
||||
expect(await page.getByRole('textbox', { name: nameField, exact: true }).inputValue()).toBe('Example Game');
|
||||
expect(await page.getByRole('combobox').evaluate((e: HTMLSelectElement) => e.selectedOptions[0]!.textContent)).toMatch(/– en-US$/);
|
||||
|
|
@ -839,7 +844,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
const s = await fillPage(page, def(), ans(), new Set(), true);
|
||||
expect(s.fallback).toBe(false);
|
||||
expect(s.missing).toEqual(['language']);
|
||||
expect(s.filled.sort()).toEqual(['name', 'price', 'type']);
|
||||
expect(s.filled.sort()).toEqual(['name', 'packageName', 'price', 'type']);
|
||||
expect(s.highlighted).toContain('language');
|
||||
expect(await agreed(page)).toBe(0);
|
||||
expect(await clicks(page)).toEqual([]);
|
||||
|
|
@ -875,8 +880,8 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
({ r, page } = await go({ ...config, play: { ...config.play!, developerAccount: 'example games ltd' } } as ShipConfig, file, said = []));
|
||||
expect(r.nav.ok).toBe(true);
|
||||
expect(said).toContain('creating the app in developer account "Example Games Ltd" (4444, play.developerAccount)');
|
||||
expect(new URL(page.url()).pathname).toBe('/play-create-app.html');
|
||||
expect(await navClicks(page)).toEqual(['Create app']);
|
||||
expect(new URL(page.url()).pathname).toMatch(/\/developers\/4444\/create-new-app$/);
|
||||
expect(await navClicks(page)).toEqual([]); // the form's own URL: no Create app click at all
|
||||
expect(readAccounts(file).play['com.example.game']).toMatchObject({ developerId: '4444', account: 'Example Games Ltd' });
|
||||
await page.close();
|
||||
|
||||
|
|
@ -895,7 +900,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
({ r, page } = await go(config, tmpAccounts(), said = [], { STOREBOUND_PLAY_DEVELOPER_ID: '2222' }));
|
||||
expect(r.nav.ok).toBe(true);
|
||||
expect(said).toContain('creating the app in developer account 2222 (play.developerId)');
|
||||
expect(said).toContain('opening the form, step 1/3: open the app list of the developer account');
|
||||
expect(said).toContain('opening the form, step 1/2: open the Create app form of the developer account');
|
||||
await page.close();
|
||||
}, SLOW_TEST_MS);
|
||||
|
||||
|
|
@ -911,7 +916,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
let { r, page } = await go('Example AI', said);
|
||||
expect(r.nav.ok).toBe(true);
|
||||
expect(said).toContain('creating the app in developer account "Example AI" (7000000005, play.developerAccount)');
|
||||
expect(new URL(page.url()).pathname).toBe('/play-create-app.html');
|
||||
expect(new URL(page.url()).pathname).toMatch(/\/developers\/7000000005\/create-new-app$/);
|
||||
await page.close();
|
||||
// spaces and case aside
|
||||
({ r, page } = await go(' example ai ', said = []));
|
||||
|
|
@ -951,17 +956,22 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
}, SLOW_TEST_MS);
|
||||
|
||||
test('fillSession: notes logged, form filled, the run ends without any submit', async () => {
|
||||
const b = await consoleBrowser(u => (u.hostname !== 'play.google.com' ? null : u.pathname === '/console' || u.pathname.endsWith('/app-list') ? 'play-home.html' : u.pathname === '/play-create-app.html' ? 'play-create-app.html' : null));
|
||||
const b = await consoleBrowser(u => (u.hostname !== 'play.google.com' ? null : u.pathname === '/console' || u.pathname.endsWith('/app-list') ? 'play-home.html' : u.pathname === '/play-create-app.html' || u.pathname.endsWith('/create-new-app') ? 'play-create-app.html' : null));
|
||||
const log = logLines(), said = log.lines;
|
||||
const c = { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig;
|
||||
const done = scope.run(fillSession(b, { form: 'play-app-record', config: c, ...fast(log) }));
|
||||
await log.wait('the Create app form filled', l => l.some(m => /^Create app: filled 4, check 2/.test(m)));
|
||||
expect(said.some(m => m.includes('first AAB upload'))).toBe(true);
|
||||
await log.wait('the Create app form filled', l => l.some(m => /^Create app: filled 5, check 2/.test(m)));
|
||||
expect(said.some(m => m.includes('Check availability'))).toBe(true);
|
||||
await log.wait('the availability check', l => l.includes('App package name: "Package name available"'));
|
||||
expect(said).toContain('clicked "Check availability" (navigation)');
|
||||
// the material radios: the inner [role=radio] was clicked, so the form's model has them and no "Select …" hint shows
|
||||
expect(await b.page.evaluate(() => (window as any).__model)).toEqual({ type: 'Game', price: 'Free' });
|
||||
expect(await b.page.getByText(/^Select (app or game|free or paid)$/).filter({ visible: true }).count()).toBe(0);
|
||||
expect(said.some(m => m.includes('never become paid'))).toBe(true);
|
||||
await log.wait('the final line', l => l.some(m => m.includes('press Create app yourself')));
|
||||
expect(await clicks(b.page)).toEqual([]);
|
||||
expect(await clicks(b.page)).toEqual(['Check availability']); // read-only; never the final Create app
|
||||
await b.context.close();
|
||||
expect((await done).filled).toBe(4);
|
||||
expect((await done).filled).toBe(5);
|
||||
}, SLOW_TEST_MS);
|
||||
});
|
||||
|
||||
|
|
@ -986,7 +996,7 @@ const playOgb = (u: URL): string | null => {
|
|||
if (u.hostname === 'accounts.google.com' || u.hostname === 'ogs.google.com') return u.pathname.startsWith('/v3/signin') ? 'google-signin.html' : 'google-account-widget.html';
|
||||
if (u.hostname !== 'play.google.com') return null;
|
||||
if (u.pathname.endsWith('/app-list')) return 'play-home-ogb.html';
|
||||
if (u.pathname === '/play-create-app.html') return 'play-create-app.html';
|
||||
if (u.pathname === '/play-create-app.html' || u.pathname.endsWith('/create-new-app')) return 'play-create-app.html';
|
||||
return u.pathname === '/console' || u.pathname === '/console/u/0/developers' ? 'play-chooser-ogb.html' : null;
|
||||
};
|
||||
const frameOn = (page: any, host: string): boolean => page.frames().some((f: any) => { try { return new URL(f.url()).hostname === host; } catch { return false; } });
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ const shared = await launchTestBrowser('playwright');
|
|||
const { browser, skipWhy } = shared;
|
||||
if (!browser) console.warn(`console-flow browser tests skipped: no Chromium installed (${skipWhy}). Run \`bunx playwright install chromium\`.`);
|
||||
const scope = testScope(() => browser);
|
||||
const TEST_MS = 60_000;
|
||||
// a whole App Privacy walk (6 types x 5 dialog steps) on a loaded CI machine: generous, still bounded
|
||||
const TEST_MS = 90_000;
|
||||
const WALK_MS = 75_000;
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let base = '';
|
||||
|
|
@ -141,7 +143,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
|
|||
const b = await local();
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
const c = await clicks(b);
|
||||
expect(c).not.toContain('Publish');
|
||||
expect(c).not.toContain('See Details');
|
||||
|
|
@ -162,7 +164,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
|
|||
const log = logLines();
|
||||
const config = privacy({ COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS'], linked: false, tracking: false } });
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html?existing=1', ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1);
|
||||
expect(log.lines.some(m => m.startsWith('Data collection was answered before'))).toBe(true);
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(4); // the list + three types
|
||||
|
|
@ -183,7 +185,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
|
|||
expect(await linked.isVisible()).toBe(true);
|
||||
expect(await b.page.getByRole('radio', { name: /^No, we do not use Device ID/ }).isVisible()).toBe(false);
|
||||
await linked.check(); // the owner answers
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(log.lines).toContain('"Device ID: linked to the user" answered: storebound goes on');
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
||||
expect(await clicks(b)).not.toContain('Publish');
|
||||
|
|
@ -198,7 +200,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
|
|||
const types = Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }]));
|
||||
const config = { ...base0, dataSafety: undefined, appPrivacy: { types } } as unknown as ShipConfig;
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(1);
|
||||
expect(await clicks(b)).not.toContain('Next');
|
||||
expect(await clicks(b)).not.toContain('Publish');
|
||||
|
|
@ -222,13 +224,13 @@ const SETUP = 'apple-app-privacy-setup.html';
|
|||
const answersOf = (b: ConsoleBrowser) => b.page.evaluate(() => (window as any).__answers);
|
||||
|
||||
describe.skipIf(!browser)('App Privacy: each data type set up from the real page ("Set Up <type>" as plain text; fixture apple-app-privacy-setup.html)', () => {
|
||||
test('every collected type is set up through its dialog (purposes > Next > linked > Next > tracking > Save); "?", Publish and the Edits never clicked; every step dumped', async () => {
|
||||
test('every collected type is set up through its dialog (purposes > Next > linked > Next > two info steps > Next > Next > tracking > Save; purpose checkboxes without an accessible name); "?", Publish and the Edits never clicked; every step dumped', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
||||
try {
|
||||
const b = await local();
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: base + SETUP, ...fast(log), debugDir: dir, debugEveryStep: true }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(await b.page.evaluate(() => (window as any).__setups)).toEqual(SIX_NAMES.map(n => `Set Up ${n}`));
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
|
||||
const got = await answersOf(b);
|
||||
|
|
@ -237,8 +239,9 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
|
|||
expect(got['Product Interaction']).toEqual({ purposes: ['Analytics', 'Product Personalization'], linked: 'no', tracking: 'no' });
|
||||
expect(Object.keys(got).sort()).toEqual([...SIX_NAMES].sort());
|
||||
const c = await clicks(b);
|
||||
for (const never of ['?', 'Publish', 'Edit', 'Cancel', 'Back']) expect(c).not.toContain(never);
|
||||
expect(c.filter(x => x === 'Next')).toHaveLength(18);
|
||||
for (const never of ['?', 'Publish', 'Edit', 'Cancel', 'Back', 'Definitions and Examples']) expect(c).not.toContain(never);
|
||||
expect(c.filter(x => x === 'Next')).toHaveLength(24); // purposes, linked and the TWO info steps of each of 6 types
|
||||
expect(log.lines.filter(m => m === 'an information-only step of the dialog: storebound pressed Next')).toHaveLength(12);
|
||||
expect(c.filter(x => x === 'Save')).toHaveLength(6);
|
||||
expect(log.lines).toContain('the Data Types section lists exactly the collected types of the config: storebound sets up each one');
|
||||
expect(log.lines).toContain('clicked "Set Up Purchase History" (navigation)');
|
||||
|
|
@ -266,7 +269,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
|
|||
expect(await yes.isVisible()).toBe(true);
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
||||
await yes.check(); // the owner answers
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(log.lines).toContain('"Purchase History: linked to the user" answered: storebound goes on');
|
||||
expect((await answersOf(b))['Purchase History'].linked).toBe('yes');
|
||||
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6);
|
||||
|
|
@ -297,7 +300,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
|
|||
expect(pj.controls).toContainEqual({ role: 'switch', name: 'Account identity', checked: false });
|
||||
await Bun.sleep(500);
|
||||
const c = await clicks(b);
|
||||
expect(c.filter(x => x === 'Next')).toHaveLength(2 * 3 + 1); // two types done (purposes, linked, definitions), Device ID's first step only
|
||||
expect(c.filter(x => x === 'Next')).toHaveLength(2 * 4 + 1); // two types done (purposes, linked, the two info steps), Device ID's first step only
|
||||
expect(c).not.toContain('Publish');
|
||||
expect(c).not.toContain('?');
|
||||
await b.context.close();
|
||||
|
|
@ -310,7 +313,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
|
|||
const log = logLines();
|
||||
const url = `${base}${SETUP}?preset=${encodeURIComponent('Crash Data:App Functionality')}&preset=${encodeURIComponent('Device ID:Analytics')}`;
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: url, ...fast(log) }));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
||||
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)), WALK_MS);
|
||||
expect(log.lines).toContain('Crash Data was set up before with the purposes of the config: left as it is');
|
||||
expect(log.lines).toContain('Device ID was set up before with other purposes than the config: storebound opens it with its Edit');
|
||||
const got = await answersOf(b);
|
||||
|
|
@ -365,7 +368,8 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
|
|||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form, config: base0, startUrl: base + file, ...fast(log) }));
|
||||
await log.wait('the final line', l => l.some(m => m.includes(`press ${final} yourself`)));
|
||||
expect(await clicks(b)).toEqual([]);
|
||||
// Play: only the read-only package name check (verified 2026-10-12); never the final button
|
||||
expect(await clicks(b)).toEqual(form === 'play-app-record' ? ['Check availability'] : []);
|
||||
expect(navAsked(log.lines)).toEqual([]);
|
||||
await b.context.close();
|
||||
await run;
|
||||
|
|
|
|||
197
tests/console-plan.test.ts
Normal file
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
|
||||
"Set Up <type>", a <p> inside a div with the click handler (no button role, no link). Purchase History's row has
|
||||
only its "Set Up" (the console shows the category "Purchases" above it).
|
||||
The per-type DIALOG is MODELED from Apple's help page "Manage app privacy"
|
||||
(https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy: for each data type,
|
||||
the purposes, whether it is linked to the user's identity, whether it is used for tracking; Next between, Save at the
|
||||
end) in the markup of the real Data Collection dialog: <dialog role="dialog"> titled by <h2><span>, checkboxes whose
|
||||
<label> holds the label and its help text as two <p>, radios "<strong>Yes</strong>, ..." / "<strong>No</strong>, ...",
|
||||
Back / Cancel / Next / Save. Saving shows the type's purposes in its row and an Edit instead of "Set Up".
|
||||
The per-type DIALOG follows the flow verified on a real console (October 2026, structure only): a <dialog> whose h2
|
||||
is the type name; step 1 purposes: 6 checkboxes in Apple's order with NO accessible name (the label and its help
|
||||
text sit in a sibling <label> without for=, run together), Cancel / Next; step 2 linked: radios "Yes, <type lower>
|
||||
data collected from this app is linked to the user’s identity" / "No, ...", Back / Cancel / Next; step 3 an info
|
||||
page "Let’s define two important terms..." and step 4 an info page "To help put tracking into context, here are a
|
||||
few examples:" (no inputs, Back / Cancel / Next); step 5 tracking: radios "Yes, we use <type lower> data for
|
||||
tracking purposes" / "No, we do not use ...", "Definitions and Examples" (help, never clicked) / Back / Cancel /
|
||||
Save. Saving closes the dialog; the row then shows an Edit and the summary ("Used for <purposes>", "Linked to the
|
||||
user’s identity" or "Not linked to the user’s identity", "Used for tracking purposes" or "Not used for tracking
|
||||
purposes"), which `console verify` reads.
|
||||
?unknown=<type>: that type's second step is one storebound does not know (switches). ?preset=<type>:<purpose>|...
|
||||
(repeatable): that type was set up before with these purposes.
|
||||
window.__setups: the "Set Up" texts clicked; window.__answers: what each Save stored; window.__saves. -->
|
||||
|
|
@ -65,10 +69,13 @@ function setUpOpen(type) {
|
|||
function setUpDone(type, purposes) {
|
||||
const a = document.querySelector(`[data-type="${type}"] .action`);
|
||||
a.onclick = null;
|
||||
a.innerHTML = `<p>${purposes.join(', ')}</p><button type="button" role="button">Edit</button>`;
|
||||
const ans = window.__answers[type];
|
||||
a.innerHTML = `<p>Used for ${purposes.join(', ')}</p>`
|
||||
+ (ans ? `<p>${ans.linked === 'yes' ? 'Linked' : 'Not linked'} to the user’s identity</p><p>${ans.tracking === 'yes' ? 'Used' : 'Not used'} for tracking purposes</p>` : '')
|
||||
+ `<button type="button" role="button">Edit</button>`;
|
||||
a.querySelector('button').addEventListener('click', () => openType(type));
|
||||
}
|
||||
// the per-type dialog: purposes > Next > linked > Next > definitions (text only, real console) > Next > tracking > Save
|
||||
// the per-type dialog: purposes > Next > linked > Next > definitions > Next > examples (both text only) > Next > tracking > Save
|
||||
function openType(type) {
|
||||
const low = type.toLowerCase();
|
||||
const d = document.createElement('dialog');
|
||||
|
|
@ -76,16 +83,17 @@ function openType(type) {
|
|||
const radio = (name, v, html) => `<div><input type="radio" name="${name}" id="${name}_${v}" value="${v}"><div><label for="${name}_${v}"><span>${html}</span></label></div></div>`;
|
||||
const step2 = unknown === type
|
||||
? `<p>Which kinds of identity does ${low} carry?</p><span role="switch" aria-checked="false" tabindex="0">Account identity</span><span role="switch" aria-checked="false" tabindex="0">Device identity</span>`
|
||||
: radio('linked', 'yes', `<strong>Yes</strong>, ${low} collected from this app is linked to the user’s identity`) + radio('linked', 'no', `<strong>No</strong>, ${low} collected from this app is not linked to the user’s identity`);
|
||||
: radio('linked', 'yes', `<strong>Yes</strong>, ${low} data collected from this app is linked to the user’s identity`) + radio('linked', 'no', `<strong>No</strong>, ${low} data collected from this app is not linked to the user’s identity`);
|
||||
d.innerHTML = `<div role="document"><h2 id="modal-header"><span>${type}</span></h2>`
|
||||
+ `<div data-p="1"><p>Indicate how ${low} collected from this app is being used by you or your third-party partners.</p>`
|
||||
+ PURPOSES.map((p, i) => `<div><input type="checkbox" id="purpose_${i}"><div><label for="purpose_${i}"><p>${p}</p><p>Description of ${p}</p></label></div></div>`).join('')
|
||||
+ PURPOSES.map((p, i) => `<div><input type="checkbox" id="purpose_${i}"><div><label><p>${p}</p><p>Description of ${p}</p></label></div></div>`).join('')
|
||||
+ `<div><button type="button">Cancel</button><button type="button" data-go="2">Next</button></div></div>`
|
||||
+ `<div data-p="2" hidden>${step2}<div><button type="button" data-go="1">Back</button><button type="button">Cancel</button><button type="button" data-go="3">Next</button></div></div>`
|
||||
+ `<div data-p="3" hidden><p>Let’s define two important terms before continuing.</p><h5>Tracking</h5><p>Linking data collected from your app with third-party data.</p><h5>Third-Party Data</h5><p>Data about a user collected from other companies.</p><div><button type="button" data-go="2">Back</button><button type="button">Cancel</button><button type="button" data-go="4">Next</button></div></div>`
|
||||
+ `<div data-p="4" hidden>`
|
||||
+ radio('tracking', 'yes', `<strong>Yes</strong>, we use ${low} for tracking purposes`) + radio('tracking', 'no', `<strong>No</strong>, we do not use ${low} for tracking purposes`)
|
||||
+ `<div><button type="button" data-go="3">Back</button><button type="button">Cancel</button><button type="button" data-save>Save</button></div></div></div>`;
|
||||
+ `<div data-p="4" hidden><p>To help put tracking into context, here are a few examples:</p><ul><li>Displaying targeted advertisements.</li><li>Sharing location data with a data broker.</li></ul><div><button type="button" data-go="3">Back</button><button type="button">Cancel</button><button type="button" data-go="5">Next</button></div></div>`
|
||||
+ `<div data-p="5" hidden>`
|
||||
+ radio('tracking', 'yes', `<strong>Yes</strong>, we use ${low} data for tracking purposes`) + radio('tracking', 'no', `<strong>No</strong>, we do not use ${low} data for tracking purposes`)
|
||||
+ `<div><button type="button" data-help>Definitions and Examples</button><button type="button" data-go="4">Back</button><button type="button">Cancel</button><button type="button" data-save>Save</button></div></div></div>`;
|
||||
d.setAttribute('open', '');
|
||||
d.addEventListener('click', e => {
|
||||
const go = e.target.closest('[data-go]')?.getAttribute('data-go');
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<h1>Create app</h1>
|
||||
<form onsubmit="return false">
|
||||
<p><label>App name <input type="text" maxlength="30"></label></p>
|
||||
<p><label>App package name <input type="text" maxlength="150"></label></p>
|
||||
<p><label>Store language <select><option value="">Choose a language</option><option>English (United States) – en-US</option></select></label></p>
|
||||
<fieldset><legend>App or game</legend>
|
||||
<label><input type="radio" name="type"> App</label> <label><input type="radio" name="type"> Game</label>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<form onsubmit="return false">
|
||||
<h2>Uygulama ayrıntıları</h2>
|
||||
<p><label>Uygulama adı <input type="text" maxlength="30"></label></p>
|
||||
<p><label>Uygulama paket adı <input type="text" maxlength="150"></label></p>
|
||||
<p><label>Varsayılan dil <select><option value="">Dil seçin</option><option>İngilizce (Birleşik Krallık) – en-GB</option><option>İngilizce (ABD) – en-US</option><option>Türkçe – tr-TR</option></select></label></p>
|
||||
<fieldset><legend>Uygulama veya oyun</legend>
|
||||
<label><input type="radio" name="type"> Uygulama</label> <label><input type="radio" name="type"> Oyun</label>
|
||||
|
|
|
|||
46
tests/fixtures/console/play-create-app.html
vendored
46
tests/fixtures/console/play-create-app.html
vendored
|
|
@ -1,21 +1,30 @@
|
|||
<!doctype html>
|
||||
<!-- MODELED, not recorded: Play Console > Create app (App details: App name, Default language; App or game; Free or
|
||||
paid; Declarations: Developer Program Policies, US export laws), written from Google's public help "Create and set
|
||||
up your app". The declarations are agreements storebound never ticks; the final "Create app" is the owner's click.
|
||||
Placeholder data only. -->
|
||||
<!-- Play Console > Create app (/console/u/0/developers/<id>/create-new-app). Structure verified on a real console
|
||||
(2026-10-12, driven through Claude in Chrome; structure only, no data copied): App name (textbox, 30 max), App
|
||||
package name (textbox, 150 max) with a read-only "Check availability" button that answers "Package name
|
||||
available", Default language, App or game and Free or paid as <material-radio> elements (an inner [role=radio]
|
||||
with aria-checked; the native input inside does NOT update the form's model: until the inner [role=radio] is
|
||||
clicked the form shows "Select app or game" / "Select free or paid"), and the two declarations (agreements the
|
||||
owner ticks). The final "Create app" is the owner's click. Placeholder data only.
|
||||
window.__model: the form's model (what Angular would hold). -->
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Create app - Play Console</title><script src="/_common.js"></script></head>
|
||||
<body>
|
||||
<h1>Create app</h1>
|
||||
<form onsubmit="return false">
|
||||
<h2>App details</h2>
|
||||
<p><label>App name <input type="text" maxlength="30"></label></p>
|
||||
<p><label>App package name <input type="text" maxlength="150" id="pkg"></label> <button type="button" id="check">Check availability</button> <span id="avail"></span></p>
|
||||
<p><label>Default language <select><option value="">Choose a language</option><option>English (United Kingdom) – en-GB</option><option>English (United States) – en-US</option><option>Turkish – tr-TR</option></select></label></p>
|
||||
<fieldset><legend>App or game</legend>
|
||||
<label><input type="radio" name="type"> App</label> <label><input type="radio" name="type"> Game</label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Free or paid</legend>
|
||||
<label><input type="radio" name="price"> Free</label> <label><input type="radio" name="price"> Paid</label>
|
||||
</fieldset>
|
||||
<div role="radiogroup" aria-label="App or game">
|
||||
<material-radio><div role="radio" aria-checked="false" tabindex="0" aria-labelledby="t-app"><input type="radio" name="type" tabindex="-1" style="opacity:0;position:absolute;width:1px;height:1px"></div><span id="t-app">App</span></material-radio>
|
||||
<material-radio><div role="radio" aria-checked="false" tabindex="0" aria-labelledby="t-game"><input type="radio" name="type" tabindex="-1" style="opacity:0;position:absolute;width:1px;height:1px"></div><span id="t-game">Game</span></material-radio>
|
||||
<p class="hint" data-group="type">Select app or game</p>
|
||||
</div>
|
||||
<div role="radiogroup" aria-label="Free or paid">
|
||||
<material-radio><div role="radio" aria-checked="false" tabindex="0" aria-labelledby="p-free"><input type="radio" name="price" tabindex="-1" style="opacity:0;position:absolute;width:1px;height:1px"></div><span id="p-free">Free</span></material-radio>
|
||||
<material-radio><div role="radio" aria-checked="false" tabindex="0" aria-labelledby="p-paid"><input type="radio" name="price" tabindex="-1" style="opacity:0;position:absolute;width:1px;height:1px"></div><span id="p-paid">Paid</span></material-radio>
|
||||
<p class="hint" data-group="price">Select free or paid</p>
|
||||
</div>
|
||||
<h2>Declarations</h2>
|
||||
<fieldset><legend>Developer Program Policies</legend>
|
||||
<label><input type="checkbox"> Confirm app meets the Developer Program Policies</label>
|
||||
|
|
@ -25,4 +34,21 @@
|
|||
</fieldset>
|
||||
<button type="button">Cancel</button> <button type="submit">Create app</button>
|
||||
</form>
|
||||
<style>material-radio { display: inline-block; margin-right: 12px } [role=radio] { display: inline-block; width: 14px; height: 14px; border: 1px solid #555; border-radius: 50%; vertical-align: middle } [role=radio][aria-checked=true] { background: #1a73e8 }</style>
|
||||
<script>
|
||||
window.__model = { type: null, price: null };
|
||||
document.querySelectorAll('[role=radiogroup]').forEach(g => {
|
||||
const key = g.querySelector('input').name;
|
||||
g.querySelectorAll('material-radio [role=radio]').forEach(r => r.addEventListener('click', () => {
|
||||
// only a click on the inner [role=radio] updates the model (the native input does not)
|
||||
g.querySelectorAll('[role=radio]').forEach(x => x.setAttribute('aria-checked', String(x === r)));
|
||||
r.querySelector('input').checked = true;
|
||||
window.__model[key] = document.getElementById(r.getAttribute('aria-labelledby')).textContent;
|
||||
g.querySelector('.hint').hidden = true;
|
||||
}));
|
||||
});
|
||||
document.getElementById('check').addEventListener('click', () => {
|
||||
document.getElementById('avail').textContent = document.getElementById('pkg').value ? 'Package name available' : 'Enter a package name';
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ describe('growth extras config', () => {
|
|||
});
|
||||
|
||||
describe('growth extras over MCP', () => {
|
||||
test('no console tool exists in MCP', () => {
|
||||
expect(TOOLS.filter(t => t.command[0] === 'console' || /console/.test(t.name))).toEqual([]);
|
||||
test('no console tool that drives a browser exists in MCP (only the read-only plan and verify)', () => {
|
||||
expect(TOOLS.filter(t => (t.command[0] === 'console' || /console/.test(t.name)) && !(t.write === false && ['plan', 'verify'].includes(t.command[1] ?? ''))).map(t => t.name)).toEqual([]);
|
||||
});
|
||||
test('Apple Ads, RevenueCat, device registration and internal sharing are read-only in MCP', () => {
|
||||
const risky = TOOLS.filter(t => (t.command[0] === 'ads' && t.command[1] === 'apple') || t.command[0] === 'revenuecat' || (t.command[0] === 'play' && t.command[1] === 'share')
|
||||
|
|
|
|||
|
|
@ -211,7 +211,8 @@ describe('serve --stdio: session', () => {
|
|||
test('console.fill: desktop only (never MCP), answers as data in the dry run, browser only with a host token, cancel kills it', async () => {
|
||||
expect(describeMethods().find(m => m.method === 'console.fill')).toMatchObject({ kind: 'write', json: true, network: true, destructive: false });
|
||||
expect(DESKTOP_ONLY.some(t => t.name === 'console_fill')).toBe(true);
|
||||
expect(TOOLS.some(t => t.name.startsWith('console') || t.command[0] === 'console')).toBe(false);
|
||||
// MCP has only the read-only agent-first console tools (plan, verify); the scripted fill never
|
||||
expect(TOOLS.filter(t => t.name.startsWith('console') || t.command[0] === 'console').map(t => [t.name, t.command.join(' '), t.write])).toEqual([['console_plan', 'console plan', false], ['console_verify', 'console verify', false]]);
|
||||
expect('console.fill').not.toMatch(FORBIDDEN_METHOD_WORDS);
|
||||
let now = 9_000_000;
|
||||
const answers = { form: 'apple-age-rating', store: 'apple', title: 'Age rating', url: 'https://appstoreconnect.apple.com/', answers: [{ id: 'violence', label: 'Cartoon violence', value: 'None', certain: true, source: 'apple.ageRating' }], uncertain: 0, guidance: [] };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue