storebound/tests/edit.test.ts

303 lines
17 KiB
TypeScript

// 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 },");
});
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: en-US, en-GB -->
### 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-'));
cpSync(join(ROOT, 'examples', 'demo-app'), dir, { recursive: true });
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);
});
});