storebound/tests/console-plan.test.ts

223 lines
17 KiB
TypeScript

// 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 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 to their card page once the app id is known (verified 2026-10-12)', () => {
expect(consolePlan('play-content-rating', base, { playAppId: '9876' }).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app/9876/app-content/content-rating-overview');
expect(consolePlan('play-target-audience', base, { playAppId: '9876' }).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app/9876/app-content/target-audience-content');
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');
/** Real App Privacy page text after publishing (a game, 2026-10-12): the summary sections list category names only; each
* type's lines use Apple's comma style ("X, and Y"), the linked line has a straight apostrophe and is left out when
* not linked; copied text may join a type's lines with " | ". */
const REAL = [
'Data Used to Track You', 'The following data may be used to track you across apps and websites owned by other companies:', 'Identifiers', 'Location',
'Data Linked to You', 'Location', 'User Content', 'Identifiers', 'Data Not Linked to You', 'Identifiers',
'Data Types', 'Edit',
'Coarse Location', "Used for Analytics, App Functionality, and Third-Party Advertising | Linked to the user's identity | Used for tracking purposes",
'Device ID', 'Used for Third-Party Advertising, and Analytics | Used for tracking purposes',
'Advertising Data', 'Used for Third-Party Advertising | Used for tracking purposes',
'Crash Data', "Used for Analytics | Linked to the user's identity",
].join('\n');
describe('console verify: App Privacy from the real page text', () => {
test('category names in the summary sections are not types; " | " lines, Apple\'s commas and straight quotes parse', () => {
const m = parseAppPrivacy(REAL);
expect([...m.keys()].sort()).toEqual(['Advertising Data', 'Coarse Location', 'Crash Data', 'Device ID']);
const coarse = m.get('Coarse Location')!;
expect([...coarse.purposes!].sort()).toEqual(['Analytics', 'App Functionality', 'Third-Party Advertising']);
expect(coarse).toMatchObject({ setUp: false, linked: true, tracking: true, inferred: [] });
expect(m.get('Device ID')).toMatchObject({ purposes: expect.arrayContaining(['Third-Party Advertising', 'Analytics']), linked: false, tracking: true, inferred: ['linked'] });
expect(m.get('Crash Data')).toMatchObject({ purposes: ['Analytics'], linked: true, tracking: false, inferred: ['tracking'] });
});
});
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);
});
});