merge origin/main (cross-store text checker); macOS platform pitfall renumbered to 79
This commit is contained in:
commit
9db7180eb9
35 changed files with 618 additions and 60 deletions
|
|
@ -167,11 +167,11 @@ describe('write gating', () => {
|
|||
const w = { ...base, kind: 'write' as const, method: 'PATCH' };
|
||||
expect(decide(w)).toMatchObject({ action: 'dry', why: expect.stringContaining('--yes') });
|
||||
expect(decide({ ...w, yes: true, dry: true }).action).toBe('dry');
|
||||
expect(decide({ ...w, yes: true, tty: false })).toMatchObject({ action: 'dry', why: expect.stringContaining('not an interactive terminal') });
|
||||
expect(decide({ ...w, yes: true, tty: false })).toMatchObject({ action: 'refuse', why: expect.stringContaining('need an interactive terminal') });
|
||||
expect(decide({ ...w, yes: true })).toEqual({ action: 'send' });
|
||||
const o = { ...w, kind: 'owner' as const };
|
||||
expect(decide({ ...o, yes: true })).toEqual({ action: 'confirm', typed: true });
|
||||
expect(decide({ ...o, yes: true, tty: false }).action).toBe('dry');
|
||||
expect(decide({ ...o, yes: true, tty: false }).action).toBe('refuse');
|
||||
// MCP / desktop: never a write, whatever the flags
|
||||
expect(decide({ ...w, yes: true, agent: true }).action).toBe('refuse');
|
||||
expect(decide({ ...o, yes: true, agent: true })).toMatchObject({ action: 'refuse', why: expect.stringContaining('never through MCP') });
|
||||
|
|
@ -222,10 +222,16 @@ describe('the CLI never sends a write it should not', () => {
|
|||
expect(r.code).toBe(0);
|
||||
expect(JSON.parse(r.out)).toMatchObject({ dryRun: true, gate: 'write', request: { method: 'PUT', url: 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.app/edits/1/tracks/beta' } });
|
||||
});
|
||||
test('--yes outside a terminal still only prints', () => {
|
||||
test('--yes outside a terminal (piped / redirected) is refused loudly: exit 1, stderr, nothing sent', () => {
|
||||
const r = cli(['apple', 'DELETE', '/v1/appScreenshots/1', '--yes']);
|
||||
expect(r.code).not.toBe(0);
|
||||
expect(r.err).toContain('refused: writes through `storebound api` need an interactive terminal');
|
||||
expect(r.err).toContain('Nothing was sent');
|
||||
expect(r.out).not.toContain('dry run');
|
||||
});
|
||||
test('without --yes, a piped write is still a plain dry run (exit 0)', () => {
|
||||
const r = cli(['apple', 'DELETE', '/v1/appScreenshots/1']);
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toContain('not an interactive terminal');
|
||||
expect(r.out).toContain('owner only: this deletes');
|
||||
});
|
||||
test('under MCP and in api get, writes are refused', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// One app record, two App Store platforms (pitfall 77). Seen live: `apple resubmit` said "nothing to resubmit" while
|
||||
// One app record, two App Store platforms (pitfall 79). Seen live: `apple resubmit` said "nothing to resubmit" while
|
||||
// the macOS version was REJECTED in an UNRESOLVED_ISSUES submission, and `apple listing --version 0.8.7` refused
|
||||
// because the iOS 0.8.7 was live while the macOS 0.8.7 was rejected. Against the fake App Store Connect.
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
|
|
|||
171
tests/cross-store.test.ts
Normal file
171
tests/cross-store.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// 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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -140,8 +140,8 @@ describe('scope and leftovers', () => {
|
|||
describe('storebound api writes outside a terminal', () => {
|
||||
test('the dry reason says TTY by design and the dedicated command is named', () => {
|
||||
const d = decide({ kind: 'write', method: 'POST', dry: false, yes: true, getOnly: false, tty: false, agent: false });
|
||||
expect(d).toMatchObject({ action: 'dry' });
|
||||
expect((d as { why: string }).why).toContain('by design');
|
||||
expect(d).toMatchObject({ action: 'refuse' });
|
||||
expect((d as { why: string }).why).toContain('Nothing was sent');
|
||||
expect(dedicatedCommand('apple', 'bundleIdCapabilities_createInstance')).toContain('apple signing ensure');
|
||||
expect(dedicatedCommand('apple', 'apps_getInstance')).toBeNull();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue