desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case

This commit is contained in:
CRTkafa 2026-10-14 00:35:29 +08:00
parent 714352ec18
commit 3cea2af4e1
37 changed files with 3020 additions and 54 deletions

View file

@ -18,7 +18,9 @@
// condition (the element, the text, or settle(): no host call in flight and no DOM change for QUIET_MS).
// Uses playwright: its headless shell (bunx playwright install chromium-headless-shell), else Google Chrome
// (channel "chrome"), else its full Chromium. Part of `bun run ci` and of `tools/desktop-release.ts mac`.
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { mint } from '../cli/lib/confirm-token';
import { extname, join, resolve } from 'node:path';
import type { Browser, Page } from 'playwright';
import { APPLE_STATUS_FIRST_REVIEW, APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, ownerDry, PLAY_STATUS, PLAY_STATUS_FIRST_200, PLAY_STATUS_FIRST_404 } from '../apps/desktop/src/dev/submit-fixtures';
@ -49,6 +51,8 @@ class Sidecar {
private next = 1;
private pending = new Map<number, { ok: (v: unknown) => void; bad: (e: unknown) => void }>();
hello: { protocol: string; server: { version: string }; methods: { method: string; kind: string }[] } | null = null;
/** The session secret (the editor case mints the host's confirm token for project.edit with it, as the Rust host does). */
secret = Buffer.alloc(0);
constructor() {
(async () => {
@ -82,6 +86,7 @@ class Sidecar {
}
async start(): Promise<void> {
const secret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex');
this.secret = Buffer.from(secret, 'hex');
this.hello = await this.call('session.hello', { secret, client: { name: 'storebound-boot-check', version: conf.version } }) as Sidecar['hello'];
}
kind(method: string): string | undefined { return this.hello?.methods.find(m => m.method === method)?.kind; }
@ -827,6 +832,139 @@ async function runErrorCase(browser: Browser, sidecar: Sidecar, variant: 'api-di
* flush): Google Chrome's graceful close takes 10-30 s after http pages (tests/helpers/browser.ts). Playwright's
* headless shell first (fast to start), else Google Chrome (channel "chrome"), else playwright's full Chromium.
*/
/** Dry runs of the store writes the editor's Apply panels preview (no store is read: canned plans). */
const APPLY_DRY: Record<string, string> = {
'apple.listing': 'version 2.4.0: keep (PREPARE_FOR_SUBMISSION)\n en-US: update subtitle "Old subtitle" -> "Lists that stay tidy"\n tr: same',
'play.listing': 'app details: same\n en-US: same\n tr-TR: same',
};
/**
* The listing editor end to end on a copy of the demo app: the Listings tab edits a field (the counter and the store
* limit check while typing, the unsaved mark on the tab), "Review and save" shows the plan of the real sidecar
* (project.edit dry run), Save writes the file (the host's confirm token minted like the Rust host after its dialog),
* the fixture file changes and the config does not, then Apply shows the store's dry run (old -> new per language).
* Text checks only; any uncaught error fails.
*/
async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise<CaseResult> {
const name = 'listing editor (edit, counter, check, save, apply dry run)';
const freeze = conf.app.security.freezePrototype === true;
const server = serve(DIST, cspHeader(conf.app.security.csp), false);
const ctx = await browser.newContext({ viewport: { width: 1360, height: 900 }, reducedMotion: 'reduce' });
const page = await ctx.newPage();
const problems: string[] = [];
const notes: string[] = [];
const unmocked = new Set<string>();
const logged: string[] = [];
const dir = mkdtempSync(join(tmpdir(), 'sb-boot-editor-'));
cpSync(join(EXAMPLES, 'demo-app'), dir, { recursive: true });
const appstore = join(dir, 'store', 'appstore.md');
const before = { md: readFileSync(appstore, 'utf8'), cfg: readFileSync(join(dir, 'storebound.config.ts'), 'utf8') };
const fx = loadFixture('empty');
fx.projects_list = [{ id: 'editor-app', path: dir, name: 'Tidy Lists', playAccount: null, appleAccount: null, added: '2026-10-14T00:00:00Z' }];
const base = host(fx, sidecar, unmocked, logged);
const writes: string[] = [];
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'write_with_confirm' && a.method === 'project.edit') {
// the Rust host: dry run, the native dialog (answered "Save" here), a token for exactly this call, the real run
const bound = { project: dir, args: a.args as Record<string, unknown> };
const dryRun = await sidecar.call('project.edit', { ...bound, dryRun: true }) as { ok: boolean };
if (!dryRun.ok) return { confirmed: false, stage: 'dry-run', dryRun };
const result = await sidecar.call('project.edit', { ...bound, dryRun: false, confirmToken: mint(sidecar.secret, 'project.edit', bound) });
writes.push('project.edit');
return { confirmed: true, stage: 'done', dryRun, result };
}
// every other write: the owner presses Cancel in the native dialog (nothing is sent)
if (cmd === 'write_with_confirm') { writes.push(String(a.method)); return { confirmed: false, stage: 'dialog', dryRun: { ...toolResult(String(a.method), null), dryRun: true, output: APPLY_DRY[String(a.method)] ?? '' } }; }
// "Review and save": the window's dry run of project.edit (the Rust host forces dryRun for calls from the window)
if (cmd === 'sidecar_call' && a.method === 'project.edit') return sidecar.call('project.edit', { project: dir, args: (a.params as { args?: unknown } | null)?.args ?? {} });
if (cmd === 'sidecar_call' && APPLY_DRY[String(a.method)] !== undefined) return { ...toolResult(String(a.method), null), dryRun: true, output: APPLY_DRY[String(a.method)] };
return base(cmd, a);
};
await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record<string, unknown>) => {
try { return { ok: (await answer(cmd, args)) ?? null }; } catch (e) { return { err: e }; }
});
page.on('pageerror', e => problems.push(`uncaught: ${e.stack ?? e.message}`.split('\n').slice(0, 4).join(' | ')));
page.on('console', m => { if (m.type() === 'error') problems.push(`console.error: ${m.text().slice(0, 400)}`); });
await page.addInitScript(initScript(freeze));
const text = async (sel: string) => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
const field = '[data-edit="listing:apple:en-US:subtitle"]';
const col = '[data-store="apple"][data-lang="en-US"]';
try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)'));
await settle(page);
const wizard = page.locator('.wizard[role="dialog"]');
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); }
await page.locator('nav.side .nav-app', { hasText: 'Tidy Lists' }).first().click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open the app: ${String(e).split('\n')[0]}`));
await settle(page);
await page.locator('[role="tab"]#tab-listings').click({ timeout: WAIT_MS }).catch(e => problems.push(`no Listings tab: ${String(e).split('\n')[0]}`));
await page.waitForSelector(field, { timeout: WAIT_MS }).catch(() => problems.push('the listing editor has no App Store en-US subtitle field'));
// over the limit: the counter and the check say so while typing
await page.locator(field).fill('Lists that stay tidy and organized forever');
const over = await text(col);
if (!over.includes('42 / 30')) problems.push(`no live counter "42 / 30" (${over.slice(0, 200)})`);
if (!over.includes('subtitle is 42 characters; the App Store takes 30')) problems.push('no limit check while typing');
else notes.push('typing: counter 42 / 30 and "the App Store takes 30"');
if (!(await page.locator('#tab-listings .ed-dot').count())) problems.push('the Listings tab has no unsaved mark');
// undo goes back to the saved text, redo brings the edit back
await page.locator('[data-edit-bar="listings"] button', { hasText: 'Undo' }).click({ timeout: WAIT_MS });
const undone = await page.locator(field).inputValue();
if (undone === 'Lists that stay tidy and organized forever') problems.push('Undo did not change the field');
// a valid value, then review: the real sidecar's dry run in the window
await page.locator(field).fill('Lists that stay tidy');
if (!(await text(col)).includes('20 / 30')) problems.push('the counter does not follow the text (20 / 30)');
await page.locator('[data-edit-bar="listings"] [data-action="review"]').click({ timeout: WAIT_MS });
if (!(await shows(page, '[data-edit-plan]', 'Lists that stay tidy'))) problems.push(`no review plan (${await text('[data-edit-bar="listings"]')})`);
else {
const plan = await text('[data-edit-plan]');
if (!plan.includes('App Store en-US subtitle') || !plan.includes('store/appstore.md')) problems.push(`the plan does not name the field and the file (${plan.slice(0, 300)})`);
else notes.push('review: App Store en-US subtitle -> "Lists that stay tidy" in store/appstore.md');
}
await page.locator('[data-edit-bar="listings"] button', { hasText: 'Save to the app folder' }).click({ timeout: WAIT_MS })
.catch(async e => problems.push(`no enabled Save button: ${String(e).split('\n')[0]} (${(await text('[data-edit-bar="listings"]')).slice(0, 600)})`));
await page.waitForFunction(() => !document.querySelector('#tab-listings .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('the unsaved mark stays after Save'));
await settle(page);
const md = readFileSync(appstore, 'utf8');
const changed = md.split('\n').filter((l, i) => l !== before.md.split('\n')[i]);
if (!md.includes('Lists that stay tidy') || changed.length !== 1) problems.push(`store/appstore.md: expected exactly the subtitle line to change, got ${JSON.stringify(changed).slice(0, 300)}`);
else notes.push(`saved: store/appstore.md changed one line (${changed[0]})`);
if (readFileSync(join(dir, 'storebound.config.ts'), 'utf8') !== before.cfg) problems.push('storebound.config.ts changed although only a listing field was edited');
// Apply: the store's dry run (old -> new per language) in the window first, then the confirm dialog
await page.locator('#ed-version').fill('2.4.0').catch(() => problems.push('no version field for the App Store apply'));
await page.locator('[data-apply="apple.listing"] button').first().click({ timeout: WAIT_MS }).catch(e => problems.push(`no App Store apply preview: ${String(e).split('\n')[0]}`));
if (!(await shows(page, '[data-apply="apple.listing"] .ed-dry', 'en-US: update subtitle'))) problems.push('the App Store apply preview does not show the dry run per language');
else notes.push('apply: App Store dry run shows en-US subtitle old -> new');
await page.locator('[data-apply="apple.listing"] button', { hasText: 'Apply to the store' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no Apply button after the preview: ${String(e).split('\n')[0]}`));
await settle(page);
if (!writes.includes('apple.listing')) problems.push('Apply did not go through the confirm dialog (write_with_confirm)');
// the other editors draw, and a config edit (App info) saves only that value, comments kept
for (const [tab, root] of [['appinfo', '#appinfo-editor'], ['testflight', '#testflight-editor'], ['art', '#art-editor'], ['catalog', '#catalog-editor, [data-edit-bar], .card']] as const) {
await page.locator(`[role="tab"]#tab-${tab}`).click({ timeout: WAIT_MS }).catch(e => problems.push(`no ${tab} tab: ${String(e).split('\n')[0]}`));
if (!(await page.waitForSelector(root, { timeout: WAIT_MS }).then(() => true, () => false))) problems.push(`${tab}: the editor did not draw (${root})`);
await settle(page);
}
await page.locator('[role="tab"]#tab-appinfo').click({ timeout: WAIT_MS });
const notes1 = '[data-edit="config:apple.info.reviewNotes"]';
await page.waitForSelector(notes1, { timeout: WAIT_MS }).catch(() => problems.push('App info: no review notes field'));
await page.locator(notes1).fill('Sign in with the demo account.');
await page.locator('[data-edit-bar="appinfo"] [data-action="review"]').click({ timeout: WAIT_MS });
if (!(await shows(page, '[data-edit-bar="appinfo"] [data-edit-plan]', 'apple.info.reviewNotes'))) problems.push(`App info: no review plan (${(await text('[data-edit-bar="appinfo"]')).slice(0, 400)})`);
await page.locator('[data-edit-bar="appinfo"] button', { hasText: 'Save to the app folder' }).click({ timeout: WAIT_MS })
.catch(async e => problems.push(`App info: no enabled Save button: ${String(e).split('\n')[0]} (${(await text('[data-edit-bar="appinfo"]')).slice(0, 600)})`));
await page.waitForFunction(() => !document.querySelector('#tab-appinfo .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('App info: the unsaved mark stays after Save'));
const cfg = readFileSync(join(dir, 'storebound.config.ts'), 'utf8');
const cfgChanged = cfg.split('\n').filter((l, i) => l !== before.cfg.split('\n')[i]);
if (!cfg.includes("reviewNotes: 'Sign in with the demo account.'") || cfgChanged.length !== 1 || !cfg.includes('// storebound config of the demo app')) problems.push(`storebound.config.ts: expected one changed line with reviewNotes and the comments kept, got ${JSON.stringify(cfgChanged).slice(0, 300)}`);
else notes.push(`saved: storebound.config.ts changed one line (${cfgChanged[0]!.trim()})`);
for (const c of (await state(page)).crash) problems.push(`error panel ${c}`);
} finally {
await ctx.close();
server.stop(true);
}
for (const l of logged) (l.startsWith('error') ? problems : notes).push(`webview.log: ${l}`);
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited: 1, notes };
}
async function launch(): Promise<{ browser: Browser; kill: () => Promise<void> }> {
const { chromium } = await import('playwright');
const opts = { headless: true, timeout: 60_000 };
@ -858,6 +996,7 @@ export async function bootCheck(): Promise<CaseResult[]> {
for (const p404 of [true, false]) if (!ONLY.length || ONLY.includes('release-state')) cases.push(() => runReleaseStateCase(browser, sidecar, p404));
for (const v of ['api-disabled', 'scan-findings', 'scan-failed'] as const) if (!ONLY.length || ONLY.includes('errors') || ONLY.includes(`errors-${v}`)) cases.push(() => runErrorCase(browser, sidecar, v));
if (!ONLY.length || ONLY.includes('auto-read')) cases.unshift(() => runAutoReadCase(browser));
if (!ONLY.length || ONLY.includes('editor')) cases.push(() => runEditorCase(browser, sidecar));
const results: CaseResult[] = new Array(cases.length);
let next = 0;
try {