console verify App Privacy: real page text (category-only summary sections, ' | ' joined lines, Apple's commas); tracking label without 'data data'; Play Data safety page path confirmed

This commit is contained in:
CRTkafa 2026-10-13 01:38:50 +08:00
parent 2b101f0fd8
commit 7e09947e62
5 changed files with 37 additions and 4 deletions

View file

@ -2,6 +2,12 @@
## Unreleased
- `console verify apple-app-privacy` checked against real page text (2026-10-12): the summary sections ("Data Used to
Track You" and the others) list category names only and are skipped; a type's lines may come joined by " | "; Apple's
comma style ("X, and Y") and the straight apostrophe parse. The tracking radio label of a type that already ends in
"Data" no longer reads "crash data data". The Play Data safety page path (`app-content/data-privacy-security`, with
Export / Import CSV) is confirmed.
- Fix (`play build` / `check` versionCode source warning): a variable set from the property first
(`def code = (project.findProperty('versionCode') ?: ...).toInteger()`, then `versionCode code`; also `ext.` and Kotlin
`val`) is followed to its definition and no longer flagged.

View file

@ -107,7 +107,9 @@ export function nameText(n: Name): string {
/** App Privacy labels verified on the real dialog (2026-10-12). */
const linkedLabel = (t: string, yes: boolean) => `${yes ? 'Yes' : 'No'}, ${t.toLowerCase()} data collected from this app ${yes ? 'is' : 'is not'} linked to the user’s identity`;
const trackingLabel = (t: string, yes: boolean) => (yes ? `Yes, we use ${t.toLowerCase()} data for tracking purposes` : `No, we do not use ${t.toLowerCase()} data for tracking purposes`);
// "Crash Data" -> "crash data", not "crash data"
const dataOf = (t: string) => (/\bdata$/i.test(t) ? t.toLowerCase() : `${t.toLowerCase()} data`);
const trackingLabel = (t: string, yes: boolean) => (yes ? `Yes, we use ${dataOf(t)} for tracking purposes` : `No, we do not use ${dataOf(t)} for tracking purposes`);
/** Purpose labels as the console shows them (curly ’ in Developer’s). */
const purposeLabel = (p: string) => p.replace(/'/g, '’');

View file

@ -45,7 +45,7 @@ export const APP_CONTENT_CARDS: AppContentCard[] = [
{ name: 'Sign in details', slug: 'testing-credentials', form: 'play-sign-in-details', via: 'console plan play-sign-in-details', verified: true },
{ name: 'Content ratings', slug: 'content-rating-overview', form: 'play-content-rating', via: 'console plan play-content-rating (Start questionnaire > content-rating-iarc-questionnaire)', verified: true },
{ name: 'Target audience and content', slug: 'target-audience-content', form: 'play-target-audience', via: 'console plan play-target-audience', verified: true },
{ name: 'Data safety', slug: 'data-privacy-security', form: null, via: 'storebound play data-safety (API; no console form to fill)', verified: false },
{ name: 'Data safety', slug: 'data-privacy-security', form: null, via: 'storebound play data-safety (API; no console form to fill; the page has Export to CSV / Import from CSV)', verified: true },
{ name: 'Advertising ID', slug: 'ad-id-declaration', form: 'play-advertising-id', via: 'console plan play-advertising-id', verified: true },
{ name: 'Government apps', slug: 'government-apps', form: 'play-government-apps', via: 'console plan play-government-apps', verified: true },
{ name: 'Financial features', slug: 'finance', form: 'play-financial-features', via: 'console plan play-financial-features', verified: true },

View file

@ -31,7 +31,7 @@ export interface VerifyResult {
notes: string[];
}
const lines = (text: string): string[] => text.normalize('NFC').split(/\r?\n/).map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
const lines = (text: string): string[] => text.normalize('NFC').split(/\r?\n| \| /).map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean);
/** Straight and curly quotes as one, case and spaces aside: for comparing labels. */
export const fold = (s: string): string => s.normalize('NFC').replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim().toLowerCase();

View file

@ -63,7 +63,7 @@ describe('console plan: the JSON shape for every form', () => {
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 === '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']]);
@ -120,6 +120,31 @@ const SAMPLE = [
'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);