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:
parent
3cea2af4e1
commit
7ac028421a
33 changed files with 852 additions and 157 deletions
33
tests/console-parity.test.ts
Normal file
33
tests/console-parity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
72
tests/listing-store.test.ts
Normal file
72
tests/listing-store.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue