store-only apps edit their listing on the store (storebound listing live / set: old -> new per field with the checks, a PATCH of only the edited fields; serve listing.set is the one store-only write, its token bound to the store ids; policy.rs STORE_ONLY_WRITES, write_with_confirm for store targets); screenshot captions per language and scene edited as a text edit of the shots copy JSON (JSON files keep their layout); Play content rating, target audience and App content answers in App info; docs/console-parity.md after the editors (73 rows: 55 complete, 18 with the reason) and tests/console-parity.test.ts keeping it honest

This commit is contained in:
CRTkafa 2026-10-14 00:53:38 +08:00
parent 3cea2af4e1
commit 7ac028421a
33 changed files with 852 additions and 157 deletions

View file

@ -0,0 +1,33 @@
// docs/console-parity.md is the parity checklist of the desktop against App Store Connect and Play Console: every row
// is edited in the window ("Edit" yes) or names its reason, no row is left as an open gap, and every desktop method a
// row names exists in the sidecar (so a renamed or removed method cannot leave the table claiming a write).
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describeMethods } from '../cli/serve';
const DOC = readFileSync(join(import.meta.dir, '..', 'docs', 'console-parity.md'), 'utf8');
const rows = DOC.split('\n').filter(l => /^\| /.test(l) && !/^\| (Console screen|What) \|/.test(l) && !/^\| ---/.test(l))
.map(l => l.slice(2, -2).split(' | ').map(c => c.trim()));
describe('console parity checklist', () => {
test('every row: edited in the window, or the reason why not', () => {
expect(rows.length).toBeGreaterThan(60);
for (const r of rows) {
expect(r, r[0]).toHaveLength(6);
const [screen, , , edit, write, gap] = r as [string, string, string, string, string, string];
if (gap === 'none' || gap.startsWith('none ')) {
// nothing missing: edited (or nothing to edit) and written where the store has an API
expect(edit, screen).not.toBe('no');
expect(write, screen).not.toMatch(/^no\b/);
} else expect(gap, screen).toMatch(/^reason: \S/);
expect(gap, screen).not.toMatch(/no editor|not in the window$|not shown/);
}
});
test('the desktop methods the table names exist', () => {
const methods = new Set(describeMethods().map(m => m.method));
const named = [...DOC.matchAll(/`((?:apple|play|owner|project|listing|shots|reviews|prices|console)\.[a-z_]+)`/g)].map(m => m[1]!);
expect(named.length).toBeGreaterThan(20);
for (const m of new Set(named)) expect(methods.has(m), m).toBe(true);
});
});

View file

@ -80,6 +80,8 @@ describe('config-path: one value set by path, the rest of the file byte for byte
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);
@ -301,3 +303,30 @@ describe('CLI: storebound edit on a copy of the demo app', () => {
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 });
});
});

View file

