store art from the live stores: storebound art pull (App Store screenshot sets and previews per locale and display type from imageAsset.templateUrl at the original size, the App Store icon from the newest build or the public artwork; per Play language icon, feature graphic, phone / 7" / 10" / TV / Wear screenshots as the =s0 originals checked against Play's sha256) into store/renders with pulled.json (sha256, store, URL host, store checksum); upsert (a re-run changes nothing, differing local files kept unless --force, the upload's file of a slot compared: Play .jpg, App Store .png, the App Store icon as a picture); onboard runs it; apple screenshots / previews and play images see unchanged pulled files as the store's own; check: render sources are notes with pulled art; local renders check: pulled files pass, store-empty sets not demanded; art live / thumb / replace; desktop Store art tab: live thumbnails per store, language and set (same / differs / not pulled), Pull from stores, store-only apps read-only, Replace with a file (image_pick, art.replace) in the lightbox and the art editor; boot check store art case; tests/art-pull.test.ts; console parity 76 rows (57 complete, 19 with the reason); store-screenshots skill (pull first for live apps), pitfall 59
This commit is contained in:
parent
49a73c90e4
commit
00be838b76
40 changed files with 1669 additions and 58 deletions
|
|
@ -18,7 +18,7 @@
|
|||
// 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 { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { mint } from '../cli/lib/confirm-token';
|
||||
import { extname, join, resolve } from 'node:path';
|
||||
|
|
@ -968,6 +968,131 @@ async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise<CaseRe
|
|||
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited: 1, notes };
|
||||
}
|
||||
|
||||
/** art.live of the store-art case: what the stores have live (made-up thumbnails on the store CDNs). */
|
||||
const LIVE_ART = {
|
||||
generated: '2026-10-14T09:00:00.000Z',
|
||||
stores: [{ store: 'apple', ok: true, version: '2.4.0 (READY_FOR_SALE)', images: 3 }, { store: 'play', ok: true, images: 3 }],
|
||||
icons: [{ store: 'apple', index: 0, type: 'icon', w: 1024, h: 1024, thumb: 'https://is1-ssl.mzstatic.com/image/thumb/example/icon.png/256x256bb.jpg', state: 'missing' }],
|
||||
sets: [
|
||||
{ store: 'apple', locale: 'en-US', lang: 'en', kind: 'screenshot', type: 'APP_IPHONE_67', size: 'ios69', label: 'App Store iPhone 6.9"', tiles: [
|
||||
{ index: 0, type: 'APP_IPHONE_67', w: 1320, h: 2868, thumb: 'https://is1-ssl.mzstatic.com/image/thumb/example/en1.png/138x300bb.jpg', state: 'missing' },
|
||||
{ index: 1, type: 'APP_IPHONE_67', w: 1320, h: 2868, thumb: 'https://is1-ssl.mzstatic.com/image/thumb/example/en2.png/138x300bb.jpg', state: 'missing' }] },
|
||||
{ store: 'play', locale: 'en-US', lang: 'en', kind: 'screenshot', type: 'phoneScreenshots', size: 'play', label: 'Play phone', tiles: [
|
||||
{ index: 0, type: 'phoneScreenshots', w: 540, h: 960, thumb: 'https://lh3.googleusercontent.com/example-en-1=w300', state: 'same', file: 'store/renders/en/play/01.png' },
|
||||
{ index: 1, type: 'phoneScreenshots', w: 540, h: 960, thumb: 'https://lh3.googleusercontent.com/example-en-2=w300', state: 'differs', file: 'store/renders/en/play/02.png' }] },
|
||||
],
|
||||
notes: [], pulled: true,
|
||||
};
|
||||
/** The dry run of art.pull the native dialog shows (canned: the boot check reads no store). */
|
||||
const ART_PULL_DRY = 'add store/renders/en/ios69/ 2 files (01.png .. 02.png) not in the project yet\nconflict store/renders/en/play/02.png your own file differs from the store\ndry run: would write 2 new, 0 changed, 0 removed, 1 same as the store, 1 kept (differ) (run with --write)';
|
||||
/** A 1x1 PNG as the thumbnail art.thumb answers. */
|
||||
const THUMB = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC';
|
||||
|
||||
/**
|
||||
* The Store art tab of an app folder with pulled art: the live store art (art.live, canned) draws its thumbnails
|
||||
* (art.thumb data: URLs) with same / differs / not pulled per image, "Pull from stores" goes through the confirm dialog
|
||||
* with its dry run (answered Cancel), and "Replace with a file" on a pulled render picks a file (image_pick) and runs
|
||||
* the real sidecar's art.replace dry run (size and checks) before the dialog. Text checks; any uncaught error fails.
|
||||
*/
|
||||
async function runLiveArtCase(browser: Browser, sidecar: Sidecar): Promise<CaseResult> {
|
||||
const name = 'store art (live thumbnails, pull dry run, replace with a file)';
|
||||
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-art-'));
|
||||
cpSync(join(EXAMPLES, 'demo-app'), dir, { recursive: true });
|
||||
// a pulled Play screenshot in the renders folder (a real PNG, so the gallery and the replace dry run read it)
|
||||
const { encodePng } = await import('../tests/build-fixtures');
|
||||
const shot = encodePng(540, 960, () => [40, 120, 200], { colorType: 2 });
|
||||
mkdirSync(join(dir, 'store/renders/en/play'), { recursive: true });
|
||||
writeFileSync(join(dir, 'store/renders/en/play/01.png'), shot);
|
||||
writeFileSync(join(dir, 'store/renders/en/play/02.png'), shot);
|
||||
const picked = join(dir, 'picked-new.png');
|
||||
writeFileSync(picked, encodePng(540, 960, () => [200, 80, 40], { colorType: 2 }));
|
||||
const fx = loadFixture('empty');
|
||||
fx.projects_list = [{ id: 'art-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: { method: string; args: unknown; dry?: string }[] = [];
|
||||
const thumbs: string[] = [];
|
||||
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
|
||||
if (cmd === 'sidecar_call' && a.method === 'art.live') return toolResult('art.live', LIVE_ART);
|
||||
if (cmd === 'sidecar_call' && a.method === 'art.thumb') { thumbs.push(String((a.params as { args?: { url?: string } } | null)?.args?.url)); return toolResult('art.thumb', { dataUrl: THUMB, bytes: 68 }); }
|
||||
if (cmd === 'image_pick') return picked;
|
||||
if (cmd === 'write_with_confirm') {
|
||||
const method = String(a.method);
|
||||
if (method === 'art.replace') {
|
||||
// the Rust host: the real dry run first (the native dialog shows it), then the owner presses Cancel
|
||||
const dryRun = await sidecar.call('art.replace', { project: dir, args: a.args as Record<string, unknown>, dryRun: true }) as { ok: boolean; output: string; data?: unknown };
|
||||
writes.push({ method, args: a.args, dry: JSON.stringify(dryRun.data ?? dryRun.output) });
|
||||
return { confirmed: false, stage: dryRun.ok ? 'dialog' : 'dry-run', dryRun };
|
||||
}
|
||||
writes.push({ method, args: a.args, dry: method === 'art.pull' ? ART_PULL_DRY : undefined });
|
||||
return { confirmed: false, stage: 'dialog', dryRun: { ...toolResult(method, null), dryRun: true, output: method === 'art.pull' ? ART_PULL_DRY : '' } };
|
||||
}
|
||||
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, ' ');
|
||||
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-art').click({ timeout: WAIT_MS }).catch(e => problems.push(`no Store art tab: ${String(e).split('\n')[0]}`));
|
||||
await page.waitForSelector('#live-art', { timeout: WAIT_MS }).catch(() => problems.push('no live store art card'));
|
||||
// nothing is read from the stores until asked (auto-read is off in the saved state)
|
||||
await page.locator('#live-art button', { hasText: 'Read the stores' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no "Read the stores" button: ${String(e).split('\n')[0]}`));
|
||||
const drawn = await page.waitForFunction(() => document.querySelectorAll('#live-art [data-live-tile] img[src^="data:image/"]').length >= 2, null, { timeout: WAIT_MS, polling: 50 }).then(() => true, () => false);
|
||||
if (!drawn) problems.push(`live thumbnails not drawn (${(await text('#live-art')).slice(0, 300)})`);
|
||||
const live = await text('#live-art');
|
||||
for (const want of ['Live on the stores', 'App Store iPhone 6.9"', 'not pulled yet', 'App Store: 3 images']) if (!live.includes(want)) problems.push(`live art: no "${want}" (${live.slice(0, 300)})`);
|
||||
await page.locator('#live-art button[role="tab"]', { hasText: 'Google Play' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no Google Play store switch: ${String(e).split('\n')[0]}`));
|
||||
if (!(await shows(page, '#live-art', 'yours differs')) || !(await shows(page, '#live-art', 'same as the store'))) problems.push('live art: no same / differs state on the Play set');
|
||||
if (thumbs.some(u => !/^https:\/\/(is1-ssl\.mzstatic\.com|lh3\.googleusercontent\.com)\//.test(u))) problems.push(`a thumbnail from another host: ${thumbs.join(', ')}`);
|
||||
else notes.push(`live: ${thumbs.length} thumbnails as data: URLs, same / differs / not pulled per image`);
|
||||
// Pull from stores: the confirm dialog with the dry run (answered Cancel)
|
||||
await page.locator('#live-art button', { hasText: 'Pull from stores' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no "Pull from stores" button: ${String(e).split('\n')[0]}`));
|
||||
await settle(page);
|
||||
const pull = writes.find(x => x.method === 'art.pull');
|
||||
if (!pull) problems.push('Pull from stores did not go through the confirm dialog (write_with_confirm art.pull)');
|
||||
else notes.push(`pull: dialog with the dry run (${pull.dry!.split('\n').pop()})`);
|
||||
// Replace with a file: the pulled render's lightbox, the picker, the real art.replace dry run
|
||||
const tile = page.locator('.art-rows .art-tile button.art-pic').first();
|
||||
await tile.click({ timeout: WAIT_MS }).catch(async e => problems.push(`no rendered tile to open: ${String(e).split('\n')[0]} (${(await text('#tabpanel')).slice(0, 300)})`));
|
||||
await page.locator('[data-action="replace-image"]').click({ timeout: WAIT_MS }).catch(e => problems.push(`no "Replace with a file" in the lightbox: ${String(e).split('\n')[0]}`));
|
||||
await settle(page);
|
||||
const rep = writes.find(x => x.method === 'art.replace');
|
||||
if (!rep) problems.push('Replace with a file did not reach the confirm dialog (write_with_confirm art.replace)');
|
||||
else {
|
||||
const args = rep.args as { from?: string; to?: string };
|
||||
if (args.to !== 'store/renders/en/play/01.png' || args.from !== picked) problems.push(`art.replace args: ${JSON.stringify(args)}`);
|
||||
if (!/"new":\{"w":540,"h":960/.test(rep.dry ?? '')) problems.push(`art.replace dry run: ${rep.dry?.slice(0, 300)}`);
|
||||
else notes.push('replace: picked file -> store/renders/en/play/01.png, the sidecar dry run 540x960 -> 540x960 (dialog: Cancel)');
|
||||
}
|
||||
if (readFileSync(join(dir, 'store/renders/en/play/01.png')).compare(shot) !== 0) problems.push('the render changed although the dialog was cancelled');
|
||||
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}`);
|
||||
if (unmocked.size) notes.push(`not answered (the window handled the rejection): ${[...unmocked].join(', ')}`);
|
||||
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 };
|
||||
|
|
@ -1000,6 +1125,7 @@ export async function bootCheck(): Promise<CaseResult[]> {
|
|||
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));
|
||||
if (!ONLY.length || ONLY.includes('art')) cases.push(() => runLiveArtCase(browser, sidecar));
|
||||
const results: CaseResult[] = new Array(cases.length);
|
||||
let next = 0;
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue