storebound/tests/fixtures/console/apple-app-privacy-flow.html
CRT 59150c1186 console fill apple-app-privacy: fit the Data Collection dialog to the real console
A real run (sanitized debug dump) showed why the walk stopped after Next:
the data types step is the same "Data Collection" dialog (URL unchanged),
and each checkbox's accessible name is its label run together with its
help text ("NameIncluding first or last name"). Some labels also differ
from Apple's help page ("Purchases", "Other Data").

- labelNames: the exact name, then the label as a prefix that ends where
  the help text starts (no lowercase letter follows), so "Name" never
  matches "Phone Number...", "Head" never "Health...", "Contact" never
  "Contacts...". Used for the data types and the purposes.
  TYPE_ALIASES maps Purchase History / Other Data Types to the console's
  labels.
- Dialog per step: a section's dialog is also found as the one visible
  dialog with a heading of that name.
- Every click and check is bounded (ACTION_MS, 10 s; the scan test now
  also rejects clicks without a timeout).
- The flow fixture mirrors the real dialog's structure (dialog/h2 title,
  group h3s, input name = Apple's enum, <label> with label + help <p>s,
  Save disabled until a type is ticked); no data from the dump. A test
  checks every Apple type matches exactly one real-style checkbox.
- Pitfall 15.

Publish is still never clicked; unknown steps still get the watchdog dump.
2026-10-12 07:17:15 +08:00

114 lines
8.7 KiB
HTML

