// storebound listing translate / check / reviewed: per-language listing drafts in both listing files, store limits, // keyword rules, the --translate hook and the review marker. import { afterAll, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy'; import { appleFields, checkFields, findAppleSection, findPlaySection, markerOf, normalizeKeywords, playFields, putSection, unmark } from '../cli/lib/listing-translate'; const ROOT = join(import.meta.dir, '..'); const CLI = join(ROOT, 'cli', 'index.ts'); const tmp = mkdtempSync(join(tmpdir(), 'storebound-listing-')); afterAll(() => rmSync(tmp, { recursive: true, force: true })); const sb = (cwd: string, ...args: string[]) => Bun.spawnSync([process.execPath, CLI, ...args], { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' }); describe('rules', () => { test('keywords: commas, duplicates, name / subtitle words, whole keywords cut at 100', () => { const r = normalizeKeywords('Liste , notizen,liste、 planer,Aufgaben,,familie', { name: 'Listen App', subtitle: 'Notizen und mehr' }); expect(r.text).toBe('Liste,planer,Aufgaben,familie'); expect(r.dropped).toEqual([{ kw: 'notizen', why: 'already in the name or subtitle' }, { kw: 'liste', why: 'duplicate' }]); const long = normalizeKeywords(Array.from({ length: 30 }, (_, i) => `keyword${i}`).join(',')); expect(long.text.length).toBeLessThanOrEqual(100); expect(long.text.endsWith(',')).toBe(false); expect(long.dropped.at(-1)!.why).toBe('over 100'); // multi-byte: 100 characters, not bytes (App Store Connect counts characters; pitfall 43) const ja = normalizeKeywords(Array.from({ length: 30 }, (_, i) => `日本語${i}`).join(',')).text; expect([...ja].length).toBeLessThanOrEqual(100); expect(Buffer.byteLength(ja)).toBeGreaterThan(100); }); test('limits per store field, keyword rules, untranslated and identical text', () => { const c = checkFields('apple', { subtitle: 'x'.repeat(31), keywords: 'a, b,a', description: 'Same long description text' }, { description: 'Same long description text' }, 'draft'); expect(c.find(x => x.field === 'subtitle')!.problems).toEqual(['31 > 30 characters']); expect(c.find(x => x.field === 'keywords')!.problems.join()).toContain('spaces around commas'); expect(c.find(x => x.field === 'keywords')!.problems.join()).toContain('duplicates: a'); expect(c.find(x => x.field === 'description')!.problems).toEqual(['identical to the source language']); expect(checkFields('play', { short: 's'.repeat(81), full: 'ok' }).map(x => x.problems)).toEqual([['81 > 80 characters'], []]); expect(checkFields('play', { short: 'short' }, {}, 'translate')[0]!.problems).toEqual(['not translated yet (status=translate)']); }); test('sections: replace in place keeps the rest, markers come off after review', () => { const md = '# X\n\n## en-US · English\n\n### subtitle\n```\nA\n```\n\n---\n\n## tr · Türkçe\n\n### subtitle\n```\nB\n```\n'; const out = putSection(md, findAppleSection(md, 'en-US'), '## en-US · English\n\n\n### subtitle\n```\nC\n```\n'); expect(out).toContain('```\nC\n```\n\n---\n\n## tr'); expect(markerOf(findAppleSection(out, 'en-US')!)).toEqual({ from: 'tr', status: 'draft' }); expect(unmark(findAppleSection(out, 'en-US')!)).toBe('## en-US · English\n\n### subtitle\n```\nC\n```\n\n---\n\n'); }); }); describe('listing commands', () => { const dir = join(tmp, 'app'); mkdirSync(dir); sb(dir, 'init', '--profile', 'app'); const cfg = join(dir, 'storebound.config.ts'); writeFileSync(cfg, readFileSync(cfg, 'utf8').replace("{ id: 'tr', play: 'tr-TR', apple: 'tr' },", "{ id: 'tr', play: 'tr-TR', apple: 'tr' },\n { id: 'de', play: 'de-DE', apple: 'de-DE' },\n { id: 'ja', play: 'ja-JP', apple: 'ja' },")); const apple = join(dir, 'store/appstore.md'), play = join(dir, 'store/listing.md'); test('without a hook: source text copied in as status=translate, check fails until translated', () => { const r = sb(dir, 'listing', 'translate', '--to', 'de-DE,ja', '--json'); expect(r.exitCode).toBe(0); const j = JSON.parse(r.stdout.toString()); expect(j.source).toBe('en'); expect(j.results.map((x: { lang: string; store: string; action: string }) => `${x.lang}/${x.store}/${x.action}`)).toEqual( ['de/apple/added (status=translate)', 'de/play/added (status=translate)', 'ja/apple/added (status=translate)', 'ja/play/added (status=translate)']); const md = readFileSync(apple, 'utf8'); expect(markerOf(findAppleSection(md, 'ja')!)).toEqual({ from: 'en-US', status: 'translate' }); expect(appleFields(findAppleSection(md, 'ja')!).keywords).toBe(appleFields(findAppleSection(md, 'en-US')!).keywords); expect(Object.keys(parseAppStoreListing(md))).toEqual(['en-US', 'tr', 'de-DE', 'ja']); expect(parsePlayListing(readFileSync(play, 'utf8'), 'Example App').map(c => c.lang)).toEqual(['en-US', 'tr-TR', 'de-DE', 'ja-JP']); const c = sb(dir, 'listing', 'check'); expect(c.exitCode).toBe(1); expect(c.stdout.toString()).toContain('TO TRANSLATE'); expect(sb(dir, 'listing', 'reviewed', '--lang', 'ja').stderr.toString()).toContain('still status=translate'); const check = sb(dir, 'check'); expect(check.stderr.toString()).toContain('not translated yet (status=translate): de-DE, ja'); }); test('with --translate: one call per field with the env, keywords cleaned up, drafts until reviewed; reviewed sections kept', () => { const hook = join(tmp, 'hook.sh'); writeFileSync(hook, '#!/bin/sh\nif [ "$STOREBOUND_FIELD" = keywords ]; then echo "Liste, liste,notizen,$STOREBOUND_TARGET_LANG"; else printf "[%s %s %s %s] " "$STOREBOUND_STORE" "$STOREBOUND_FIELD" "$STOREBOUND_TARGET_LANG" "$STOREBOUND_LIMIT"; tr a-z A-Z; fi\n'); const r = sb(dir, 'listing', 'translate', '--to', 'de', '--translate', `sh ${hook}`); expect(r.exitCode).toBe(0); const sec = findAppleSection(readFileSync(apple, 'utf8'), 'de-DE')!; expect(markerOf(sec)!.status).toBe('draft'); const f = appleFields(sec); expect(f.subtitle).toBe('[apple subtitle de-DE 30] LISTS AND NOTES, IN SYNC'); expect(f.keywords).toBe('Liste,notizen,de-DE'); expect(r.stdout.toString()).toContain('dropped "liste" (duplicate)'); const p = playFields(findPlaySection(readFileSync(play, 'utf8'), ['de-DE'])!); expect(p.short).toStartWith('[play short de-DE 80] '); expect(p.whatsNew).toBeUndefined(); const c = sb(dir, 'listing', 'check', '--json'); const j = JSON.parse(c.stdout.toString()); expect(j.drafts).toEqual(['apple:de-DE', 'play:de-DE']); expect(sb(dir, 'listing', 'reviewed', '--lang', 'de').stdout.toString()).toContain('2 section(s) marked reviewed'); expect(markerOf(findAppleSection(readFileSync(apple, 'utf8'), 'de-DE')!)).toBeNull(); // a reviewed section is kept unless --force const again = JSON.parse(sb(dir, 'listing', 'translate', '--to', 'de', '--translate', `sh ${hook}`, '--json').stdout.toString()); expect(again.results.map((x: { action: string }) => x.action)).toEqual(['kept (reviewed; --force replaces it)', 'kept (reviewed; --force replaces it)']); expect(sb(dir, 'listing', 'translate', '--to', 'en').stderr.toString()).toContain('source language'); expect(sb(dir, 'listing', 'translate', '--to', 'xx').stderr.toString()).toContain('not a language of the config'); }); }); test('listing check counts App Store keywords in characters, like App Store Connect (pitfall 43)', () => { const ru = 'игра,бег,прыжки,монеты,уровни,рекорд,аркада,быстро'; // 50 characters, 93 bytes const kw = checkFields('apple', { keywords: ru, subtitle: 'Быстрая аркада' }).find(c => c.field === 'keywords')!; expect(kw).toMatchObject({ chars: 50, unit: 'chars', problems: [] }); // live shape: a Thai field of 95 characters / 265 bytes, READY_FOR_SALE on the App Store const th = [...'ปลา,ตกปลา,เกมปลา,ทะเล,มหาสมุทร,ตู้ปลา,สัตว์น้ำ,ผ่อนคลาย,สะสม,ฉลาม,ปลาวาฬ,ปะการัง,ดำน้ำ,ฟาร์มปลา,เลี้ยงปลา'].slice(0, 95).join(''); expect([...th].length).toBe(95); expect(Buffer.byteLength(th)).toBeGreaterThan(100); expect(checkFields('apple', { keywords: th }).find(c => c.field === 'keywords')!.problems).toEqual([]); // Turkish 100 characters / 105 bytes (live) passes; 101 characters does not const tr = 'balık,akvaryum,okyanus,deniz,köpekbalığı,mercan,dalış,sakin,rahatlatıcı,koleksiyon,çiftlik,yüzme,su'.slice(0, 100).padEnd(100, 'a'); expect([...tr].length).toBe(100); expect(Buffer.byteLength(tr)).toBeGreaterThan(100); expect(checkFields('apple', { keywords: tr }).find(c => c.field === 'keywords')!.problems).toEqual([]); expect(checkFields('apple', { keywords: tr + 'b' }).find(c => c.field === 'keywords')!.problems).toContain('101 > 100 characters'); expect(checkFields('apple', { subtitle: 'Быстрая аркада' })[0]).toMatchObject({ chars: 14, unit: 'chars' }); });