// Pitfall 77: other platforms or stores named in one store's text (App Store 2.3.10, Play Metadata policy). import { describe, expect, test } from 'bun:test'; import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileMentions, notesForStore, refuseMentions, textMentions, type PlatformAllow } from '../cli/lib/cross-store'; import { appleLocaleIssues, playLangIssues } from '../cli/lib/listing-rules'; import { setPlan } from '../cli/listing-store'; import { getByPath, parseOps, planEdits, type EditSources } from '../cli/lib/edit'; import { applyLanguageNotes, checkLanguageNotes, draftForStore, languageNoteMentions } from '../cli/lib/whats-new'; import { runScan } from '../cli/lib/scan'; const APPLE_MD = `# App Store ## en-US · English ### description \`\`\` A calm puzzle game. \`\`\` ### whatsNew \`\`\` New levels. Achievements on Google Play Games. Fixed a crash on Android 14. \`\`\` ## de-DE · Deutsch ### whatsNew \`\`\` Neue Level. Erfolge in Google Play Spiele. \`\`\` `; const PLAY_MD = `# Listing ## English ### Play short description (max 80) \`\`\` A calm puzzle game \`\`\` ### Play full description (max 4000) \`\`\` Play offline. Also on iPhone and iPad. \`\`\` `; describe('the checker: per locale, per line', () => { test('App Store text: the exact term, field, locale, line and file line; longest term wins', () => { const hits = fileMentions('apple', 'store/appstore.md', APPLE_MD); expect(hits.map(h => [h.locale, h.field, h.line, h.term, h.fileLine])).toEqual([ ['en-US', 'whatsNew', 2, 'Google Play Games', 13], ['en-US', 'whatsNew', 3, 'Android', 14], ['de-DE', 'whatsNew', 2, 'Google Play Spiele', 22], ]); expect(hits[0]!.fix).toContain('[play]'); }); test('Play text: iPhone / iPad with the Play field name', () => { const hits = fileMentions('play', 'store/listing.md', PLAY_MD); expect(hits.map(h => [h.locale, h.field, h.line, h.term])).toEqual([['en-US', 'full', 2, 'iPhone'], ['en-US', 'full', 2, 'iPad']]); }); test('whole words only; localized and CJK forms; tags for the other store', () => { expect(textMentions('Androids are robots. androidal', { store: 'apple', locale: 'x', field: 'description' })).toEqual([]); expect(textMentions('Full steam ahead', { store: 'apple', locale: 'x', field: 'description' })).toEqual([]); expect(textMentions('Jetzt auf Steam', { store: 'apple', locale: 'x', field: 'description' }).map(h => h.term)).toEqual(['Steam']); expect(textMentions('支持安卓', { store: 'apple', locale: 'zh-Hans', field: 'description' }).map(h => h.term)).toEqual(['安卓']); expect(textMentions('[play] Cloud saves', { store: 'apple', locale: 'x', field: 'whatsNew' }).map(h => h.term)).toEqual(['[play]']); expect(textMentions('a fair app store', { store: 'play', locale: 'x', field: 'full' })).toEqual([]); expect(textMentions('Syncs with Game Center and TestFlight builds on iOS', { store: 'play', locale: 'x', field: 'full' }).map(h => h.term)).toEqual(['Game Center', 'TestFlight', 'iOS']); }); }); describe('the allowlist: only the owner\'s entries, matched per line', () => { const allow: PlatformAllow[] = [{ store: 'play', text: 'Sign in with Apple', reason: 'the sign-in button' }, { store: 'apple', text: 'Continue with Google Play Games', reason: 'cross-progress button' }]; test('an allowed line keeps its terms; other lines and the other store still hit', () => { expect(textMentions('Tap Sign in with Apple to start', { store: 'play', locale: 'en-US', field: 'full', allow })).toEqual([]); expect(textMentions('Also on iPhone', { store: 'play', locale: 'en-US', field: 'full', allow }).length).toBe(1); expect(textMentions('Sign in with Apple', { store: 'apple', locale: 'en-US', field: 'description', allow })).toEqual([]); expect(textMentions('Continue with Google Play Games', { store: 'apple', locale: 'en-US', field: 'description', allow })).toEqual([]); expect(textMentions('Continue with Google Play Games on Android', { store: 'apple', locale: 'en-US', field: 'description', allow }).map(h => h.term)).toEqual(['Android']); }); }); describe('writes refuse for both stores', () => { test('listing rules: an error for each store (the desktop editor and storebound edit stop on it)', () => { const a = appleLocaleIssues('en-US', { subtitle: 's', promotionalText: 'p', keywords: 'puzzle', description: 'd', whatsNew: 'Now on Google Play' }, 'Game'); expect(a.find(i => i.field === 'whatsNew')).toMatchObject({ level: 'error', msg: expect.stringContaining('2.3.10') }); const p = playLangIssues('en-US', { short: 'A game', full: 'Play it on your iPad', whatsNew: 'Fixed TestFlight builds' }, 'Game'); expect(p.filter(i => i.level === 'error').map(i => i.field).sort()).toEqual(['full', 'whatsNew']); expect(p.find(i => i.field === 'full' && i.level === 'error')!.msg).toContain('Play: Metadata'); expect(appleLocaleIssues('en-US', { subtitle: 's', promotionalText: 'p', keywords: 'puzzle', description: 'Sign in with Google', whatsNew: 'x' }, 'Game', [{ store: 'apple', text: 'Sign in with Google', reason: 'button' }]).some(i => i.level === 'error')).toBe(false); }); test('store-only listing edit (listing set): both stores refused on the fields it sends', () => { const ap = setPlan('apple', 'en-US', { whatsNew: 'old' }, { whatsNew: 'Now with Android cross-play' }, { appName: 'Game', editable: true }); expect(ap.issues.some(i => i.level === 'error' && i.field === 'whatsNew')).toBe(true); const pl = setPlan('play', 'en-US', { title: 'Game', short: 'Old', full: 'Full.' }, { short: 'The best iOS puzzle' }, { appName: 'Game' }); expect(pl.issues.some(i => i.level === 'error' && i.field === 'short')).toBe(true); const ok = setPlan('play', 'en-US', { title: 'Game', short: 'Old', full: 'Full.' }, { short: 'Sign in with Apple supported' }, { appName: 'Game', allow: [{ store: 'play', text: 'Sign in with Apple', reason: 'button' }] }); expect(ok.issues.some(i => i.level === 'error')).toBe(false); }); test('the desktop editor write path (storebound edit) counts them as errors', () => { const src: EditSources = { config: { file: '/x/storebound.config.ts', rel: 'storebound.config.ts', text: 'export default {}\n' }, play: { file: '/x/store/listing.md', rel: 'store/listing.md', text: PLAY_MD.replace('Also on iPhone and iPad.\n', '') }, apple: { file: '/x/store/appstore.md', rel: 'store/appstore.md', text: APPLE_MD }, contact: { file: '/x/.storebound/credentials.json', rel: '.storebound/credentials.json', text: '{}' }, appName: 'Game', appleName: 'Game', }; const apple = planEdits(parseOps(JSON.stringify([{ kind: 'listing', store: 'apple', lang: 'en-US', field: 'description', value: 'Get it on Google Play too.' }])), src, p => getByPath({}, p)); expect(apple.plan.issues.some(i => i.level === 'error' && i.msg.includes('Google Play'))).toBe(true); const play = planEdits(parseOps(JSON.stringify([{ kind: 'listing', store: 'play', lang: 'en-US', field: 'full', value: 'Best on iPhone.' }])), src, p => getByPath({}, p)); expect(play.plan.errors).toBeGreaterThan(0); }); test('refuseMentions throws one message with every hit, nothing sent', () => { expect(() => refuseMentions(textMentions('On Android', { store: 'apple', locale: 'en-US', field: 'whatsNew' }), 'apple listing')) .toThrow(/refused: apple listing: .*App Store text \(2\.3\.10\); nothing was sent\n.*en-US whatsNew line 1: "Android"/); expect(() => refuseMentions([], 'x')).not.toThrow(); }); }); describe('whats-new: one shared text, per-store notes', () => { const shared = 'New levels.\n• [play] Achievements on Google Play Games.\n• [apple] Game Center leaderboards.\n[android] Fixed a crash on Android 14.\n\n[play]\nPlay-only block line.\n\nShared again.'; test('tagged lines split per store, tags removed; a [play] heading line starts a block until a blank line', () => { expect(notesForStore(shared, 'apple')).toBe('New levels.\n• Game Center leaderboards.\n\nShared again.'); expect(notesForStore(shared, 'play')).toBe('New levels.\n• Achievements on Google Play Games.\nFixed a crash on Android 14.\n\nPlay-only block line.\n\nShared again.'); }); test('the draft leaves untagged other-platform lines out of the wrong store and says which', () => { const d = draftForStore('New levels.\nAchievements on Google Play Games.', 'apple'); expect(d).toEqual({ text: 'New levels.', dropped: ['Achievements on Google Play Games.'] }); expect(draftForStore('New levels.\nAchievements on Google Play Games.', 'play').dropped).toEqual([]); }); test('apply writes each store its own lines; tagged notes pass the cross-store check', () => { const l = { id: 'en', apple: 'en-US', play: 'en-US' }; const md = { apple: '## en-US\n\n### whatsNew\n```\nold\n```\n', play: '## English\n\n\n### What\'s new (max 500)\n```\nold\n```\n' }; const r = applyLanguageNotes(md, l, shared); expect(r.apple).toContain('• Game Center leaderboards.'); expect(r.apple).not.toContain('Google Play'); expect(r.play).toContain('Achievements on Google Play Games.'); expect(r.play).not.toContain('Game Center'); expect(languageNoteMentions(l, shared)).toEqual([]); const untagged = languageNoteMentions(l, 'New levels.\nAchievements on Google Play Games.'); expect(untagged.map(h => [h.store, h.locale, h.line, h.term])).toEqual([['apple', 'en-US', 2, 'Google Play Games']]); // each store's limit counts its own notes expect(checkLanguageNotes(l, { lang: 'en', source: 'en', status: 'source', text: shared }).problems).toEqual([]); }); }); describe('scan: guideline ids per store', () => { test('App Store 2.3.10 and Play: Metadata, with file and line', async () => { const dir = mkdtempSync(join(tmpdir(), 'sb-cross-')); try { cpSync(join(import.meta.dir, 'fixtures', 'scan', 'android'), dir, { recursive: true }); mkdirSync(join(dir, 'store'), { recursive: true }); writeFileSync(join(dir, 'store', 'listing.md'), PLAY_MD); const r = (await runScan(dir)).report; const f = r.findings.find(x => x.rule === 'play-other-platforms')!; expect(f).toMatchObject({ store: 'play', guideline: 'Play: Metadata' }); expect(f.evidence[0]).toMatchObject({ file: 'store/listing.md', line: 14 }); } finally { rmSync(dir, { recursive: true, force: true }); } const ios = (await runScan(join(import.meta.dir, 'fixtures', 'scan', 'ios-native'))).report; expect(ios.findings.find(x => x.rule === 'other-platforms')).toMatchObject({ store: 'apple', guideline: '2.3.10' }); }); });