@ -0,0 +1,72 @@
// Store-only apps edit their listing straight on the store (`storebound listing set`, serve method listing.set): the
// plan (old -> new per field, only the fields sent are judged, the App Store version rule), and the serve rules: the
// only write a store-only app (virtual) may run, a dry run unless the host's token for exactly those store ids comes.
import { describe, expect, test } from 'bun:test';
import { mint } from '../cli/lib/confirm-token';
import { ERR } from '../cli/lib/desktop-protocol';
import { setPlan } from '../cli/listing-store';
import type { RunOptions, Running } from '../cli/lib/runner';
import { Session } from '../cli/serve';
describe('listing set: the plan', () => {
test('old -> new for the fields sent, the rest left alone', () => {
const p = setPlan('play', 'en-US', { title: 'Tidy', short: 'Old short', full: 'Full text.' }, { short: 'New short' }, { appName: 'Tidy' });
expect(p.changes).toEqual([{ field: 'short', from: 'Old short', to: 'New short' }]);
expect(p.issues.filter(i => i.level === 'error')).toEqual([]);
expect(setPlan('play', 'en-US', { short: 'Same' }, { short: 'Same ' }, { appName: 'A' }).changes).toEqual([]);
});
test('limits and rules of the sent fields; a new language', () => {
const p = setPlan('apple', 'en-US', null, { subtitle: 'x'.repeat(31) }, { appName: 'Tidy', editable: true });
expect(p.create).toBe(true);
expect(p.issues.map(i => [i.field, i.level])).toContainEqual(['subtitle', 'error']);
// required fields the run does not send are not this run's problem
expect(p.issues.some(i => i.field === 'keywords')).toBe(false);
});
test('App Store: without an editable version only name, subtitle and promotional text', () => {
expect(setPlan('apple', 'en-US', { promotionalText: 'a' }, { promotionalText: 'b' }, { appName: 'A', editable: false }).refused).toBeNull();
expect(setPlan('apple', 'en-US', { description: 'a' }, { description: 'b' }, { appName: 'A', editable: false }).refused).toMatch(/no editable App Store version/);
});
});
const SECRET_HEX = Array.from({ length: 32 }, (_, i) => (i + 3).toString(16).padStart(2, '0')).join('');
const SECRET = Buffer.from(SECRET_HEX, 'hex');
type J = Record<string, unknown>;
async function session(now = () => Date.now()) {
const calls: { argv: string[]; o: RunOptions }[] = [];
const run = (argv: string[], o: RunOptions): Running => { calls.push({ argv, o }); return { result: Promise.resolve({ code: 0, out: 'done', cancelled: false }), cancel: () => {} }; };
const s = new Session(() => {}, run, now);
let id = 0;
const call = async (method: string, params: unknown = {}) => (await s.handle({ jsonrpc: '2.0', id: ++id, method, params }))! as J;
await call('session.hello', { secret: SECRET_HEX });
return { calls, call };
}
const err = (r: J) => r.error as { code: number; message: string } | undefined;
describe('serve: listing.set is the one write of a store-only app', () => {
const virt = { name: 'com.example.tidy', play: { packageName: 'com.example.tidy' } };
const args = { store: 'play', lang: 'en-US', fields: JSON.stringify({ short: 'Lists that stay tidy' }) };
test('dry run with the store ids, the real run only with a token bound to them', async () => {
const now = 5_000_000;
const s = await session(() => now);
const dry = await s.call('listing.set', { virtual: virt, args });
expect(err(dry)).toBeUndefined();
expect(s.calls.at(-1)!.argv).toEqual(['listing', 'set', '--store', 'play', '--lang', 'en-US', '--fields', args.fields, '--dry', '--json']);
expect(err(await s.call('listing.set', { virtual: virt, args, dryRun: false }))!.code).toBe(ERR.confirmRequired);
// a token for a folder, or for another app's store ids, does not fit
expect(err(await s.call('listing.set', { virtual: virt, args, dryRun: false, confirmToken: mint(SECRET, 'listing.set', { project: '/x', args }, { now }) }))!.code).toBe(ERR.confirmInvalid);
expect(err(await s.call('listing.set', { virtual: virt, args, dryRun: false, confirmToken: mint(SECRET, 'listing.set', { virtual: { ...virt, name: 'other' }, args }, { now }) }))!.code).toBe(ERR.confirmInvalid);
const real = await s.call('listing.set', { virtual: virt, args, dryRun: false, confirmToken: mint(SECRET, 'listing.set', { virtual: virt, args }, { now }) });
expect(err(real)).toBeUndefined();
expect(s.calls.at(-1)!.argv).not.toContain('--dry');
});
test('folder writes and other store writes stay refused for store-only apps; listing.live reads', async () => {
const s = await session();
for (const m of ['play.listing', 'apple.listing', 'project.edit', 'play.images']) {
const r = await s.call(m, { virtual: virt, args: m === 'project.edit' ? { ops: '[]' } : {} });
expect(err(r)?.message, m).toMatch(/needs an app folder/);
}
expect(err(await s.call('listing.live', { virtual: virt }))).toBeUndefined();
expect(s.calls.at(-1)!.argv).toEqual(['listing', 'live', '--json']);
});
});

View file

@ -238,7 +238,7 @@ describe('serve: store-only apps (virtual)', () => {
const r = await s.call('reviews.list', { virtual: V, args: { days: 30 }, credentials: APPLE });
expect(r.error).toBeUndefined();
expect(existsSync(join(s.calls[0]!.o.cwd, 'storebound.config.json'))).toBe(true);
expect([...VIRTUAL_READS].sort()).toEqual(['apple_ppo_status', 'apple_status', 'apple_testflight_status', 'health', 'health_issue', 'play_status', 'reviews_list']);
expect([...VIRTUAL_READS].sort()).toEqual(['apple_ppo_status', 'apple_status', 'apple_testflight_status', 'health', 'health_issue', 'listing_live', 'play_status', 'reviews_list']);
for (const m of ['play.listing', 'catalog.diff', 'project.listings', 'check', 'reviews.approve'])
expect((await s.call(m, { virtual: V, args: m === 'reviews.approve' ? { file: 'apple-1.md' } : {} })).error).toMatchObject({ code: ERR.invalidParams });
expect((await s.call('health', { virtual: V, project: '/tmp' })).error).toMatchObject({ code: ERR.invalidParams });