171 lines
11 KiB
TypeScript
171 lines
11 KiB
TypeScript
// 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: en-US -->
|
|
|
|
### 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<!-- play: en-US -->\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' });
|
|
});
|
|
});
|