// Desktop D2 on the CLI side: app icon discovery, listing completeness, reply drafts (read + approve), embedded // templates for `init` from the compiled sidecar, `storebound doctor`, and the optional playwright peer. import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, relative } from 'node:path'; import pkg from '../package.json' with { type: 'json' }; import { doctor } from '../cli/doctor'; import { FILES, templateFor, templateKey, templateText } from '../cli/init'; import type { ShipConfig } from '../cli/lib/config'; import { draftMarkdown, draftName, parseDraft } from '../cli/lib/reviews'; import { findIcon, iconCandidates, iconDataUrl, listingCompleteness, readReplyDrafts, setApproved } from '../cli/lib/project-files'; import { TEMPLATE_TEXT } from '../cli/lib/template-files'; import { installCommands } from '../cli/lib/tools'; import { NEUTRAL_ROOT, neutralize, privateStrings } from '../tools/build-sidecars'; const ROOT = join(import.meta.dir, '..'); const tmp = () => mkdtempSync(join(tmpdir(), 'sb-d2-')); const APP = { name: 'Demo', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'a@example.com' }; const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex'); describe('app icon', () => { test('config first, then the rendered store icons, then the usual places; data: URL with a size cap', () => { const d = tmp(); try { const cfg = { icons: { composite: 'art/icon.png', out: 'store/icons' }, play: { packageName: 'x', icon: 'play.png' } } as unknown as ShipConfig; expect(iconCandidates(cfg, d).slice(0, 4).map(p => relative(d, p))).toEqual(['art/icon.png', 'store/icons/app-store-1024.png', 'store/icons/play-512.png', 'play.png']); expect(findIcon(cfg, d)).toBeNull(); mkdirSync(join(d, 'store/icons'), { recursive: true }); writeFileSync(join(d, 'store/icons/play-512.png'), PNG); expect(findIcon(cfg, d)).toBe(join(d, 'store/icons/play-512.png')); const u = iconDataUrl(findIcon(cfg, d)); expect(u.dataUrl).toStartWith('data:image/png;base64,'); writeFileSync(join(d, 'big.png'), Buffer.alloc(1_600_000)); expect(iconDataUrl(join(d, 'big.png')).dataUrl).toBeNull(); expect(iconDataUrl(null)).toEqual({ path: null, dataUrl: null }); } finally { rmSync(d, { recursive: true, force: true }); } }); }); describe('listing completeness', () => { test('per language and store: present, missing, over the limit, optional; screenshots counted; missing files reported', () => { const d = tmp(); try { const cfg = { app: APP, languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }, { id: 'tr', play: 'tr-TR', apple: 'tr' }], play: { packageName: 'x' }, apple: { bundleId: 'x' }, renders: 'r' } as unknown as ShipConfig; const none = listingCompleteness(cfg, d); expect(none.problems).toEqual(['store/listing.md: file not found', 'store/appstore.md: file not found']); expect(none.locales.map(l => [l.lang, l.complete, l.screenshots])).toEqual([['en', 0, null], ['tr', 0, null]]); mkdirSync(join(d, 'store')); writeFileSync(join(d, 'store/listing.md'), '## English\n\n### Short description\n```\nShort text\n```\n### Full description\n```\nFull text\n```\n'); writeFileSync(join(d, 'store/appstore.md'), `## en-US\n### subtitle\n\`\`\`\n${'x'.repeat(31)}\n\`\`\`\n### promotionalText\n\`\`\`\nPromo\n\`\`\`\n### keywords\n\`\`\`\na,b\n\`\`\`\n### description\n\`\`\`\nDesc\n\`\`\`\n`); mkdirSync(join(d, 'r/en/phone'), { recursive: true }); for (const n of ['01.png', '02.jpg', 'notes.txt']) writeFileSync(join(d, 'r/en/phone', n), 'x'); const l = listingCompleteness(cfg, d); expect(l.problems).toEqual([]); const en = l.locales[0]!; expect(en.play!.fields.map(f => [f.field, f.state])).toEqual([['title', 'ok'], ['short', 'ok'], ['full', 'ok'], ['whatsNew', 'empty-optional']]); expect(en.apple!.fields.find(f => f.field === 'subtitle')).toMatchObject({ length: 31, limit: 30, state: 'over' }); expect(en.screenshots).toBe(2); expect(en.complete).toBeCloseTo(7 / 8); expect(l.locales[1]!).toMatchObject({ complete: 0, screenshots: 0 }); expect(l.locales[1]!.play!.fields[1]).toMatchObject({ field: 'short', state: 'missing' }); } finally { rmSync(d, { recursive: true, force: true }); } }); }); describe('reply drafts', () => { const review = { store: 'play' as const, id: 'gp:1', rating: 2, title: 'Crash', body: 'It crashes', date: '2026-10-09T10:00:00.000Z', where: 'en' }; test('read with checks; approve sets the front matter only; unparsable files are problems', () => { const d = tmp(); try { const f = join(d, draftName(review)); writeFileSync(f, draftMarkdown(review).replace('## Reply\n\n', '## Reply\n\nSorry! Fixed in 2.4. Rate us 5 stars!\n')); writeFileSync(join(d, 'apple-bad.md'), 'no front matter'); const r = readReplyDrafts(d); expect(r.problems).toHaveLength(1); expect(r.drafts).toHaveLength(1); expect(r.drafts[0]).toMatchObject({ store: 'play', reviewId: 'gp:1', approved: false, sent: null, rating: 2, title: 'Crash', body: 'It crashes' }); expect(r.drafts[0]!.issues.some(i => i.level === 'error')).toBe(true); const text = readFileSync(f, 'utf8'); const approved = setApproved(text, true); expect(parseDraft(f, approved).approved).toBe(true); expect(approved.split('---').slice(2).join('---')).toBe(text.split('---').slice(2).join('---')); expect(() => setApproved('---\nfoo: 1\n---\nx', true)).toThrow(); expect(readReplyDrafts(join(d, 'none'))).toEqual({ dir: join(d, 'none'), drafts: [], problems: [] }); } finally { rmSync(d, { recursive: true, force: true }); } }); test('CLI: reviews drafts --json and reviews approve (refuses a draft that fails the checks)', async () => { const d = tmp(); try { writeFileSync(join(d, 'storebound.config.ts'), `export default ${JSON.stringify({ app: APP, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: 'com.example.demo' } })};\n`); const dir = join(d, '.storebound/replies'); mkdirSync(dir, { recursive: true }); const good = draftName({ ...review, id: 'gp:2' }), bad = draftName(review); writeFileSync(join(dir, good), draftMarkdown({ ...review, id: 'gp:2' }).replace('## Reply\n\n', '## Reply\n\nThanks, fixed in 2.4.\n')); writeFileSync(join(dir, bad), draftMarkdown(review).replace('## Reply\n\n', '## Reply\n\nRate us 5 stars for free coins!\n')); const run = (args: string[]) => Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), ...args], { cwd: d, stdout: 'pipe', stderr: 'pipe' }); const list = run(['reviews', 'drafts', '--json']); expect(list.exitCode).toBe(0); expect((JSON.parse(list.stdout.toString()) as { drafts: unknown[] }).drafts).toHaveLength(2); expect(run(['reviews', 'approve', good, '--dry']).exitCode).toBe(0); expect(parseDraft(good, readFileSync(join(dir, good), 'utf8')).approved).toBe(false); expect(run(['reviews', 'approve', good]).exitCode).toBe(0); expect(parseDraft(good, readFileSync(join(dir, good), 'utf8')).approved).toBe(true); expect(run(['reviews', 'approve', bad]).exitCode).not.toBe(0); expect(run(['reviews', 'approve', '../storebound.config.ts']).exitCode).not.toBe(0); expect(run(['reviews', 'approve', good, '--undo']).exitCode).toBe(0); expect(parseDraft(good, readFileSync(join(dir, good), 'utf8')).approved).toBe(false); } finally { rmSync(d, { recursive: true, force: true }); } }, 30_000); }); describe('init from the compiled sidecar', () => { test('every template file is embedded with the same bytes, and init writes from the embedded copy', () => { const all: string[] = []; const walk = (dir: string) => { for (const n of readdirSync(dir)) { const p = join(dir, n); if (statSync(p).isDirectory()) walk(p); else all.push(relative(join(ROOT, 'templates'), p).split('\\').join('/')); } }; walk(join(ROOT, 'templates')); expect(Object.keys(TEMPLATE_TEXT).sort()).toEqual(all.sort()); for (const k of all) expect(TEMPLATE_TEXT[k], k).toBe(readFileSync(join(ROOT, 'templates', k), 'utf8')); for (const p of ['app', 'game', 'subscription-app'] as const) for (const [f] of FILES) expect(templateText(p, f)).toBe(readFileSync(templateFor(p, f), 'utf8')); expect(templateKey('subscription-app', 'privacy-policy.html')).toBe('profiles/app/privacy-policy.html'); }); test('init --dry writes nothing', () => { const d = tmp(); try { const r = Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'init', '--profile', 'game', '--dry'], { cwd: d, stdout: 'pipe', stderr: 'pipe' }); expect(r.exitCode).toBe(0); expect(r.stdout.toString()).toContain('would create storebound.config.ts'); expect(readdirSync(d)).toEqual([]); } finally { rmSync(d, { recursive: true, force: true }); } }); }); describe('doctor and playwright', () => { test('doctor: the GUI\'s detection plus playwright, install commands for what is missing', () => { const r = doctor(ROOT, { PATH: '/nonexistent' }); expect(r.tools.map(t => t.id)).toEqual(expect.arrayContaining(['ffmpeg', 'ffprobe', 'chrome', 'playwright', 'git'])); const ff = r.tools.find(t => t.id === 'ffmpeg')!; expect(ff.found).toBe(false); expect(ff.install.length).toBeGreaterThan(0); expect(r.missing).toBe(r.tools.filter(t => !t.found).length); expect(r.project).toBeNull(); }); test('sidecar bundle: no checkout path, and bundled require.resolve of own files cannot throw', () => { const js = 'var a = __require.resolve("/work/sb/node_modules/x/package.json"); var __dirname = "/work/sb/node_modules/x/lib";'; expect(neutralize(js, '/work/sb')).toBe(`var a = "${NEUTRAL_ROOT}/node_modules/x/package.json"; var __dirname = "${NEUTRAL_ROOT}/node_modules/x/lib";`); }); test('sidecar leak check: a person\'s home and the checkout, not a CI container\'s /root', () => { expect(privateStrings('/Users/you', '/work/sb')).toEqual(['/Users/you', '/work/sb']); expect(privateStrings('/root', '/workspace/o/sb')).toEqual(['/workspace/o/sb']); expect(privateStrings('/home/runner', '/home/runner/work/sb')).toEqual(['/home/runner/work/sb']); }); test('install commands per OS, never empty for the tools that have a package', () => { expect(installCommands('ffmpeg', 'darwin')).toEqual(['brew install ffmpeg']); expect(installCommands('ffmpeg', 'win32')[0]).toContain('winget'); expect(installCommands('adb', 'linux')).toEqual(['sudo apt install adb']); expect(installCommands('xcode', 'linux')).toEqual([]); expect(installCommands('playwright')).toEqual(['bun add -d playwright']); }); test('playwright is an optional peer, not a runtime dependency; playwright-core is only a dev dependency for the sidecar', () => { const p = pkg as unknown as { dependencies?: Record; peerDependencies: Record; peerDependenciesMeta: Record; devDependencies: Record }; expect(p.dependencies ?? {}).toEqual({}); expect(p.peerDependencies.playwright).toBeString(); expect(p.peerDependenciesMeta.playwright).toEqual({ optional: true }); expect(p.devDependencies['playwright-core']).toBe(p.devDependencies.playwright); // the CLI reaches playwright-core only when compiled (the sidecar bundles it) const browser = readFileSync(join(ROOT, 'cli/shots/browser.ts'), 'utf8'); expect(browser).toMatch(/if \(compiled\(\)\) return \(await import\('playwright-core'\)\)\.chromium;/); }); });