// A real-use report from a second game (Capacitor monorepo, first release): false positives and gaps in scan, check, // privacy-manifest, the console answer sheets and init. The patterns are recreated with example code. import { afterAll, describe, expect, test } from 'bun:test'; import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type AppleDataTypeId, type AppleTypeAnswer, appPrivacyMismatches } from '../cli/lib/app-privacy'; import type { ShipConfig } from '../cli/lib/config'; import { validateConfig } from '../cli/lib/config'; import { consoleAnswers } from '../cli/lib/console-answers'; import { consolePlan } from '../cli/lib/console-plan'; import { advertisingIdUse } from '../cli/lib/console-play-plans'; import { credentialStatus, credentialValue, isPlaceholder } from '../cli/lib/credentials'; import { collectedFromAppPrivacy, proposeAppManifest } from '../cli/lib/privacy-manifest'; import { type ProjectSignals, projectSignals } from '../cli/lib/project-signals'; import { runScan } from '../cli/lib/scan'; import { detectProject } from '../cli/lib/scan/project'; import { applyIgnore, type ScanFinding, SIGNALS, rewardNearReviewApi, unusedIgnores } from '../cli/lib/scan/rules'; const ROOT = join(import.meta.dir, '..'); const FX = join(import.meta.dir, 'fixtures', 'scan', 'capacitor-game'); const tmp = mkdtempSync(join(tmpdir(), 'sb-game-report-')); afterAll(() => rmSync(tmp, { recursive: true, force: true })); /** A copy of the fixture with files replaced (path -> text). */ function variant(name: string, files: Record): string { const d = join(tmp, name); cpSync(FX, d, { recursive: true }); for (const [p, t] of Object.entries(files)) { mkdirSync(join(d, p, '..'), { recursive: true }); writeFileSync(join(d, p), t); } return d; } const fxConfig = readFileSync(join(FX, 'storebound.config.ts'), 'utf8'); describe('review-prompt: a bare "rate" next to a reward word is not a reward for rating', () => { test('negatives: production rates, frame rates, rate limits, exchange rates, code review', () => { for (const t of [ 'const rate = F.genRate(lv, bonus.gen(r));', 'const frameRate = 60; // bonus coins drop at 30 fps', 'if (rateLimit.hit) return bonus.coins;', 'const exchangeRate = coins / gems;', 'rate limit bonus reward', 'review the code before the bonus round ships', 'growth rate per level gives bonus coins', ]) expect({ t, hit: SIGNALS.incentiveRating.test(t) }).toEqual({ t, hit: false }); }); test('positives: rate-us phrases next to a reward, rewards for rating', () => { for (const t of [ 'Rate us 5 stars and get 100 coins', 'review the app to earn a reward', 'Get 50 gems for rating us', 'rate the game for a bonus', 'Leave a review and get 20 gems!', 'Write us a review for free spins', ]) expect({ t, hit: SIGNALS.incentiveRating.test(t) }).toEqual({ t, hit: true }); }); test('a reward granted next to the system review prompt call', () => { const yes = { path: 'src/rate.ts', text: 'export async function askRating() {\n await InAppReview.requestReview();\n wallet.addCoins(50);\n}\n' }; expect(rewardNearReviewApi([yes]).map(h => h.line)).toEqual([2]); const no = { path: 'src/rate.ts', text: 'export async function askRating() {\n await InAppReview.requestReview();\n}\n' + '\n'.repeat(20) + 'wallet.addCoins(50);\n' }; expect(rewardNearReviewApi([no])).toEqual([]); }); }); describe('account signals: module paths are not auth code', () => { test('negatives: import paths and module names with register / login / account', () => { for (const t of [ "import './buildings/register';", "import { registerAll } from './modules/register';", "export * from './account/login';", "const m = require('./login');", "const Screen = lazy(() => import('./screens/register'));", "loadModule('./buildings/register');", "registry.register('farm', Farm);", "router.add({ path: './buildings/signup' });", ]) expect({ t, up: SIGNALS.signUp.test(t), in: SIGNALS.signIn.test(t) }).toEqual({ t, up: false, in: false }); }); test('positives: auth endpoints, SDK calls, credential fields, guest accounts, screen strings', () => { for (const t of [ "await fetch(`${API}/auth/register`, { method: 'POST', body });", "fetch('/api/signup', { method: 'POST' })", "http.post('https://api.example.com/v1/register', body)", '', 'const guest = await createGuestAccount(deviceId);', '', 'await supabase.auth.signUp({ email, password })', ]) expect({ t, hit: SIGNALS.signUp.test(t) }).toEqual({ t, hit: true }); for (const t of ["fetch('/auth/login', { method: 'POST' })", '', 'await signInWithEmailAndPassword(auth, e, p)']) expect({ t, hit: SIGNALS.signIn.test(t) }).toEqual({ t, hit: true }); }); test('a whole file: the import line is skipped, the real call on a later line still counts', () => { expect(SIGNALS.signUp.test("import './buildings/register';\nconst x = 1;\n")).toBe(false); expect(SIGNALS.signUp.test("import './buildings/register';\nawait fetch('/api/register', { method: 'POST' });\n")).toBe(true); }); }); describe('scan on the Capacitor game fixture', () => { test('no reward-for-rating finding from production rates; account evidence never an import path', async () => { const r = (await runScan(FX)).report; expect(r.findings.filter(f => f.rule === 'review-prompt')).toEqual([]); const account = r.findings.filter(f => ['account-deletion', 'play-deletion-url', 'demo-account', 'privacy-policy-link'].includes(f.rule)); expect(account.length).toBeGreaterThan(0); for (const f of account) for (const e of f.evidence) expect({ rule: f.rule, bad: /import|buildings\/register/.test(e.text) }).toEqual({ rule: f.rule, bad: false }); const ua = r.declarations.items.find(i => i.what === 'PSL_USER_ACCOUNT')!; expect(ua.evidence[0]!.text).toBe('from config (accounts.enabled)'); }); test('accounts.inApp alone does not clear 5.1.1(v): medium, "claimed by config"', async () => { const r = (await runScan(FX)).report; const del = r.findings.find(f => f.rule === 'account-deletion')!; expect(del).toMatchObject({ severity: 'medium', store: 'apple' }); expect(del.title).toContain('claimed by config'); expect(del.evidence[0]!.text).toBe('from config (accounts.inApp: Settings > Delete account)'); expect(del.fix).toContain('Show the code path or the screen'); }); test('deletion code clears it; no config claim and no code is a blocker; an import of a delete-account module is not code', async () => { const withCode = variant('del-code', { 'src/settings.ts': "export async function deleteAccount(): Promise {\n await fetch('/api/account', { method: 'DELETE' });\n}\n" }); expect((await runScan(withCode)).report.findings.filter(f => f.rule === 'account-deletion')).toEqual([]); const noClaim = variant('del-none', { 'storebound.config.ts': fxConfig.replace(", inApp: 'Settings > Delete account'", '') }); expect((await runScan(noClaim)).report.findings.find(f => f.rule === 'account-deletion')).toMatchObject({ severity: 'blocker', title: 'Accounts can be created but no in-app account deletion was found' }); const imported = variant('del-import', { 'src/menu.ts': "import './screens/delete-account';\n" }); expect((await runScan(imported)).report.findings.find(f => f.rule === 'account-deletion')!.severity).toBe('medium'); }); test('a scan.ignore entry that matches nothing is reported; evidence matching accepts by the evidence text', async () => { const r = (await runScan(FX)).report; expect(r.notes.some(n => n.startsWith('scan.ignore[0] (rule privacy-labels, evidence "src/no-such-file.ts") matches no finding'))).toBe(true); const by = variant('ignore-evidence', { 'storebound.config.ts': fxConfig.replace("{ rule: 'privacy-labels', evidence: 'src/no-such-file.ts', reason: 'stale entry: matches nothing' }", "{ rule: 'account-deletion', evidence: 'accounts.inApp', reason: 'deletion is a server-driven screen, checked on a device' }") }); const r2 = (await runScan(by)).report; expect(r2.findings.find(f => f.rule === 'account-deletion')!.accepted).toBe('deletion is a server-driven screen, checked on a device'); expect(r2.notes.filter(n => n.startsWith('scan.ignore['))).toEqual([]); }); }); describe('scan.ignore: title (match) and evidence', () => { const f = (): ScanFinding => ({ rule: 'privacy-labels', guideline: '5.1.2', store: 'apple', severity: 'medium', title: 'Data types the SDKs collect are missing', evidence: [{ file: 'src/sdk.ts', line: 7, text: 'Analytics.init(key)' }], fix: 'x', skill: 's', ref: 'https://example.com' }); test('match keeps matching the title (backward compatible), evidence matches file:line or text, both together narrow', () => { const cases: [{ rule: string; store?: 'apple' | 'play'; match?: string; evidence?: string; reason: string }, boolean][] = [ [{ rule: 'privacy-labels', match: 'SDKS COLLECT', reason: 'r' }, true], [{ rule: 'privacy-labels', match: 'Analytics.init', reason: 'r' }, false], [{ rule: 'privacy-labels', evidence: 'src/sdk.ts:7', reason: 'r' }, true], [{ rule: 'privacy-labels', evidence: 'analytics.init', reason: 'r' }, true], [{ rule: 'privacy-labels', evidence: 'src/other.ts', reason: 'r' }, false], [{ rule: 'privacy-labels', match: 'missing', evidence: 'src/sdk.ts', reason: 'r' }, true], [{ rule: 'privacy-labels', match: 'missing', evidence: 'src/other.ts', reason: 'r' }, false], [{ rule: 'privacy-labels', store: 'play', reason: 'r' }, false], ]; for (const [i, yes] of cases) { const x = f(); applyIgnore([x], [i]); expect({ i, accepted: !!x.accepted }).toEqual({ i, accepted: yes }); } }); test('unused entries are named; originality entries are skipped unless that rule ran', () => { const msgs = unusedIgnores([f()], [{ rule: 'privacy-labels', evidence: 'src/sdk.ts', reason: 'r' }, { rule: 'privacy-label', reason: 'typo' }, { rule: 'originality', reason: 'r' }]); expect(msgs.length).toBe(1); expect(msgs[0]).toStartWith('scan.ignore[1] (rule privacy-label) matches no finding'); }); test('the config accepts evidence and rejects an empty one', () => { const base = { app: { name: 'A', privacyPolicyUrl: 'https://example.com/p' }, languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }] }; const errs = (ignore: unknown[]) => validateConfig({ ...base, scan: { ignore } }).filter(e => e.startsWith('scan.ignore')); expect(errs([{ rule: 'x', evidence: 'src/a.ts:3', reason: 'r' }])).toEqual([]); expect(errs([{ rule: 'x', evidence: ' ', reason: 'r' }]).length).toBe(1); }); }); describe('privacy manifest from appPrivacy.types', () => { const types: Partial> = { USER_ID: { collected: true, linked: true, tracking: false, purposes: ['APP_FUNCTIONALITY'] }, DEVICE_ID: { collected: true, linked: false, tracking: true, purposes: ['APP_FUNCTIONALITY', 'THIRD_PARTY_ADVERTISING'] }, PRODUCT_INTERACTION: { collected: true, linked: false, purposes: ['ANALYTICS'] }, CRASH_DATA: { collected: true, linked: false, tracking: false, purposes: ['APP_FUNCTIONALITY'] }, GAMEPLAY_CONTENT: { collected: true, linked: true, tracking: false, purposes: ['APP_FUNCTIONALITY'] }, NAME: { collected: false }, }; test('--propose lists every collected type with linked, tracking and purposes; NSPrivacyTracking stays false without domains', () => { const m = proposeAppManifest({ types, collected: [] }) as { NSPrivacyTracking: boolean; NSPrivacyCollectedDataTypes: Record[] }; expect(m.NSPrivacyTracking).toBe(false); expect(m.NSPrivacyCollectedDataTypes.map(e => e.NSPrivacyCollectedDataType)).toEqual(['NSPrivacyCollectedDataTypeGameplayContent', 'NSPrivacyCollectedDataTypeUserID', 'NSPrivacyCollectedDataTypeDeviceID', 'NSPrivacyCollectedDataTypeProductInteraction', 'NSPrivacyCollectedDataTypeCrashData']); expect(m.NSPrivacyCollectedDataTypes.find(e => e.NSPrivacyCollectedDataType === 'NSPrivacyCollectedDataTypeDeviceID')).toEqual({ NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeDeviceID', NSPrivacyCollectedDataTypeLinked: false, NSPrivacyCollectedDataTypeTracking: true, NSPrivacyCollectedDataTypePurposes: ['NSPrivacyCollectedDataTypePurposeAppFunctionality', 'NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising'] }); expect((proposeAppManifest({ types, trackingDomains: ['t.example.com'] }) as { NSPrivacyTracking: boolean }).NSPrivacyTracking).toBe(true); // no types: the manifest's own entries stay expect((proposeAppManifest({ collected: [{ NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeName' }] }) as { NSPrivacyCollectedDataTypes: unknown[] }).NSPrivacyCollectedDataTypes.length).toBe(1); }); test('notes: unanswered fields and manifest entries that appPrivacy.types does not mark collected', () => { const { notes } = collectedFromAppPrivacy(types, [{ NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeName' }]); expect(notes).toContain('appPrivacy.types.PRODUCT_INTERACTION.tracking is not answered: wrote false'); expect(notes.some(n => n.startsWith('NSPrivacyCollectedDataTypeName was in the manifest'))).toBe(true); }); test('check: an empty NSPrivacyCollectedDataTypes against collected types is one warning naming --propose', () => { const out = appPrivacyMismatches(types, { attString: true, manifest: { path: 'ios/App/App/PrivacyInfo.xcprivacy', tracking: false, collectedTypes: [] }, tracking: { trackerSdks: ['Example Ads'] } }); const w = out.filter(x => x.msg.includes('empty NSPrivacyCollectedDataTypes')); expect(w).toHaveLength(1); expect(w[0]!.level).toBe('warn'); expect(w[0]!.msg).toContain('--propose'); expect(w[0]!.msg).toContain('USER_ID, DEVICE_ID, PRODUCT_INTERACTION, CRASH_DATA, GAMEPLAY_CONTENT'); }); test('the CLI proposes the fixture manifest from the config', () => { const r = Bun.spawnSync(['bun', join(ROOT, 'cli', 'index.ts'), 'privacy-manifest', '--ios', 'ios/App', '--propose'], { cwd: FX, env: { ...process.env, STOREBOUND_PROFILE: '' } }); const out = r.stdout.toString(); expect(r.exitCode).toBe(0); for (const k of ['UserID', 'DeviceID', 'GameplayContent', 'ProductInteraction', 'CrashData']) expect(out).toContain(`NSPrivacyCollectedDataType${k}`); expect(out).toMatch(/NSPrivacyTracking<\/key>\s*/); }); }); const none: ProjectSignals = { adSdks: [], attributionSdks: [], adIdSdks: [], adIdPermission: null, adIdRemoved: null, support: null, ar: null }; describe('App Privacy answer sheet: appPrivacy.types is authoritative', () => { const cfg = async () => (await runScan(FX)).config!; test('unlisted types are not collected (certain), never derived from dataSafety (PSL_OTHER_APP_ACTIVITY) or asked', async () => { const a = consoleAnswers('apple-app-privacy', await cfg(), none); const types = a.filter(x => x.id.startsWith('type:')); expect(types.filter(x => !x.certain)).toEqual([]); expect(a.find(x => x.id === 'type:Other Usage Data')).toMatchObject({ value: false, certain: true, source: 'appPrivacy.types (not listed: not collected)' }); expect(types.filter(x => x.value === true).map(x => x.id).sort()).toEqual(['type:Crash Data', 'type:Device ID', 'type:Gameplay Content', 'type:Product Interaction', 'type:User ID']); // the same without project signals: authoritative does not need a scan expect(consoleAnswers('apple-app-privacy', await cfg()).filter(x => x.id.startsWith('type:') && !x.certain)).toEqual([]); }); test('without appPrivacy.types: tracking false and no ad SDK default the ad, support and AR types to not collected, unless a signal says otherwise', async () => { const c = { ...(await cfg()), appPrivacy: { tracking: false } } as ShipConfig; const val = (s: ProjectSignals | undefined, t: string) => consoleAnswers('apple-app-privacy', c, s).find(x => x.id === `type:${t}`)!; for (const t of ['Advertising Data', 'Customer Support', 'Environment Scanning', 'Hands', 'Head']) expect(val(none, t)).toMatchObject({ value: false, certain: true }); expect(val(undefined, 'Advertising Data').certain).toBe(false); expect(val({ ...none, ar: 'src/ar.swift:3' }, 'Hands').certain).toBe(false); expect(val({ ...none, ar: 'src/ar.swift:3' }, 'Advertising Data').value).toBe(false); expect(val({ ...none, support: 'package.json:9' }, 'Customer Support').certain).toBe(false); expect(val({ ...none, adSdks: ['Google AdMob'] }, 'Advertising Data').certain).toBe(false); expect(consoleAnswers('apple-app-privacy', { ...c, appPrivacy: { tracking: true } } as ShipConfig, none).find(x => x.id === 'type:Advertising Data')!.certain).toBe(false); // dataSafety still answers the types it maps (Other Usage Data from PSL_OTHER_APP_ACTIVITY) when there are no types expect(val(none, 'Other Usage Data')).toMatchObject({ value: true, certain: true }); }); }); describe('Play "Does your app use advertising ID?" from real signals', () => { const c = { app: { name: 'Example Farm', profile: 'game' }, play: { packageName: 'org.example.farmgame' }, languages: [], dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { shared: false, collect: ['functionality'] } } } } as unknown as ShipConfig; test('a Device ID in Data safety alone is No (certain) after a scan', () => { expect(advertisingIdUse(c, none)).toMatchObject({ v: false }); const p = consolePlan('play-advertising-id', c, { signals: none }); expect(p.answers.find(a => a.id === 'uses')).toMatchObject({ value: false, status: 'certain', label: 'No' }); expect(p.answers.filter(a => a.id.startsWith('purpose'))).toEqual([]); }); test('ad / attribution SDKs, the AD_ID permission, app-ads.txt or the flag say Yes; disagreeing signals ask', () => { expect(advertisingIdUse(c, { ...none, adSdks: ['Google AdMob'] }).v).toBe(true); expect(advertisingIdUse(c, { ...none, attributionSdks: ['Adjust'] }).v).toBe(true); expect(advertisingIdUse(c, { ...none, adIdPermission: 'android/app/src/main/AndroidManifest.xml:3' }).v).toBe(true); expect(advertisingIdUse({ ...c, console: { appContent: { advertisingId: true } } } as ShipConfig, none).v).toBe(true); expect(advertisingIdUse({ ...c, console: { appContent: { advertisingId: false } } } as ShipConfig, none).v).toBe(false); const clash = advertisingIdUse({ ...c, console: { appContent: { advertisingId: false } } } as ShipConfig, { ...none, adSdks: ['Google AdMob'] }); expect(clash.v).toBeNull(); expect(clash.note).toContain('disagree'); expect(advertisingIdUse(c, { ...none, adSdks: ['Google AdMob'], adIdRemoved: 'android/app/src/main/AndroidManifest.xml:4' }).v).toBeNull(); expect(consolePlan('play-advertising-id', { ...c, console: { appContent: { advertisingId: false } } } as ShipConfig, { signals: { ...none, adSdks: ['Google AdMob'] } }).answers.find(a => a.id === 'uses')!.status).toBe('ask-owner'); // not scanned: ask, as before expect(advertisingIdUse(c, undefined).v).toBeNull(); }); test('project signals read the AD_ID permission and its removal from the manifests', () => { expect(projectSignals(detectProject(FX))).toMatchObject({ adSdks: [], adIdPermission: null, adIdRemoved: null, ar: null, support: null }); const withPerm = variant('ad-id', { 'android/app/src/main/AndroidManifest.xml': '\n\n \n\n' }); expect(projectSignals(detectProject(withPerm)).adIdPermission).toBe('android/app/src/main/AndroidManifest.xml:3'); const removed = variant('ad-id-removed', { 'android/app/src/main/AndroidManifest.xml': '\n\n \n\n' }); expect(projectSignals(detectProject(removed)).adIdRemoved).toBe('android/app/src/main/AndroidManifest.xml:3'); }); }); describe('credentials: template placeholders are missing, never credentials', () => { test('the template has only empty values', () => { const t = JSON.parse(readFileSync(join(ROOT, 'templates', 'credentials.example.json'), 'utf8')) as Record; const strings = (v: unknown): string[] => typeof v === 'string' ? [v] : v && typeof v === 'object' ? Object.values(v).flatMap(strings) : []; expect(strings(t).filter(Boolean)).toEqual([]); }); test('placeholder-looking values', () => { for (const v of ['YOUR_KEY_ID', 'your-key', 'sk_your_revenuecat_v2_secret_key', '/absolute/path/to/upload.p12', '/absolute/path/to/AuthKey_.p8', '', 'xxx', 'XXXX-XXXX', 'SEARCHADS.00000000-0000-0000-0000-000000000000', 'example', 'key-example', 'review-contact@example.com']) expect({ v, p: isPlaceholder(v) }).toEqual({ v, p: true }); for (const v of ['ABCDE12345', '00000000-0000-0000-0000-000000000000', 'sk_test_placeholder_secret_value', '/Users/me/keys/upload.p12', 'upload', '-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----', '{"client_email":"x@example.com"}', '']) expect({ v, p: isPlaceholder(v) }).toEqual({ v, p: false }); expect(isPlaceholder('demo@example.com', { example: false })).toBe(false); }); test('the 0.7.1 template in .storebound/credentials.json counts as missing', () => { const d = join(tmp, 'creds'); mkdirSync(join(d, '.storebound'), { recursive: true }); writeFileSync(join(d, '.storebound', 'credentials.json'), JSON.stringify({ PLAY_SERVICE_ACCOUNT: '/absolute/path/to/play-service-account.json', ASC_KEY_ID: 'YOUR_KEY_ID', ASC_P8: '/absolute/path/to/AuthKey_.p8', REVENUECAT_SECRET_KEY: 'sk_your_revenuecat_v2_secret_key', ANDROID_KEYSTORE: '/absolute/path/to/upload.p12', ANDROID_KEY_ALIAS: 'upload', ASC_VENDOR_NUMBER: '', })); const keep = Object.fromEntries(['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_P8', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEY_ALIAS', 'STOREBOUND_PROFILE'].map(k => [k, process.env[k]])); for (const k of Object.keys(keep)) delete process.env[k]; try { for (const k of ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_P8', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE']) expect({ k, v: credentialValue(d, k) }).toEqual({ k, v: undefined }); expect(credentialValue(d, 'ANDROID_KEY_ALIAS')).toBe('upload'); const st = Object.fromEntries(credentialStatus(d).map(s => [s.key, s.status])); expect(st).toMatchObject({ PLAY_SERVICE_ACCOUNT: 'placeholder', ASC_KEY_ID: 'placeholder', REVENUECAT_SECRET_KEY: 'placeholder', ANDROID_KEY_ALIAS: 'set', ASC_VENDOR_NUMBER: 'missing', ASC_ISSUER_ID: 'missing' }); } finally { for (const [k, v] of Object.entries(keep)) if (v !== undefined) process.env[k] = v; } }); test('check warns about placeholders, the empty manifest list and an unused scan.ignore entry', () => { const d = variant('check', { '.storebound/credentials.json': JSON.stringify({ ASC_KEY_ID: 'YOUR_KEY_ID', REVENUECAT_SECRET_KEY: 'sk_your_revenuecat_v2_secret_key' }) }); const env = { ...process.env }; for (const k of ['ASC_KEY_ID', 'REVENUECAT_SECRET_KEY', 'STOREBOUND_PROFILE']) delete env[k]; const r = Bun.spawnSync(['bun', join(ROOT, 'cli', 'index.ts'), 'check'], { cwd: d, env }); const err = r.stderr.toString() + r.stdout.toString(); expect(err).toContain('credentials: ASC_KEY_ID, REVENUECAT_SECRET_KEY still hold template placeholders'); expect(err).toContain('has an empty NSPrivacyCollectedDataTypes'); expect(err).toContain('scan.ignore[0] (rule privacy-labels'); }); });