223 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|