import { describe, expect, test } from 'bun:test'; import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { generate, parseSkill, readSkills } from '../tools/gen-adapters'; import { scan } from '../tools/scrub'; import { FORBIDDEN_ARGS, listTools, LOCAL_WRITE_TOOLS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools'; import { handle, LEGACY_PROTOCOL as PROTOCOL } from '../cli/mcp'; import { auditAppleKeywords, auditAso, auditPlayListing, crossLocaleHint } from '../cli/lib/aso'; import { checkNotes, formatNotes, noteItems, setAppleWhatsNew, setPlayWhatsNew } from '../cli/lib/whats-new'; import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy'; import { evaluatePrivacyManifest, parseManifest, scanSources } from '../cli/lib/privacy-manifest'; import { type AppleSnapshot, appleRules, normAdsLine, type PlaySnapshot, playRules } from '../cli/lib/validate-rules'; import { classifyReviewState, compareBuild, nextBuildNumber, nextPollDelay, nextVersionCode, parseDuration, phasedRequest, rolloutReleases } from '../cli/lib/release'; import { AGENTS, mergeCodexToml, mergeJsonServer } from '../cli/lib/agents'; import { mcpCommand, run as install } from '../cli/install'; import { parseArgs } from '../cli/lib/args'; import { rolloutAction } from '../cli/play/rollout'; import { validateConfig } from '../cli/lib/config'; const ROOT = join(import.meta.dir, '..'); const T = join(ROOT, 'templates'); const tmp = () => mkdtempSync(join(tmpdir(), 'storebound-')); describe('adapters', () => { test('generation is deterministic and the committed files are fresh', () => { const a = generate(ROOT); const b = generate(ROOT); expect([...a]).toEqual([...b]); for (const [rel, text] of a) expect({ rel, fresh: existsSync(join(ROOT, rel)) && readFileSync(join(ROOT, rel), 'utf8') === text }).toEqual({ rel, fresh: true }); }); test('every adapter lists every skill and the manifests are valid JSON with the package version', () => { const out = generate(ROOT); const skills = readSkills(ROOT); expect(skills.length).toBeGreaterThanOrEqual(16); for (const f of ['AGENTS.md', 'GEMINI.md', '.github/copilot-instructions.md', '.cursor/rules/storebound.mdc']) for (const s of skills) expect(out.get(f)!).toContain(s.name); const version = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version; for (const f of ['.claude-plugin/plugin.json', '.codex-plugin/plugin.json', 'gemini-extension.json']) expect(JSON.parse(out.get(f)!).version).toBe(version); expect(JSON.parse(out.get('.codex-plugin/plugin.json')!).skills).toBe('./skills/'); expect(JSON.parse(out.get('.mcp.json')!).mcpServers.storebound).toEqual({ command: '${CLAUDE_PLUGIN_ROOT}/bin/storebound', args: ['mcp'] }); expect(JSON.parse(out.get('gemini-extension.json')!).mcpServers.storebound.command).toContain('${extensionPath}'); expect(out.get('.cursor/rules/storebound.mdc')!).toMatch(/^---\ndescription: .+\nalwaysApply: false\n---\n/); }); test('skill frontmatter rules', () => { expect(parseSkill('a-b', '---\nname: a-b\ndescription: x\n---\n')).toEqual({ name: 'a-b', description: 'x' }); expect(() => parseSkill('a-b', '---\nname: a-c\ndescription: x\n---\n')).toThrow('folder'); expect(() => parseSkill('Ab', '---\nname: Ab\ndescription: x\n---\n')).toThrow('lowercase'); expect(() => parseSkill('a', '---\nname: a\ndescription: x\nlicense: MIT\n---\n')).toThrow('only name + description'); expect(() => parseSkill('a', '---\nname: a\ndescription: \n---\n')).toThrow('1-1024'); }); }); describe('mcp tools', () => { test('every write tool has dry_run defaulting to true and builds --dry', () => { for (const t of TOOLS.filter(x => x.write)) { const schema = listTools().find(x => x.name === t.name)!.inputSchema as { properties: Record }; expect(schema.properties.dry_run!.default).toBe(true); const req = Object.fromEntries((t.required ?? []).map(r => [r, r === 'version' ? '1.0.0' : r === 'action' ? 'on' : 'x'])); expect(toArgv(t, req).argv).toContain('--dry'); expect(toArgv(t, { ...req, dry_run: false }).argv).not.toContain('--dry'); } }); test('annotations match the kind of tool', () => { for (const t of listTools()) { const a = t.annotations as Record; const def = TOOLS.find(x => x.name === t.name)!; expect(a.readOnlyHint).toBe(!def.write); if (!def.write) expect(a.destructiveHint).toBe(false); expect(typeof a.openWorldHint).toBe('boolean'); expect((t.inputSchema as { additionalProperties: boolean }).additionalProperties).toBe(false); expect(t.name).toMatch(/^[a-z0-9_]{1,64}$/); } }); test('nothing public or irreversible is reachable', () => { const names = TOOLS.map(t => t.name); for (const bad of ['apple_submit', 'apple_release', 'apple_manual_release', 'play_rollout', 'apple_cancel_submission', 'play_release']) expect(names).not.toContain(bad); for (const t of TOOLS) { const enums = Object.values(t.props ?? {}).flatMap(p => p.enum ?? []); for (const combo of [{}, ...enums.map(e => Object.fromEntries(Object.entries(t.props ?? {}).filter(([, p]) => p.enum?.includes(e)).map(([k]) => [k, e])))]) { const input: Record = { ...Object.fromEntries((t.required ?? []).map(r => [r, r === 'action' ? 'on' : r === 'version' ? '1.0.0' : 'x'])), ...combo, dry_run: false }; const argv = toArgv(t, input).argv; for (const f of FORBIDDEN_ARGS.filter(x => !LOCAL_WRITE_TOOLS[t.name]?.includes(x))) expect({ tool: t.name, argv, hit: argv.includes(f) }).toEqual({ tool: t.name, argv, hit: false }); } } // the local-write exception covers only the pull tools, and only with dry_run false expect(Object.keys(LOCAL_WRITE_TOOLS).sort()).toEqual(['apple_listing_pull', 'art_pull', 'catalog_pull', 'onboard', 'play_listing_pull']); for (const n of Object.keys(LOCAL_WRITE_TOOLS)) expect(toArgv(TOOLS.find(t => t.name === n)!, {}).argv).not.toContain('--write'); const phased = TOOLS.find(t => t.name === 'apple_phased_release')!; expect(phased.props!.action!.enum).toEqual(['on', 'pause']); expect(TOOLS.find(t => t.name === 'play_release_testing')!.props!.track!.enum).not.toContain('production'); }); test('input validation', () => { const listing = TOOLS.find(t => t.name === 'apple_listing')!; expect(validateInput(listing, {})).toEqual(['version: required']); expect(validateInput(listing, { version: '1.0.0', nope: 1 })).toEqual(['nope: unknown argument']); expect(validateInput(listing, { version: 'abc' })[0]).toContain('does not match'); expect(validateInput(listing, { version: '1.0', build: '--yes' })[0]).toContain('must not start with "-"'); expect(validateInput(listing, { version: '1.0', dry_run: 'no' })).toEqual(['dry_run: must be boolean']); }); test('json-rpc handling', async () => { const init = await handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25' } }); expect((init!.result as { protocolVersion: string }).protocolVersion).toBe(PROTOCOL); const old = await handle({ jsonrpc: '2.0', id: 2, method: 'initialize', params: { protocolVersion: '1999-01-01' } }); expect((old!.result as { protocolVersion: string }).protocolVersion).toBe(PROTOCOL); expect(await handle({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBeNull(); expect((await handle({ jsonrpc: '2.0', id: 3, method: 'ping' }))!.result).toEqual({}); expect(((await handle({ jsonrpc: '2.0', id: 4, method: 'nope' }))!.error as { code: number }).code).toBe(-32601); expect(((await handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'apple_submit' } }))!.error as { code: number }).code).toBe(-32602); let seen: string[] = []; const fake = async (argv: string[]) => { seen = argv; return { code: 0, out: 'ok' }; }; const r = await handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'play_listing', arguments: { only: 'en-US' } } }, fake); expect(seen).toEqual(['play', 'listing', '--only', 'en-US', '--dry']); const res = r!.result as { isError: boolean; structuredContent: { dryRun: boolean; exitCode: number } }; expect(res.isError).toBe(false); expect(res.structuredContent).toMatchObject({ dryRun: true, exitCode: 0 }); const bad = await handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'play_listing', arguments: { only: 1 } } }, fake); expect((bad!.result as { isError: boolean }).isError).toBe(true); }); }); describe('mcp: screenshot and video tools', () => { const tool = (n: string) => TOOLS.find(t => t.name === n)!; test('kinds: check is read-only, local writes are dry by default, previews is a network write', () => { expect(tool('shots_check')).toMatchObject({ write: false, network: false }); for (const n of ['shots_capture', 'shots_render', 'shots_gallery', 'shots_record', 'shots_video']) { expect(tool(n)).toMatchObject({ write: true, network: false }); expect(toArgv(tool(n), {}).argv.at(-1)).toBe('--dry'); } expect(tool('apple_previews')).toMatchObject({ write: true, network: true, destructive: true }); expect(toArgv(tool('apple_previews'), { only: 'en-US', poster: 3 }).argv).toEqual(['apple', 'previews', '--only', 'en-US', '--poster', '3', '--dry']); }); test('argv', () => { expect(toArgv(tool('shots_check'), { lang: 'en,tr', variants: true }).argv).toEqual(['shots', 'check', '--lang', 'en,tr', '--variants']); expect(toArgv(tool('shots_render'), { all_variants: true, variant: 'b', dry_run: false }).argv).toEqual(['shots', 'render', '--variants']); expect(toArgv(tool('shots_render'), { variant: 'b', size: 'ios69' }).argv).toEqual(['shots', 'render', '--size', 'ios69', '--variant', 'b', '--dry']); expect(toArgv(tool('shots_video'), { seconds: 20, captions: false, dry_run: false }).argv).toEqual(['shots', 'video', '--seconds', '20', '--no-captions']); expect(toArgv(tool('shots_capture'), { source: 'ios-sim', device: 'tablet' }).argv).toEqual(['shots', 'capture', '--device', 'tablet', '--source', 'ios-sim', '--dry']); expect(toArgv(tool('play_images'), { variant: 'b' }).argv).toEqual(['play', 'images', '--variant', 'b', '--dry']); }); test('input checks', () => { expect(validateInput(tool('apple_screenshots'), { variant: '../x' })[0]).toContain('does not match'); expect(validateInput(tool('shots_video'), { seconds: 45 })).toEqual(['seconds: <= 30']); expect(validateInput(tool('shots_video'), { seconds: 10 })).toEqual(['seconds: >= 15']); expect(validateInput(tool('shots_record'), { source: 'camera' })).toEqual(['source: one of web, ios-sim, android-emu, folder']); }); }); describe('aso', () => { test('App Store keywords count characters, not bytes (limit and fill hint; pitfall 43)', () => { const msgs = (kw: string) => auditAppleKeywords('pl', { keywords: kw } as never, 'X').map(f => f.msg); // live shape: a Polish field of 99 characters / 105 bytes, READY_FOR_SALE const pl = 'ryby,akwarium,ocean,morze,rekin,koralowiec,nurkowanie,relaks,kolekcja,hodowla,pływanie,łowienie,żółw'; expect([...pl].length).toBeLessThanOrEqual(100); expect(Buffer.byteLength(pl)).toBeGreaterThan(100); expect(msgs(pl).filter(m => /> 100/.test(m))).toEqual([]); expect(msgs('ryby,akwarium').some(m => m.includes('13/100 characters used') && m.includes('fill at least 90'))).toBe(true); expect(msgs('ż'.repeat(101)).some(m => m.includes('101 characters > 100'))).toBe(true); }); test('keyword field rules', () => { const f = auditAppleKeywords('en-US', { subtitle: 'Relaxing puzzle', keywords: 'match,puzzle,match,free game' }, 'Tile Match'); const text = f.map(x => `${x.level}:${x.msg}`).join('\n'); expect(text).toContain("warn:keywords: 28/100 characters used"); expect(text).toContain('"match" listed twice'); expect(text).toContain('repeat words from the name / subtitle (already indexed): match, puzzle'); expect(text).toContain('filler words that rarely help: free, game'); expect(auditAppleKeywords('en-US', { keywords: 'a, b' }, 'X').some(x => x.level === 'error' && /spaces/.test(x.msg))).toBe(true); expect(auditAppleKeywords('en-US', { keywords: 'x'.repeat(101) }, 'X').some(x => x.level === 'error')).toBe(true); expect(auditAppleKeywords('en-US', { keywords: Array.from({ length: 23 }, (_, i) => `w${String(i).padStart(2, '0')}`).join(',') }, 'Name').filter(x => x.level !== 'info')).toEqual([]); }); test('play stuffing and claims', () => { const full = Array.from({ length: 20 }, () => 'puzzle puzzle fun levels').join(' '); const f = auditPlayListing({ lang: 'en-US', title: 'BEST PUZZLE', short: 'The #1 puzzle 🎉', full, whatsNew: '' }); const t = f.map(x => x.msg).join('\n'); expect(t).toContain('"puzzle" 40x'); expect(t).toContain('"best", "#1"'); expect(t).toContain('all caps'); expect(t).toContain('emoji'); }); test('cross-locale hint and template listings', () => { expect(crossLocaleHint('US', ['en-US', 'es-MX'])!.msg).toContain('You fill en-US, es-MX'); expect(crossLocaleHint('ZZ', [])).toBeNull(); const apple = parseAppStoreListing(readFileSync(join(T, 'appstore.md'), 'utf8')); const play = parsePlayListing(readFileSync(join(T, 'listing.md'), 'utf8'), 'Example Game'); expect(auditAso({ apple, play, appName: 'Example Game' }).filter(x => x.level === 'error')).toEqual([]); }); }); describe('whats-new', () => { const subjects = ['feat: desert world with 20 levels (#41)', 'fix(ui): settings button overlapped the notch', 'chore: bump deps', 'Merge branch x', 'refactor: asset loader', 'perf: faster level loading', 'feat: desert world with 20 levels', 'docs: readme', '1.2.0', 'tweak shop layout [internal]']; test('items: housekeeping dropped, prefixes stripped, deduped, new first', () => { expect(noteItems(subjects)).toEqual([ { kind: 'new', text: 'Desert world with 20 levels.' }, { kind: 'better', text: 'Faster level loading.' }, { kind: 'fix', text: 'Fixed: settings button overlapped the notch.' }, ]); }); test('format within limits and checks', () => { const notes = formatNotes(noteItems(subjects), { limit: 500 }); expect(notes.split('\n')[0]).toBe('New in this update:'); expect(notes).toContain('• Desert world with 20 levels.'); expect(checkNotes(notes, 'play')).toEqual([]); const many = Array.from({ length: 60 }, (_, i) => ({ kind: 'fix' as const, text: `Fixed thing number ${i}.` })); expect([...formatNotes(many, { limit: 500 })].length).toBeLessThanOrEqual(500); expect(formatNotes([], { limit: 500 })).toBe('Bug fixes and performance improvements.'); expect(checkNotes('x'.repeat(501), 'play')[0]).toContain('501 > 500'); expect(checkNotes('bump version', 'apple')[0]).toBe('internal jargon (developer word): "bump"; instead: "update"'); }); test('writes into both listing files, keeping them parseable', () => { const play = readFileSync(join(T, 'listing.md'), 'utf8'); const r = setPlayWhatsNew(play, 'en-US', 'New: desert world!'); expect(r.changed).toEqual(['en-US']); const parsed = parsePlayListing(r.md, 'X'); expect(parsed.find(x => x.lang === 'en-US')!.whatsNew).toBe('New: desert world!'); expect(parsed.find(x => x.lang === 'tr-TR')!.whatsNew).toContain('İlk sürüm'); expect(setPlayWhatsNew(play, '*', 'A').changed).toEqual(['en-US', 'tr-TR']); const apple = readFileSync(join(T, 'appstore.md'), 'utf8'); const a1 = setAppleWhatsNew(apple, 'tr', 'Yeni dünya!'); expect(parseAppStoreListing(a1.md).tr!.whatsNew).toBe('Yeni dünya!'); const a2 = setAppleWhatsNew(a1.md, 'tr', 'İkinci'); expect(parseAppStoreListing(a2.md).tr!.whatsNew).toBe('İkinci'); expect(parseAppStoreListing(a2.md)['en-US']!.whatsNew).toBeUndefined(); expect(setAppleWhatsNew(apple, 'de-DE', 'x').changed).toEqual([]); }); }); describe('privacy manifest', () => { const xml = ` NSPrivacyTracking NSPrivacyTrackingDomains NSPrivacyAccessedAPITypes NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaults NSPrivacyAccessedAPITypeReasonsCA92.1 NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryDiskSpace NSPrivacyAccessedAPITypeReasonsZZZZ.1 `; test('parse', () => { const m = parseManifest('App/PrivacyInfo.xcprivacy', xml); expect(m.tracking).toBe(false); expect(m.apis).toEqual({ NSPrivacyAccessedAPICategoryUserDefaults: ['CA92.1'], NSPrivacyAccessedAPICategoryDiskSpace: ['ZZZZ.1'] }); }); test('evaluate', () => { const hits = scanSources([{ path: 'App/Save.swift', text: 'UserDefaults.standard.set(1, forKey: "x")' }, { path: 'App/T.swift', text: 'let t = ProcessInfo.processInfo.systemUptime' }]); const f = evaluatePrivacyManifest({ appManifest: parseManifest('p', xml), sourceHits: hits, asksTracking: true, sdks: [{ name: 'FirebaseCore', path: 'Pods/FirebaseCore', hasManifest: false }, { name: 'SomeAds', path: 'Pods/SomeAds', hasManifest: false }, { name: 'Ok', path: 'x', hasManifest: true }] }); const t = f.map(x => `${x.level}:${x.msg}`); expect(t.filter(x => x.startsWith('blocker')).length).toBe(2); expect(t.some(x => x.startsWith('blocker:NSPrivacyAccessedAPICategorySystemBootTime used'))).toBe(true); expect(t.some(x => x.startsWith('blocker:FirebaseCore'))).toBe(true); expect(t.some(x => x.startsWith('warn:SomeAds'))).toBe(true); expect(t.some(x => x.includes('unknown reason code(s) ZZZZ.1'))).toBe(false); // DiskSpace not used in sources -> info only expect(t.some(x => x.startsWith('warn:Info.plist asks for tracking'))).toBe(true); expect(evaluatePrivacyManifest({ appManifest: null, sourceHits: new Map(), sdks: [], asksTracking: false })[0]!.level).toBe('blocker'); }); }); const appleOk = (): AppleSnapshot => ({ version: { versionString: '1.1.0', state: 'PREPARE_FOR_SUBMISSION' }, build: { number: '42', processingState: 'VALID', usesNonExemptEncryption: false, declarationState: null }, expectedLocales: ['en-US', 'tr'], locales: ['en-US', 'tr'].map(l => ({ locale: l, subtitle: 's', description: 'd', keywords: 'k', screenshots: { APP_IPHONE_67: 5 } })), requiredDisplayTypes: ['APP_IPHONE_67'], reviewDetail: { contactEmail: 'review@example.com', contactPhone: '+1 555 0100', contactFirstName: 'A', contactLastName: 'B', notes: 'n' }, ageRating: { advertising: true, gambling: false, kidsAgeBand: null }, priceSchedule: true, availability: true, iaps: [{ productId: 'p1', state: 'READY_TO_SUBMIT', reviewScreenshot: true }], expectedProducts: ['p1'], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, }); describe('validate rules', () => { test('apple: all good', () => { expect(appleRules(appleOk()).filter(f => f.level !== 'ok')).toEqual([]); }); test('apple: ordered blockers', () => { const s = appleOk(); s.build = { number: '42', processingState: 'PROCESSING', usesNonExemptEncryption: null, declarationState: null }; s.locales = [{ locale: 'en-US', description: 'd', keywords: '', screenshots: {} }]; s.reviewDetail = null; s.ageRating = { gambling: null, kidsAgeBand: null }; s.availability = false; s.iaps = [{ productId: 'p1', state: 'MISSING_METADATA', reviewScreenshot: false }]; s.expectedProducts = ['p1', 'p2']; s.privacyUrl = { url: 'https://example.com/privacy', status: 404 }; const f = appleRules(s); const codes = f.filter(x => x.level === 'blocker').map(x => x.code); expect(codes).toEqual(['build', 'encryption', 'locale', 'metadata', 'screenshots', 'review-details', 'age-rating', 'availability', 'iap', 'iap', 'privacy-url']); expect(f.find(x => x.code === 'metadata')!.msg).toBe('en-US: missing keywords, subtitle'); expect(f.find(x => x.code === 'availability')!.fix).toContain('Pricing and Availability'); expect(f.findIndex(x => x.level === 'ok')).toBeGreaterThan(f.findLastIndex(x => x.level === 'blocker')); }); test('apple: non-exempt needs an approved declaration', () => { const s = appleOk(); s.build = { ...s.build!, usesNonExemptEncryption: true, declarationState: 'IN_REVIEW' }; expect(appleRules(s).find(x => x.code === 'encryption')!.level).toBe('blocker'); s.build.declarationState = 'APPROVED'; expect(appleRules(s).find(x => x.code === 'encryption')!.level).toBe('ok'); }); const playOk = (): PlaySnapshot => ({ defaultLanguage: 'en-US', expectedLanguages: ['en-US', 'tr-TR'], listings: ['en-US', 'tr-TR'].map(language => ({ language, title: 't', shortDescription: 's', fullDescription: 'f', images: { icon: 1, featureGraphic: 1, phoneScreenshots: 4 } })), privacyUrl: { url: 'https://example.com/privacy', status: 200 }, website: 'https://example.com', appAds: { url: 'https://example.com/app-ads.txt', status: 200, body: 'example-network.com, pub-1, DIRECT, abc\n# comment\n' }, appAdsLines: ['Example-Network.com,pub-1,DIRECT,abc'], adsLikely: true, tracks: [{ track: 'production', releases: [{ status: 'completed', versionCodes: ['7'] }] }], products: [{ productId: 'p1', states: ['ACTIVE'] }], expectedProducts: ['p1'], }); test('play: all good, app-ads normalisation', () => { expect(playRules(playOk()).filter(f => f.level !== 'ok')).toEqual([]); expect(normAdsLine(' A.com , X , DIRECT # c')).toBe('a.com,x,direct'); }); test('play: blockers', () => { const s = playOk(); s.listings = [{ language: 'en-US', title: 't', shortDescription: '', fullDescription: 'f', images: { icon: 1, featureGraphic: 0, phoneScreenshots: 1 } }]; s.appAds = { url: 'https://example.com/app-ads.txt', status: 200, body: 'other.com, 1, RESELLER\n' }; s.tracks = []; s.products = [{ productId: 'p1', states: ['DRAFT'] }]; s.privacyUrl = { url: 'https://example.com/privacy', status: null, error: 'timeout' }; const codes = playRules(s).filter(x => x.level === 'blocker').map(x => `${x.code}:${x.msg}`); expect(codes).toEqual([ 'listing:tr-TR: no listing', 'listing:en-US: missing shortDescription', 'images:en-US: featureGraphic 0/1', 'images:en-US: phoneScreenshots 1/2', 'privacy-url:privacy policy https://example.com/privacy: unreachable (timeout)', 'app-ads:app-ads.txt lacks 1 line(s): Example-Network.com,pub-1,DIRECT,abc', 'tracks:no release on any track', 'products:p1: purchase option DRAFT']); const h = playOk(); h.tracks = [{ track: 'production', releases: [{ status: 'halted', versionCodes: ['8'], userFraction: 0.1 }] }]; expect(playRules(h).find(x => x.code === 'tracks')!.level).toBe('warn'); }); test('play: a non-default language without graphics uses the default language\'s (pitfall 45)', () => { // live shape: 18 listings, only the default language (tr-TR) has an icon and a feature graphic const s = playOk(); const extra = ['en-US', 'de-DE', 'fr-FR', 'es-ES', 'it-IT', 'pt-BR', 'ru-RU', 'ja-JP', 'ko-KR', 'zh-CN', 'ar', 'hi-IN', 'id', 'pl-PL', 'nl-NL', 'th', 'vi']; s.defaultLanguage = 'tr-TR'; s.expectedLanguages = ['tr-TR', ...extra]; s.listings = [{ language: 'tr-TR', title: 't', shortDescription: 's', fullDescription: 'f', images: { icon: 1, featureGraphic: 1, phoneScreenshots: 4 } }, ...extra.map(language => ({ language, title: 't', shortDescription: 's', fullDescription: 'f', images: { icon: 0, featureGraphic: 0, phoneScreenshots: 4 } }))]; const f = playRules(s); expect(f.filter(x => x.level === 'blocker')).toEqual([]); expect(f.filter(x => x.code === 'images-fallback').map(x => x.msg)).toEqual([ "icon: 17 language(s) without their own use the default language's (tr-TR) graphics: en-US, de-DE, fr-FR, es-ES, it-IT, pt-BR ...", "featureGraphic: 17 language(s) without their own use the default language's (tr-TR) graphics: en-US, de-DE, fr-FR, es-ES, it-IT, pt-BR ...", ]); // the default language's own missing graphics still block s.listings[0]!.images.featureGraphic = 0; expect(playRules(s).filter(x => x.level === 'blocker').map(x => x.msg)).toEqual(expect.arrayContaining(['tr-TR: featureGraphic 0/1'])); expect(playRules(s).filter(x => x.level === 'blocker').length).toBe(18); }); test('apple: a live app with only the 6.5-inch set passes; 6.9-inch is a recommendation (pitfall 44)', () => { const s = appleOk(); // live shape: GET /v1/appStoreVersionLocalizations/{id}/appScreenshotSets answered one APP_IPHONE_65 set per locale s.locales = s.locales.map(l => ({ ...l, screenshots: { APP_IPHONE_65: 6 } })); const f = appleRules(s); expect(f.filter(x => x.level === 'blocker')).toEqual([]); expect(f.filter(x => x.code === 'screenshots-size').map(x => `${x.level}:${x.msg}`)[0]).toBe('warn:en-US: no APP_IPHONE_67 set; Apple accepts the APP_IPHONE_65 set (6 screenshots) and scales it for the other sizes. Recommended, not required: add APP_IPHONE_67'); s.locales[0]!.screenshots = { APP_IPHONE_61: 3 }; expect(appleRules(s).filter(x => x.level === 'blocker').map(x => x.msg)).toEqual(['en-US: no APP_IPHONE_67 screenshots (or APP_IPHONE_65)']); }); }); describe('release helpers', () => { test('review states and polling', () => { expect(classifyReviewState('IN_REVIEW')).toBe('pending'); expect(classifyReviewState('PENDING_DEVELOPER_RELEASE')).toBe('approved'); expect(classifyReviewState('REJECTED')).toBe('rejected'); expect(classifyReviewState('PREPARE_FOR_SUBMISSION')).toBe('not-submitted'); expect(nextPollDelay(null)).toBe(60_000); expect(nextPollDelay(60_000)).toBe(90_000); expect(nextPollDelay(800_000)).toBe(900_000); expect(parseDuration('48h')).toBe(172_800_000); expect(parseDuration('90')).toBe(90_000); expect(() => parseDuration('soon')).toThrow(); }); test('build numbers and version codes', () => { expect(nextBuildNumber([])).toBe('1'); expect(nextBuildNumber(['3', '12', '9'])).toBe('13'); expect(nextBuildNumber(['1.0.9', '1.0.10'])).toBe('1.0.11'); expect(compareBuild('2', '10')).toBeLessThan(0); expect(nextVersionCode(['5', 12, '7'])).toBe(13); expect(nextVersionCode([])).toBe(1); expect(() => nextVersionCode([2_100_000_000])).toThrow(); }); test('play rollout transitions', () => { const base = [{ status: 'completed', versionCodes: ['6'] }, { status: 'inProgress', versionCodes: ['7'], userFraction: 0.05 }]; expect(rolloutReleases(base, { kind: 'fraction', value: 0.2 }).releases[1]).toMatchObject({ status: 'inProgress', userFraction: 0.2 }); expect(() => rolloutReleases(base, { kind: 'fraction', value: 0.01 })).toThrow('does not lower'); expect(() => rolloutReleases(base, { kind: 'fraction', value: 1 })).toThrow('--complete'); const halted = rolloutReleases(base, { kind: 'halt' }).releases; expect(halted[1]!.status).toBe('halted'); expect(rolloutReleases(halted, { kind: 'resume' }).releases[1]).toMatchObject({ status: 'inProgress', userFraction: 0.05 }); expect(() => rolloutReleases(halted, { kind: 'fraction', value: 0.5 })).toThrow('halted'); const done = rolloutReleases(base, { kind: 'complete' }).releases; expect(done).toEqual([{ status: 'completed', versionCodes: ['7'] }]); expect(() => rolloutReleases([{ status: 'completed', versionCodes: ['6'] }], { kind: 'halt' })).toThrow('no staged rollout'); }); test('rollout args', () => { expect(rolloutAction(parseArgs(['--fraction', '0.2']))).toEqual({ kind: 'fraction', value: 0.2 }); expect(rolloutAction(parseArgs(['--resume', '--fraction', '0.1']))).toEqual({ kind: 'resume', value: 0.1 }); expect(rolloutAction(parseArgs(['--halt']))).toEqual({ kind: 'halt' }); expect(() => rolloutAction(parseArgs(['--halt', '--complete']))).toThrow('pick one'); expect(() => rolloutAction(parseArgs([]))).toThrow('usage'); }); test('apple phased release transitions', () => { expect(phasedRequest(null, 'on').method).toBe('POST'); expect(phasedRequest({ id: '1', state: 'INACTIVE' }, 'on').method).toBe('NONE'); expect(phasedRequest({ id: '1', state: 'ACTIVE' }, 'pause')).toMatchObject({ method: 'PATCH', state: 'PAUSED' }); expect(phasedRequest({ id: '1', state: 'PAUSED' }, 'resume')).toMatchObject({ method: 'PATCH', state: 'ACTIVE' }); expect(phasedRequest({ id: '1', state: 'ACTIVE' }, 'complete')).toMatchObject({ method: 'PATCH', state: 'COMPLETE' }); expect(() => phasedRequest(null, 'pause')).toThrow('--phased on'); expect(() => phasedRequest({ id: '1', state: 'INACTIVE' }, 'pause')).toThrow('ACTIVE'); expect(() => phasedRequest({ id: '1', state: 'COMPLETE' }, 'resume')).toThrow('COMPLETE'); }); }); describe('install', () => { const cmd = { command: '/x/bin/storebound', args: ['mcp'] }; test('json merge keeps other entries and is idempotent', () => { const cur = JSON.stringify({ mcpServers: { other: { command: 'o' } }, extra: 1 }); const r = mergeJsonServer(cur, 'mcpServers', cmd); expect(JSON.parse(r.text)).toEqual({ mcpServers: { other: { command: 'o' }, storebound: cmd }, extra: 1 }); expect(r.changed).toBe(true); expect(mergeJsonServer(r.text, 'mcpServers', cmd).changed).toBe(false); expect(JSON.parse(mergeJsonServer(null, 'vscode', cmd).text)).toEqual({ servers: { storebound: { type: 'stdio', ...cmd } } }); expect(JSON.parse(mergeJsonServer(null, 'opencode', cmd).text)).toEqual({ $schema: 'https://opencode.ai/config.json', mcp: { storebound: { type: 'local', command: ['/x/bin/storebound', 'mcp'], enabled: true } } }); expect(() => mergeJsonServer('{nope', 'mcpServers', cmd)).toThrow('not valid JSON'); }); test('codex toml merge', () => { const cur = 'model = "x"\n\n[mcp_servers.other]\ncommand = "o"\n\n[mcp_servers.storebound]\ncommand = "old"\n\n[mcp_servers.storebound.env]\nA = "1"\n\n[profiles.p]\nk = 1\n'; const r = mergeCodexToml(cur, cmd); expect(r.changed).toBe(true); expect(r.text).toContain('[mcp_servers.other]\ncommand = "o"'); expect(r.text).toContain('[mcp_servers.storebound]\ncommand = "/x/bin/storebound"\nargs = ["mcp"]'); expect(r.text).not.toContain('old'); expect(r.text).not.toContain('storebound.env'); expect(r.text).toContain('[profiles.p]\nk = 1'); expect(mergeCodexToml(r.text, cmd).changed).toBe(false); expect(mergeCodexToml(null, cmd).text).toBe('[mcp_servers.storebound]\ncommand = "/x/bin/storebound"\nargs = ["mcp"]\n'); }); test('agent table covers every tool', () => { expect(Object.keys(AGENTS).sort()).toEqual(['claude', 'codex', 'copilot', 'cursor', 'gemini', 'opencode', 'windsurf']); const env = { home: '/h', exists: () => false }; expect(AGENTS.windsurf.mcpFile(env)).toBe('~/.config/devin/mcp_config.json'); expect(AGENTS.windsurf.mcpFile({ ...env, exists: p => p === '/h/.codeium/windsurf/mcp_config.json' })).toBe('~/.codeium/windsurf/mcp_config.json'); }); test('install into a project: dry writes nothing, real run is idempotent', async () => { const dir = tmp(); const home = tmp(); delete process.env.XDG_CONFIG_HOME; // never touch the real user config from a test mkdirSync(join(dir, '.vscode')); writeFileSync(join(dir, '.vscode/mcp.json'), JSON.stringify({ servers: { keep: { type: 'stdio', command: 'k' } } })); await install(parseArgs(['--agent', 'copilot,codex,windsurf', '--dir', dir, '--home', home, '--dry'])); expect(existsSync(join(dir, '.github/skills'))).toBe(false); await install(parseArgs(['--agent', 'copilot,codex,windsurf', '--dir', dir, '--home', home])); expect(existsSync(join(dir, '.github/skills/whats-new/SKILL.md'))).toBe(true); expect(existsSync(join(dir, '.agents/skills/review-rejections/SKILL.md'))).toBe(true); const vs = JSON.parse(readFileSync(join(dir, '.vscode/mcp.json'), 'utf8')); expect(Object.keys(vs.servers)).toEqual(['keep', 'storebound']); expect(readFileSync(join(dir, '.codex/config.toml'), 'utf8')).toContain('[mcp_servers.storebound]'); expect(JSON.parse(readFileSync(join(home, '.config/devin/mcp_config.json'), 'utf8')).mcpServers.storebound.args).toEqual(['mcp']); const before = readFileSync(join(dir, '.vscode/mcp.json'), 'utf8'); await install(parseArgs(['--agent', 'copilot', '--dir', dir, '--home', home])); expect(readFileSync(join(dir, '.vscode/mcp.json'), 'utf8')).toBe(before); }); test('mcp command choice', () => { const dir = tmp(); expect(mcpCommand(dir).command).toMatch(/bin\/storebound$/); // run with Node (npx storebound install): the toolchain sits in npx's cache, so the pinned package instead expect(mcpCommand(dir, undefined, 'node', '1.2.3')).toEqual({ command: 'npx', args: ['-y', 'storebound@1.2.3', 'mcp'] }); mkdirSync(join(dir, 'node_modules/storebound'), { recursive: true }); writeFileSync(join(dir, 'node_modules/storebound/package.json'), '{}'); expect(mcpCommand(dir)).toEqual({ command: 'bunx', args: ['storebound', 'mcp'] }); expect(mcpCommand(dir, undefined, 'node')).toEqual({ command: 'npx', args: ['storebound', 'mcp'] }); expect(mcpCommand(dir, 'npx -y storebound')).toEqual({ command: 'npx', args: ['-y', 'storebound', 'mcp'] }); }); }); describe('config + scrub', () => { test('new config fields', () => { const base = { app: { name: 'X', privacyPolicyUrl: 'https://a.b', supportUrl: 'https://a.b', website: 'https://a.b', contactEmail: 'a@example.com' }, languages: [{ id: 'en' }] }; expect(validateConfig({ ...base, apple: { bundleId: 'com.a.b', usesNonExemptEncryption: 'no', screenshotTypes: ['IPHONE'] } }).join('\n')) .toMatch(/usesNonExemptEncryption[\s\S]*screenshotTypes/); expect(validateConfig({ ...base, ads: { appAdsTxt: ['just-a-domain.com'] } })[0]).toContain('ads.appAdsTxt'); expect(validateConfig({ ...base, ads: { appAdsTxt: ['net.com, pub-1, DIRECT'] } })).toEqual([]); }); test('scrub catches secrets and allows placeholders', () => { const hits = scan([ { path: 'a.md', text: `mail ${'someone'}@${'company'}.io and ok@example.com` }, { path: 'templates/x.json', text: '{"k": "AB12CD34EF"}' }, { path: 'cli/x.ts', text: `const ua = 'Chrome/126.0.0.0'; const host = '${[10, 1, 2, 3].join('.')}';` }, { path: 'k.pem', text: '-----BEGIN PRIVATE ' + 'KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC' }, ]); expect(hits.map(h => h.rule).sort()).toEqual(['apple-id-like', 'email', 'ip-address', 'private-key']); }); }); describe('iap review screenshot size', () => { const png = (w: number, h: number) => { const b = Buffer.alloc(33); b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); b.writeUInt32BE(13, 8); b.write('IHDR', 12); b.writeUInt32BE(w, 16); b.writeUInt32BE(h, 20); b[24] = 8; b[25] = 2; return b; }; test('only App Store screenshot sizes pass (1320 x 2706 was rejected by Apple with IMAGE_INCORRECT_DIMENSIONS)', async () => { const { reviewShotProblem } = await import('../cli/apple/iap-review'); expect(reviewShotProblem(png(1320, 2706))).toContain('IMAGE_INCORRECT_DIMENSIONS'); expect(reviewShotProblem(png(1320, 2868))).toBeNull(); expect(reviewShotProblem(png(2796, 1290))).toBeNull(); // landscape expect(reviewShotProblem(png(2064, 2752))).toBeNull(); // iPad 13" }); });