507 lines
36 KiB
TypeScript
507 lines
36 KiB
TypeScript
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, MCP_LAUNCH, 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: 'sh', args: ['-c', MCP_LAUNCH] });
|
|
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<string, { default?: unknown }> };
|
|
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<string, boolean>;
|
|
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<string, unknown> = { ...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 = `<?xml version="1.0"?><plist version="1.0"><dict>
|
|
<key>NSPrivacyTracking</key><false/>
|
|
<key>NSPrivacyTrackingDomains</key><array/>
|
|
<key>NSPrivacyAccessedAPITypes</key><array>
|
|
<dict><key>NSPrivacyAccessedAPIType</key><string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
|
<key>NSPrivacyAccessedAPITypeReasons</key><array><string>CA92.1</string></array></dict>
|
|
<dict><key>NSPrivacyAccessedAPIType</key><string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
|
<key>NSPrivacyAccessedAPITypeReasons</key><array><string>ZZZZ.1</string></array></dict>
|
|
</array></dict></plist>`;
|
|
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.at(-1)).toBe('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, undefined, 'bun', undefined, 'linux').command).toMatch(/bin[\\/]storebound$/);
|
|
expect(mcpCommand(dir, undefined, 'bun', undefined, 'win32')).toEqual({ command: 'bun', args: [expect.stringMatching(/cli[\\/]index\.ts$/), 'mcp'] });
|
|
// 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', 'linux')).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', undefined, 'linux')).toEqual({ command: 'npx', args: ['storebound', 'mcp'] });
|
|
expect(mcpCommand(dir, 'npx -y storebound', undefined, undefined, 'linux')).toEqual({ command: 'npx', args: ['-y', 'storebound', 'mcp'] });
|
|
expect(mcpCommand(dir, 'npx -y storebound', undefined, undefined, 'win32')).toEqual({ command: 'cmd', args: ['/c', 'npx', '-y', 'storebound', 'mcp'] });
|
|
expect(mcpCommand(dir, undefined, 'bun', undefined, 'win32').command).not.toBe('cmd');
|
|
});
|
|
});
|
|
|
|
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"
|
|
});
|
|
});
|