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 ## 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 - 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 (`def code = (project.findProperty('versionCode') ?: ...).toInteger()`, then `versionCode code`; also `ext.` and Kotlin
`val`) is followed to its definition and no longer flagged. `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). */ /** 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 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). */ /** Purpose labels as the console shows them (curly ’ in Developer’s). */
const purposeLabel = (p: string) => p.replace(/'/g, '’'); 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: '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: '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: '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: '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: '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 }, { 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[]; 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. */ /** 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(); 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(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 === '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: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' }); 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'); 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']]); 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', 'Diagnostics', 'Crash Data', '?', 'Used for App Functionality', 'Not linked to the user’s identity', 'Not used for tracking purposes', 'Edit',
].join('\n'); ].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', () => { 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)', () => { test('parses the per-type summaries (curly quotes, the Purchases label, missing tracking line read as No)', () => {
const m = parseAppPrivacy(SAMPLE); const m = parseAppPrivacy(SAMPLE);