<!doctype html>
<!-- App Store Connect > App Privacy as a whole questionnaire. The "Data Collection" dialog mirrors the STRUCTURE of a
real console page (sanitized debug dump, October 2026; no data copied): one <dialog role="dialog"> titled by an
<h2><span>Data Collection</span></h2>; step 1 "Yes, we collect data" / "No, ..." radios (name isAnyDataCollected)
with Next; step 2 group headings (Contact Info, Identifiers ...) and one checkbox per data type whose <label> holds
two <p>: the type label and its help text, so the accessible name is both run together ("NameDescription ...");
the input's name is Apple's enum (NAME, PURCHASE_HISTORY ...); labels as the console shows them ("Purchases",
"Other Data"); Back / Cancel / Save, Save disabled until a type is ticked. The rest is MODELED from Apple's help
page "Manage app privacy" (each data type in the Data Types section opens its questions: purposes > Next > linked
to the user > Next > tracking > Save; Edit next to Data Types changes the list; Publish publishes, with a
confirmation); the purpose checkboxes also carry their help text in the name. ?existing=1: answers exist (no Get
Started; Edit next to Data Types). Decoys: Publish, See Details, the Edit next to the Privacy Policy URL.
window.__saves counts the draft saves. Help texts are placeholders. -->
<html lang="en"><head><meta charset="utf-8"><title>App Store Connect</title><script src="/_common.js"></script></head>
<body>
<h1>App Privacy</h1>
<button type="button" id="publish">Publish</button>
<section><h2>Privacy Policy</h2><p>https://example.com/privacy</p><button type="button">Edit</button></section>
<p id="intro"><button type="button" id="start"><span>Get Started</span></button></p>
<section id="dt" hidden><h2>Data Types</h2><button type="button" id="edit">Edit</button><ul id="list"></ul></section>
<section><h2>Product Page Preview</h2><button type="button">See Details</button></section>
<dialog role="dialog" aria-modal="true" aria-labelledby="modal-header" id="collect">
<div role="document">
<h2 id="modal-header"><span>Data Collection</span></h2>
<div id="c1">
<div><input type="radio" name="isAnyDataCollected" id="collect_true" value="yes"><div><label for="collect_true"><span><strong>Yes</strong>, we collect data from this app</span></label></div></div>
<div><input type="radio" name="isAnyDataCollected" id="collect_false" value="no"><div><label for="collect_false"><span><strong>No</strong>, we do not collect data from this app</span></label></div></div>
<div><button type="button">Cancel</button> <button type="button" id="c1next">Next</button> <button type="button" id="c1save" hidden>Save</button></div>
</div>
<div id="c2" hidden>
<p><span><p>Next, select all of the data types that you or your third-party partners collect from this app.</p></span></p>
<h3><span>Optional Disclosure</span></h3>
<div id="groups"></div>
<div><button type="button" id="c2back">Back</button> <button type="button">Cancel</button> <button type="button" id="c2save" disabled>Save</button></div>
</div>
</div>
</dialog>
<div id="per"></div>
<script>
// [group, [enum, label as the console shows it]]
const GROUPS = [
['Contact Info', [['NAME', 'Name'], ['EMAIL_ADDRESS', 'Email Address'], ['PHONE_NUMBER', 'Phone Number'], ['PHYSICAL_ADDRESS', 'Physical Address'], ['OTHER_CONTACT_INFO', 'Other User Contact Info']]],
['Health & Fitness', [['HEALTH', 'Health'], ['FITNESS', 'Fitness']]],
['Financial Info', [['PAYMENT_INFORMATION', 'Payment Info'], ['CREDIT_AND_FRAUD', 'Credit Info'], ['OTHER_FINANCIAL_INFO', 'Other Financial Info']]],
['Location', [['PRECISE_LOCATION', 'Precise Location'], ['COARSE_LOCATION', 'Coarse Location']]],
['Sensitive Info', [['SENSITIVE_INFO', 'Sensitive Info']]],
['Contacts', [['CONTACTS', 'Contacts']]],
['User Content', [['EMAILS_OR_TEXT_MESSAGES', 'Emails or Text Messages'], ['PHOTOS_OR_VIDEOS', 'Photos or Videos'], ['AUDIO', 'Audio Data'], ['GAMEPLAY_CONTENT', 'Gameplay Content'], ['CUSTOMER_SUPPORT', 'Customer Support'], ['OTHER_USER_CONTENT', 'Other User Content']]],
['Browsing History', [['BROWSING_HISTORY', 'Browsing History']]],
['Search History', [['SEARCH_HISTORY', 'Search History']]],
['Identifiers', [['USER_ID', 'User ID'], ['DEVICE_ID', 'Device ID']]],
['Purchases', [['PURCHASE_HISTORY', 'Purchases']]],
['Usage Data', [['PRODUCT_INTERACTION', 'Product Interaction'], ['ADVERTISING_DATA', 'Advertising Data'], ['OTHER_USAGE_DATA', 'Other Usage Data']]],
['Diagnostics', [['CRASH_DATA', 'Crash Data'], ['PERFORMANCE_DATA', 'Performance Data'], ['OTHER_DIAGNOSTIC_DATA', 'Other Diagnostic Data']]],
['Surroundings', [['ENVIRONMENTAL_SCANNING', 'Environment Scanning']]],
['Body', [['HANDS', 'Hands'], ['HEAD_MOVEMENT', 'Head']]],
['Other Data', [['OTHER_DATA', 'Other Data']]],
];
const PURPOSES = ['Third-Party Advertising', "Developer's Advertising or Marketing", 'Analytics', 'Product Personalization', 'App Functionality', 'Other Purposes'];
const $ = id => document.getElementById(id);
window.__saves = 0;
for (const [g, types] of GROUPS) {
const h = document.createElement('h3'); h.innerHTML = `<span>${g}</span>`; $('groups').append(h);
for (const [id, label] of types) {
const d = document.createElement('div');
d.innerHTML = `<div><div><input type="checkbox" name="${id}" id="SELECT_CATEGORIES_checkbox_${id}"><div><label for="SELECT_CATEGORIES_checkbox_${id}"><p>${label}</p><p>Description of what ${label} covers</p></label></div></div></div>`;
$('groups').append(d);
}
}
$('groups').addEventListener('change', () => { $('c2save').disabled = !$('groups').querySelector('input:checked'); });
const existing = new URLSearchParams(location.search).has('existing');
const show = (el, on) => { el.hidden = !on; };
const open = (on) => { if (on) $('collect').setAttribute('open', ''); else $('collect').removeAttribute('open'); };
function listTypes(names) {
$('list').innerHTML = '';
for (const [i, t] of names.entries()) {
const li = document.createElement('li');
// one data type opens with a "Set Up <type>" button, the next with a link named like the type
li.innerHTML = i % 2 === 0 ? `<button type="button">Set Up ${t}</button>` : `<a href="#">${t}</a>`;
li.querySelector('button, a').addEventListener('click', e => { e.preventDefault(); openType(t); });
$('list').append(li);
}
show($('dt'), true); show($('intro'), false);
}
// the per-type dialog: three pages (purposes > Next > linked > Next > tracking > Save); labels with their help text
function openType(t) {
const d = document.createElement('div'); d.setAttribute('role', 'dialog'); d.setAttribute('aria-label', t);
d.innerHTML = `<div data-p="1">${PURPOSES.map((p, i) => `<div><input type="checkbox" id="p${i}"><label for="p${i}"><p>${p}</p><p>Description of ${p}</p></label></div>`).join('')}<button type="button" data-go="2">Next</button></div>`
+ `<div data-p="2" hidden><label><input type="radio" name="l"> Yes, ${t} collected from this app is linked to the user's identity</label>`
+ `<label><input type="radio" name="l"> No, ${t} collected from this app is not linked to the user's identity</label><button type="button" data-go="1b">Back</button> <button type="button" data-go="3">Next</button></div>`
+ `<div data-p="3" hidden><label><input type="radio" name="t"> Yes, we use ${t} for tracking purposes</label>`
+ `<label><input type="radio" name="t"> No, we do not use ${t} for tracking purposes</label><button type="button" data-save>Save</button></div>`;
d.addEventListener('click', e => {
const go = e.target.closest('[data-go]')?.getAttribute('data-go');
if (go && go !== '1b') d.querySelectorAll('[data-p]').forEach(p => { p.hidden = p.getAttribute('data-p') !== go; });
if (e.target.closest('[data-save]')) { window.__saves++; d.remove(); }
});
$('per').append(d);
}
if (existing) listTypes(['Device ID', 'Crash Data']);
$('start').addEventListener('click', () => { open(true); show($('c1'), true); show($('c2'), false); });
$('edit').addEventListener('click', () => { open(true); show($('c1'), false); show($('c2'), true); });
// "No" saves at once; "Yes" goes on with Next
document.querySelectorAll('input[name=isAnyDataCollected]').forEach(r => r.addEventListener('change', () => { const no = r.value === 'no' && r.checked; show($('c1next'), !no); show($('c1save'), no); }));
$('c1next').addEventListener('click', () => { show($('c1'), false); show($('c2'), true); });
$('c1save').addEventListener('click', () => { window.__saves++; open(false); });
$('c2save').addEventListener('click', () => {
window.__saves++;
const picked = [...$('groups').querySelectorAll('input:checked')].map(i => i.parentElement.querySelector('label p').textContent.trim());
open(false); listTypes(picked);
});
</script>
</body></html>