170 lines
12 KiB
TypeScript
170 lines
12 KiB
TypeScript
// 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<!-- play: en-US -->\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<string, string>; peerDependencies: Record<string, string>; peerDependenciesMeta: Record<string, { optional: boolean }>; devDependencies: Record<string, string> };
|
|
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;/);
|
|
});
|
|
});
|