// The desktop's editors write through `storebound edit` (serve method project.edit): config values by path as text // edits (comments, order and formatting kept, only the edited value changes), listing fields per store and language, // the App Review contact (masked), the checks of `check` / `scan` before anything is written, and the dry run. import { describe, expect, test } from 'bun:test'; import { cpSync, existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parsePath, rootObject, setByPath } from '../cli/lib/config-path'; import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy'; import { checkOp, getByPath, parseOps, planEdits, type EditSources } from '../cli/lib/edit'; import { appleSection, playSection, setAppleField, setPlayField } from '../cli/lib/listing-edit'; import { appleLocaleIssues, playLangIssues, urlIssue } from '../cli/lib/listing-rules'; const ROOT = join(import.meta.dir, '..'); const CLI = join(ROOT, 'cli', 'index.ts'); const CFG = `// storebound config of Example (a comment that must survive) import type { ShipConfig } from 'storebound'; const config: ShipConfig = { app: { name: 'Example', // the on-device name privacyPolicyUrl: 'https://example.com/privacy', supportUrl: "https://example.com/support", website: 'https://example.com', contactEmail: 'support@example.com', }, languages: [ { id: 'en', play: 'en-US', apple: 'en-US' }, { id: 'pt-BR', play: 'pt-BR', apple: 'pt-BR' }, ], apple: { bundleId: 'com.example.app' }, products: [ { id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No ads', description: 'Removes every ad.' }, 'pt-BR': { title: 'Sem anúncios', description: 'Remove os anúncios.' }, }, }, ], subscriptions: [ { name: 'Example Pro', listings: { en: { name: 'Pro' } }, subscriptions: [ { id: 'com.example.app.pro_monthly', period: 'P1M', level: 1, usd: 3.99, listings: { en: { title: 'Pro monthly', description: 'All of it.' } } }, ] }, ], }; export default config; `; describe('config-path: one value set by path, the rest of the file byte for byte', () => { const only = (before: string, after: string): string[] => { const a = before.split('\n'), b = after.split('\n'); return b.filter((l, i) => l !== a[i]); }; test('finds the exported object (const + export default, export default {}, defineConfig)', () => { expect(CFG[rootObject(CFG)]).toBe('{'); expect(rootObject('export default { a: 1 }')).toBe(15); expect(rootObject('export default defineConfig({ a: 1 })')).toBe(28); }); test('replaces a string, keeps comments and the quote style', () => { const out = setByPath(CFG, 'storebound.config.ts', 'app.supportUrl', 'https://example.com/help'); expect(only(CFG, out)).toEqual([' supportUrl: "https://example.com/help",']); const n = setByPath(CFG, 'storebound.config.ts', 'app.name', 'Example 2'); expect(only(CFG, n)).toEqual([" name: 'Example 2', // the on-device name"]); expect(n).toContain('// storebound config of Example (a comment that must survive)'); }); test('array elements by [key=value] (ids with dots), quoted keys, numbers', () => { const out = setByPath(CFG, 'storebound.config.ts', 'products[id=com.example.app.no_ads].listings.pt-BR.title', 'Sem propaganda'); expect(only(CFG, out)).toEqual([" 'pt-BR': { title: 'Sem propaganda', description: 'Remove os anúncios.' },"]); const price = setByPath(CFG, 'storebound.config.ts', 'products[id=com.example.app.no_ads].usd', 3.99); expect(only(CFG, price)).toEqual([" id: 'com.example.app.no_ads', type: 'non_consumable', usd: 3.99,"]); const sub = setByPath(CFG, 'storebound.config.ts', 'subscriptions[name=Example Pro].subscriptions[id=com.example.app.pro_monthly].listings.en.title', 'Pro (monthly)'); expect(only(CFG, sub)).toHaveLength(1); expect(sub).toContain("title: 'Pro (monthly)'"); }); test('adds a missing key (and the objects on the way), multi-line text escaped', () => { const out = setByPath(CFG, 'storebound.config.ts', 'apple.info.reviewNotes', "Line one\nIt's line two"); expect(out).toContain("apple: { bundleId: 'com.example.app', info: { reviewNotes: 'Line one\\nIt\\'s line two' } },"); const app = setByPath(CFG, 'storebound.config.ts', 'app.marketingUrl', 'https://example.com/m'); expect(app).toContain(" contactEmail: 'support@example.com',\n marketingUrl: 'https://example.com/m',\n },"); // after a last member without a trailing comma (and its comment) expect(setByPath('export default {\n a: 1 // one\n};\n', 'c.ts', 'b', 2)).toBe('export default {\n a: 1, // one\n b: 2,\n};\n'); }); test('removes a key; the edited file still loads to the same object otherwise', async () => { const out = setByPath(CFG, 'storebound.config.ts', 'app.website', undefined); expect(out).not.toContain('website'); expect(only(CFG, out).length).toBeLessThan(CFG.split('\n').length); const dir = mkdtempSync(join(tmpdir(), 'sb-path-')); writeFileSync(join(dir, 'a.ts'), out.replace("import type { ShipConfig } from 'storebound';", "type ShipConfig = any;")); const m = (await import(join(dir, 'a.ts'))).default; expect(m.app).toEqual({ name: 'Example', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', contactEmail: 'support@example.com' }); }); test('refuses values that are not plain literals and missing list elements', () => { const t = "const shared = { a: 1 };\nexport default { app: shared, x: { y: f() } };\n"; expect(() => setByPath(t, 'c.ts', 'app.name', 'x')).toThrow(/not a plain object/); expect(() => setByPath(t, 'c.ts', 'x.y', 'z')).toThrow(/not a plain value/); expect(() => setByPath(CFG, 'c.ts', 'products[id=nope].usd', 1)).toThrow(/no such element/); }); test('JSON configs are parsed and written back with their indent', () => { const j = JSON.stringify({ app: { name: 'A' }, products: [{ id: 'p', usd: 1 }] }, null, 2) + '\n'; const out = setByPath(j, 'storebound.config.json', 'products[id=p].usd', 2); expect(JSON.parse(out).products[0].usd).toBe(2); expect(out.endsWith('\n')).toBe(true); }); test('paths', () => { expect(parsePath('products[id=a.b].listings.pt-BR.title')).toEqual([{ key: 'products' }, { match: 'id', eq: 'a.b' }, { key: 'listings' }, { key: 'pt-BR' }, { key: 'title' }]); expect(getByPath({ products: [{ id: 'a.b', usd: 3 }] }, 'products[id=a.b].usd')).toBe(3); }); }); const PLAY_MD = `# Listing Prose the parser ignores. ## English ### Play short description (max 80) \`\`\` Old short \`\`\` ### Play full description (max 4000) \`\`\` Old full text. \`\`\` `; const APPLE_MD = `# App Store ## en-US · English ### subtitle \`\`\` Old subtitle \`\`\` ### promotionalText \`\`\` Promo \`\`\` ### keywords \`\`\` lists,tidy,notes,todo,planner,groceries,checklist,tasks,organize,shopping,reminders,family,share \`\`\` ### description \`\`\` Description. \`\`\` `; describe('listing-edit: one field block, the rest of the file unchanged', () => { test('Play: a field of a section serving two codes; a new block; a new language', () => { const r = setPlayField(PLAY_MD, 'en-GB', 'short', 'New short'); expect(r.codes).toEqual(['en-US', 'en-GB']); expect(r.from).toBe('Old short'); expect(r.md.replace('New short', 'Old short')).toBe(PLAY_MD); const w = setPlayField(PLAY_MD, 'en-US', 'whatsNew', 'Faster sync.'); expect(parsePlayListing(w.md, 'Example')[0]!.whatsNew).toBe('Faster sync.'); const n = setPlayField(PLAY_MD, 'tr-TR', 'short', 'Kısa'); expect(n.created).toBe(true); expect(playSection(n.md, 'tr-TR')!.values.short).toBe('Kısa'); const gone = setPlayField(w.md, 'en-US', 'whatsNew', ''); expect(gone.md).toBe(PLAY_MD); }); test('App Store: replace, add and remove a block of one locale', () => { const r = setAppleField(APPLE_MD, 'en-US', 'subtitle', 'Lists that stay tidy'); expect(r.md.replace('Lists that stay tidy', 'Old subtitle')).toBe(APPLE_MD); const a = setAppleField(APPLE_MD, 'en-US', 'whatsNew', 'Dark mode'); expect(parseAppStoreListing(a.md)['en-US']!.whatsNew).toBe('Dark mode'); expect(setAppleField(a.md, 'en-US', 'whatsNew', '').md).toBe(APPLE_MD); expect(appleSection(APPLE_MD, 'en-US')!.values.keywords).toContain('lists,tidy'); }); }); describe('listing-rules: the checks while typing', () => { test('limits, required fields, keyword and metadata rules', () => { const ok = appleLocaleIssues('en-US', { subtitle: 'Tidy lists', promotionalText: 'p', keywords: 'notes,todo,planner,groceries,checklist,tasks,organize,shopping,reminders,family,share,home,week', description: 'd' }, 'Example'); expect(ok.filter(i => i.level === 'error')).toEqual([]); const bad = appleLocaleIssues('en-US', { subtitle: 'x'.repeat(31), promotionalText: '', keywords: 'a, b', description: 'Get it on Google Play' }, 'Example'); expect(bad.find(i => i.field === 'subtitle' && i.level === 'error')!.msg).toContain('31 characters'); expect(bad.some(i => i.field === 'promotionalText' && i.level === 'error')).toBe(true); expect(bad.some(i => i.field === 'keywords' && /comma/.test(i.msg))).toBe(true); expect(bad.some(i => i.field === 'description' && /Google Play/.test(i.msg))).toBe(true); const comp = appleLocaleIssues('en-US', { subtitle: 'Best TikTok lists', promotionalText: 'p', keywords: 'k', description: 'd' }, 'Example'); expect(comp.filter(i => i.field === 'subtitle').map(i => i.level)).toEqual(['warn', 'warn']); const play = playLangIssues('en-US', { short: 'The #1 best list app', full: 'lists '.repeat(100) }, 'Example'); expect(play.some(i => i.field === 'short' && /best/.test(i.msg))).toBe(true); expect(play.some(i => i.field === 'full' && /stuffing/.test(i.msg))).toBe(true); expect(playLangIssues('en-US', { short: 's', full: 'f' }, 'A name that is far too long for Play')[0]!.msg).toContain('falls back'); expect(urlIssue('http://example.com')).toContain('https'); expect(urlIssue('https://example.com/x')).toBeNull(); }); }); function sources(cfg: string, play: string | null, apple: string | null): EditSources { return { config: { file: '/x/storebound.config.ts', rel: 'storebound.config.ts', text: cfg }, play: { file: '/x/store/listing.md', rel: 'store/listing.md', text: play }, apple: { file: '/x/store/appstore.md', rel: 'store/appstore.md', text: apple }, contact: { file: '/x/.storebound/credentials.json', rel: '.storebound/credentials.json', text: '{"ASC_KEY_ID":"K","reviewContact":{"firstName":"Ada","email":"ada@example.com"}}' }, appName: 'Example', appleName: 'Example', }; } describe('planEdits: old -> new, the diff per file, the checks', () => { test('a listing, a config and a contact edit in one plan; personal data masked', () => { const ops = parseOps(JSON.stringify([ { kind: 'listing', store: 'apple', lang: 'en-US', field: 'subtitle', value: 'Lists that stay tidy' }, { kind: 'config', path: 'app.supportUrl', value: 'https://example.com/help' }, { kind: 'contact', field: 'email', value: 'review@example.com' }, ])); const { plan, texts } = planEdits(ops, sources(CFG, PLAY_MD, APPLE_MD), p => getByPath({ app: { supportUrl: 'https://example.com/support' } }, p)); expect(plan.changes.map(c => c.what)).toEqual(['App Store en-US subtitle', 'app.supportUrl', 'App Review contact email']); expect(plan.changes[0]).toMatchObject({ from: 'Old subtitle', to: 'Lists that stay tidy' }); expect(plan.changes[2]).toMatchObject({ from: 'a***@example.com', to: 'r***@example.com' }); expect(plan.files.map(f => f.rel).sort()).toEqual(['.storebound/credentials.json', 'store/appstore.md', 'storebound.config.ts']); expect(plan.files.find(f => f.rel.endsWith('credentials.json'))).toMatchObject({ masked: true, diff: [] }); expect(plan.files.find(f => f.rel === 'store/appstore.md')!.diff).toEqual(expect.arrayContaining(['-Old subtitle', '+Lists that stay tidy'])); expect(JSON.parse(texts.get('/x/.storebound/credentials.json')!)).toEqual({ ASC_KEY_ID: 'K', reviewContact: { firstName: 'Ada', email: 'review@example.com' } }); expect(plan.errors).toBe(0); }); test('errors: over the limit, a bad URL', () => { const { plan } = planEdits(parseOps(JSON.stringify([ { kind: 'listing', store: 'play', lang: 'en-US', field: 'short', value: 'x'.repeat(81) }, { kind: 'config', path: 'app.supportUrl', value: 'example.com' }, ])), sources(CFG, PLAY_MD, APPLE_MD), () => undefined); expect(plan.issues.filter(i => i.level === 'error').map(i => i.where)).toEqual(['app.supportUrl', 'Google Play en-US']); expect(plan.errors).toBe(2); }); test('the op shape is checked (paths, stores, fields, sizes)', () => { expect(() => checkOp({ kind: 'config', path: 'account', value: 'x' }, 0)).toThrow(/not edited from here/); expect(() => checkOp({ kind: 'config', path: 'a;b', value: 'x' }, 0)).toThrow(/not a config path/); expect(() => checkOp({ kind: 'listing', store: 'play', lang: 'en-US', field: 'keywords', value: 'x' }, 0)).toThrow(/no field/); expect(() => checkOp({ kind: 'contact', field: 'password', value: 'x' }, 0)).toThrow(/contact field/); expect(() => parseOps('[]')).toThrow(/no edits/); }); }); describe('CLI: storebound edit on a copy of the demo app', () => { const copy = (): string => { const dir = mkdtempSync(join(tmpdir(), 'sb-edit-')); // only the tracked example: the gitignored .storebound/, renders, frames and video of a local checkout stay out cpSync(join(ROOT, 'examples', 'demo-app'), dir, { recursive: true, filter: (src) => !/(^|[\\/])(\.storebound|renders|frames|video)([\\/]|$)/.test(src.slice(join(ROOT, 'examples', 'demo-app').length)) }); return dir; }; test('--show lists what the editors show', async () => { const dir = copy(); const r = await Bun.$`bun ${CLI} edit --show --json`.cwd(dir).quiet().nothrow(); expect(r.exitCode).toBe(0); const d = JSON.parse(r.stdout.toString()); expect(d.files).toMatchObject({ config: 'storebound.config.ts', play: 'store/listing.md', apple: 'store/appstore.md' }); expect(d.apple.map((x: { lang: string }) => x.lang)).toEqual(['en-US', 'tr']); expect(d.play[0].values.short).toBeTruthy(); expect(d.config.app.supportUrl).toBe('https://example.com/support'); expect(d.limits.apple.keywords).toBe(100); }); test('dry run writes nothing; the real run changes only the edited values; an error writes nothing', async () => { const dir = copy(); const cfgBefore = readFileSync(join(dir, 'storebound.config.ts'), 'utf8'); const mdBefore = readFileSync(join(dir, 'store', 'appstore.md'), 'utf8'); const ops = JSON.stringify([ { kind: 'listing', store: 'apple', lang: 'en-US', field: 'subtitle', value: 'Lists that stay tidy' }, { kind: 'config', path: 'app.supportUrl', value: 'https://example.com/help' }, ]); const dry = await Bun.$`bun ${CLI} edit --ops ${ops} --dry --json`.cwd(dir).quiet().nothrow(); expect(dry.exitCode).toBe(0); const plan = JSON.parse(dry.stdout.toString()); expect(plan).toMatchObject({ written: false, dry: true, errors: 0 }); expect(readFileSync(join(dir, 'storebound.config.ts'), 'utf8')).toBe(cfgBefore); const real = await Bun.$`bun ${CLI} edit --ops ${ops}`.cwd(dir).quiet().nothrow(); expect(real.exitCode).toBe(0); const cfg = readFileSync(join(dir, 'storebound.config.ts'), 'utf8'); expect(cfg).toBe(cfgBefore.replace("supportUrl: 'https://example.com/support'", "supportUrl: 'https://example.com/help'")); const md = readFileSync(join(dir, 'store', 'appstore.md'), 'utf8'); expect(appleSection(md, 'en-US')!.values.subtitle).toBe('Lists that stay tidy'); expect(md.split('\n').filter((l, i) => l !== mdBefore.split('\n')[i])).toHaveLength(1); // the edited config still loads (the check command reads it) const check = await Bun.$`bun ${CLI} inspect --json`.cwd(dir).quiet().nothrow(); expect(check.exitCode).toBe(0); // an over-limit value or a config the validator refuses: nothing is written const bad = await Bun.$`bun ${CLI} edit --ops ${JSON.stringify([{ kind: 'listing', store: 'apple', lang: 'en-US', field: 'subtitle', value: 'x'.repeat(40) }])}`.cwd(dir).quiet().nothrow(); expect(bad.exitCode).not.toBe(0); expect(bad.stderr.toString() + bad.stdout.toString()).toMatch(/40 characters/); expect(readFileSync(join(dir, 'store', 'appstore.md'), 'utf8')).toBe(md); const invalid = await Bun.$`bun ${CLI} edit --ops ${JSON.stringify([{ kind: 'config', path: 'languages', value: [] }])}`.cwd(dir).quiet().nothrow(); expect(invalid.exitCode).not.toBe(0); expect(readFileSync(join(dir, 'storebound.config.ts'), 'utf8')).toBe(cfg); expect(existsSync(join(dir, '.storebound'))).toBe(false); }); test('the App Review contact goes into .storebound/credentials.json, 0600, never printed', async () => { const dir = copy(); const ops = JSON.stringify([{ kind: 'contact', field: 'email', value: 'reviewer@example.com' }, { kind: 'contact', field: 'phone', value: '+15550100' }]); const r = await Bun.$`bun ${CLI} edit --ops ${ops}`.cwd(dir).quiet().nothrow(); expect(r.exitCode).toBe(0); const out = r.stdout.toString() + r.stderr.toString(); expect(out).not.toContain('reviewer@example.com'); expect(out).not.toContain('+15550100'); const f = join(dir, '.storebound', 'credentials.json'); expect(JSON.parse(readFileSync(f, 'utf8')).reviewContact).toEqual({ email: 'reviewer@example.com', phone: '+15550100' }); expect(statSync(f).mode & 0o777).toBe(0o600); }); }); describe('JSON files edited as text (the shots copy file keeps one scene per line)', () => { test('a caption line changes, the other lines and the layout stay', async () => { const dir = mkdtempSync(join(tmpdir(), 'sb-copy-')); cpSync(join(ROOT, 'examples', 'demo-app'), dir, { recursive: true }); const file = join(dir, 'store', 'shots', 'copy.json'); const before = readFileSync(file, 'utf8'); const ops = JSON.stringify([{ kind: 'shotsCopy', lang: 'en', scene: 'lists', field: 'title', line: 1, value: 'in one tidy place' }, { kind: 'shotsCopy', lang: 'tr', scene: 'share', field: 'sub', value: 'Birlikte düzenleyin.' }]); const r = await Bun.$`bun ${CLI} edit --ops ${ops}`.cwd(dir).quiet().nothrow(); expect(r.exitCode).toBe(0); const after = readFileSync(file, 'utf8'); const changed = after.split('\n').filter((l, i) => l !== before.split('\n')[i]); expect(changed).toHaveLength(2); expect(changed[0]).toContain('"title": ["Every list,","in one tidy place"]'); expect(JSON.parse(after).tr.share.sub).toBe('Birlikte düzenleyin.'); expect(after.split('\n').length).toBe(before.split('\n').length); }); test('setByPath on JSON: insert and remove without trailing commas', () => { const j = '{\n "a": { "x": 1 },\n "b": 2\n}\n'; const ins = setByPath(j, 'f.json', 'c', 'new'); expect(JSON.parse(ins)).toEqual({ a: { x: 1 }, b: 2, c: 'new' }); expect(ins).toContain(' "b": 2,\n "c": "new"\n}'); const rem = setByPath(j, 'f.json', 'b', undefined); expect(JSON.parse(rem)).toEqual({ a: { x: 1 } }); expect(JSON.parse(setByPath(j, 'f.json', 'a.y', [1, 2]))).toEqual({ a: { x: 1, y: [1, 2] }, b: 2 }); }); });