console fill apple-app-privacy: set up each data type from the real page ('Set Up <type>' is a plain text, clicked by its exact text in the Data Types section), fill its dialog step by step, dump and pause on an unknown step; '?' and Publish never clicked

This commit is contained in:
CRTkafa 2026-10-12 07:36:06 +08:00
parent 3aedf9e137
commit e0bef82b06
7 changed files with 500 additions and 28 deletions

View file

@ -11,7 +11,7 @@ import { fillSession } from '../cli/console/fill';
import type { ConsoleBrowser } from '../cli/console/session';
import { APPLE_DATA_TYPE_IDS, type AppleDataTypeId, type AppleTypeAnswer } from '../cli/lib/app-privacy';
import type { ShipConfig } from '../cli/lib/config';
import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS, labelNames, TYPE_ALIASES } from '../cli/lib/console-forms';
import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS, labelNames, setUpAllowed, setUpTexts, TYPE_ALIASES } from '../cli/lib/console-forms';
import { APPLE_PRIVACY_TYPES } from '../cli/lib/app-privacy';
import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, logLines, testScope } from './helpers/browser';
@ -104,6 +104,15 @@ describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
for (const n of ['Publish', 'Yayınla', 'Submit for Review']) expect(flowAllowed('apple-app-privacy', n, 'button', at({ inDialog: true, inDataTypes: true }))).toBe(false);
expect(flowAllowed('apple-app-privacy', 'Get Started', 'checkbox', at())).toBe(false);
});
test('the Set Up text gate: exactly "Set Up <a type storebound answers>" in the Data Types section, outside dialogs', () => {
const w = at({ inDataTypes: true, dataTypes: ['Coarse Location', 'Purchase History', 'Purchases'] });
for (const t of ['Set Up Coarse Location', 'set up coarse location', 'Set Up Purchase History', 'Set Up Purchases']) expect(setUpAllowed(t, w)).toBe(true);
for (const t of ['?', 'More information', 'Set Up Device ID', 'Set Up Coarse Location now', 'Publish', 'Coarse Location', 'Edit']) expect(setUpAllowed(t, w)).toBe(false);
expect(setUpAllowed('Set Up Coarse Location', { ...w, inDataTypes: false })).toBe(false);
expect(setUpAllowed('Set Up Coarse Location', { ...w, inDialog: true })).toBe(false);
expect(setUpAllowed('Set Up Coarse Location', { ...w, privacyPage: false })).toBe(false);
expect(setUpTexts('Purchase History')).toContain('Set Up Purchases');
});
test('other forms: Next is navigation; the content rating\'s Save draft is clicked; Save, Submit, Create, Done stay the owner\'s', () => {
const w = at({ privacyPage: false });
for (const f of ['play-content-rating', 'play-target-audience', 'apple-app-record', 'play-app-record'] as const) {
@ -148,14 +157,15 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
expect((await run).fallback).toBe(false);
}, TEST_MS);
test('answers exist: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => {
test('answers exist and the list differs from the config: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => {
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?existing=1', ...fast(log) }));
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)));
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(3);
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(4); // the list + three types
expect(await clicks(b)).not.toContain('Publish');
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
@ -197,6 +207,122 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
}, TEST_MS);
});
/** Six collected types as on the real page (Purchase History shows as "Purchases"), every answer certain. */
const SIX: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {
COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'], linked: false, tracking: false },
EMAILS_OR_TEXT_MESSAGES: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false },
DEVICE_ID: { collected: true, purposes: ['THIRD_PARTY_ADVERTISING', 'ANALYTICS'], linked: false, tracking: true },
PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false },
PRODUCT_INTERACTION: { collected: true, purposes: ['ANALYTICS', 'PRODUCT_PERSONALIZATION'], linked: false, tracking: false },
CRASH_DATA: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: false, tracking: false },
};
const six = (over: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {}) => privacy({ CRASH_DATA: SIX.CRASH_DATA!, ...SIX, ...over });
const SIX_NAMES = ['Coarse Location', 'Emails or Text Messages', 'Device ID', 'Purchase History', 'Product Interaction', 'Crash Data'];
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 () => {
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)));
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);
expect(got['Device ID']).toEqual({ purposes: ['Third-Party Advertising', 'Analytics'], linked: 'no', tracking: 'yes' });
expect(got['Purchase History']).toEqual({ purposes: ['App Functionality'], linked: 'yes', tracking: 'no' });
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(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)');
expect(log.lines.some(m => m.startsWith('answer needed'))).toBe(false);
expect(navAsked(log.lines)).toEqual([]);
// --debug-dump: the start, each Set Up, each dialog step and each filled step
const names = readdirSync(dir);
for (const want of [/-after-set-up-coarse-location$/, /-coarse-location-after-next$/, /-filled-coarse-location-purposes$/, /-filled-coarse-location-linked-to-/, /-filled-crash-data-tracking$/])
expect(names.some(n => want.test(n))).toBe(true);
const step = names.find(n => /-coarse-location-after-next$/.test(n))!;
expect(JSON.parse(readFileSync(join(dir, step, 'page.json'), 'utf8')).dialogs).toContain('Coarse Location');
await b.context.close();
expect((await run).fallback).toBe(false);
} finally { rmSync(dir, { recursive: true, force: true }); }
}, TEST_MS);
test('an uncertain answer ("you decide") pauses on its step, names it, and goes on once the owner set it', async () => {
const b = await local();
const log = logLines();
const config = six({ PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], tracking: false } }); // linked: the owner's
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + SETUP, ...fast(log) }));
await log.wait('the stop', l => l.some(m => m.startsWith('answer needed on "Purchase History: linked to the user": Purchase History: linked to the user\'s identity?')));
await Bun.sleep(600); // several rounds: Next is not clicked past the open question
const yes = b.page.getByRole('radio', { name: /^Yes, purchase history .* linked/ });
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)));
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);
expect(await clicks(b)).not.toContain('Publish');
expect(await clicks(b)).not.toContain('?');
await b.context.close();
await run;
}, TEST_MS);
test('an unknown dialog step: a debug dump, a pause that names the data type, within the step timeout; nothing clicked past it', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
try {
const b = await local();
const log = logLines();
const started = Date.now();
const run = scope.run(fillSession(b, {
form: 'apple-app-privacy', config: six(), startUrl: `${base}${SETUP}?unknown=Device%20ID`, ...fast(log),
stepTimeoutMs: 1500, idleLimitMs: 60_000, heartbeatMs: 700, debugDir: dir,
}));
await log.wait('the pause', l => l.some(m => m.startsWith('paused on "Device ID"')));
expect(Date.now() - started).toBeLessThan(30_000);
const said = log.lines.join('\n');
expect(said).toMatch(/the "Device ID" dialog step after "Next" is not a page storebound knows .*Buttons in the dialog: "Back", "Cancel", "Next"/);
expect(said).toContain(`Debug saved to ${dir}`);
const sub = readdirSync(dir).find(n => /unknown/.test(n))!;
const pj = JSON.parse(readFileSync(join(dir, sub, 'page.json'), 'utf8'));
expect(pj.dialogs).toContain('Device ID');
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 * 2 + 1); // two types done, Device ID's first step only
expect(c).not.toContain('Publish');
expect(c).not.toContain('?');
await b.context.close();
expect((await run).fallback).toBe(false);
} finally { rmSync(dir, { recursive: true, force: true }); }
}, TEST_MS);
test('types set up before: the same purposes are left as they are, other purposes are opened with their Edit', async () => {
const b = await local();
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)));
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);
expect(got['Device ID'].purposes).toEqual(['Third-Party Advertising', 'Analytics']);
expect(got['Crash Data']).toBeUndefined();
expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1); // Device ID's own, never the section's
expect(await clicks(b)).not.toContain('Publish');
await b.context.close();
await run;
}, TEST_MS);
});
describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => {
test('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; Submit never clicked', async () => {
const b = await local();
@ -265,7 +391,7 @@ describe.skipIf(!browser)('watchdog: an unknown page after a click never means a
const said = log.lines.join('\n');
expect(said).toContain('clicked "Next" (navigation)');
expect(said).toMatch(/still waiting for the page after "Next", \d+ s/);
expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons there: .*"Done"/);
expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons in the dialog: .*"Done"/);
expect(said).toContain(`Debug saved to ${dir}`);
expect(said).toContain('do this part yourself');
expect(said).toContain('it stops here');