README, docs and site: one story (the whole job, no avoidable rejections, the desktop app, your AI, install)
README is a short front page (about 120 lines) with one real visual per section; every former section moved unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth, studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools: tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
This commit is contained in:
parent
deb218943c
commit
710c3a53d0
63 changed files with 2742 additions and 1782 deletions
|
|
@ -2,7 +2,7 @@
|
|||
// Renders the diagrams in docs/diagrams/*.mmd with the Mermaid CLI (@mermaid-js/mermaid-cli, a devDependency; never
|
||||
// shipped). Plain SVG text (no HTML labels), a fixed dark canvas so it reads the same on light and dark pages.
|
||||
// bun tools/diagrams.ts
|
||||
// journey.mmd -> docs/img/journey.svg (README), site/img/journey.svg (wide screens) and site/img/journey-tall.svg
|
||||
// journey.mmd -> docs/img/journey.svg (docs/journey.md), site/img/journey.svg (wide screens) and site/img/journey-tall.svg
|
||||
// (phones: every row turned into a column). Chrome: STOREBOUND_CHROME, Playwright's Chromium, or Google Chrome.
|
||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
|
|||
230
tools/site-desktop-shots.ts
Normal file
230
tools/site-desktop-shots.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
#!/usr/bin/env bun
|
||||
// Real frames of the storebound desktop app for the website and the README: the Vite dev server of apps/desktop in
|
||||
// fixture mode (VITE_FIXTURES=1: made-up example.com-style data for the demo game "Prism Puddle" and the other demo
|
||||
// apps, no Tauri, no store accounts), captured headlessly at 1280x800 @2x in both themes, with the two dev-build
|
||||
// markers hidden (they are not in a release build). Nothing is sent anywhere: the owner actions and console plans
|
||||
// only show the fixture's dry run; no "Continue" or final button is pressed.
|
||||
//
|
||||
// Views (each as <view>-light.webp and <view>-dark.webp in site/img/desktop/):
|
||||
// portfolio Portfolio: every app of every studio, both stores
|
||||
// send-for-review Prism Puddle > Releases > "Send for review": build 52 picked, "Submit for review" pressed, so the
|
||||
// plan panel (dry run table + "Continue" confirm) is open
|
||||
// checks Prism Puddle > Checks: the "Rejection risks" card (scan findings) at the top
|
||||
// growth Prism Puddle > Growth: the store analytics chart with the pointer mid-chart (crosshair + tooltip)
|
||||
// catalog Prism Puddle > Catalog: the parity matrix, every product on both stores
|
||||
// prices Prism Puddle > Prices: the "Products x key territories" matrix
|
||||
// testflight Prism Puddle > TestFlight
|
||||
// console-plan Prism Puddle > Releases > Google Play card > "Send changes for review": the console plan panel
|
||||
// (steps, answer sheet, never-press list)
|
||||
//
|
||||
// Each view is checked in the page (expected text visible, every '@' address in the visible text on example.com, zero
|
||||
// page errors; the transient toasts are hidden); the PNGs go to a temp dir, cwebp encodes them (q 76, lowered to 65
|
||||
// at most until a file is <= 150 KB). SHOTS_DEBUG=1 prints the text inside each frame (for alt texts).
|
||||
// Needs cwebp (brew install webp) and the desktop app's dependencies (bun install in apps/desktop).
|
||||
// Run: bun tools/site-desktop-shots.ts (SHOTS_TMP=<dir> to keep the PNGs somewhere else)
|
||||
import { mkdirSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeBrowser, launch } from '../cli/shots/browser';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const OUT = join(ROOT, 'site/img/desktop');
|
||||
const VIEW = { width: 1280, height: 800 };
|
||||
const MAX_BYTES = 150 * 1024;
|
||||
const Q_START = 76;
|
||||
const Q_MIN = 65;
|
||||
const WAIT = 15_000;
|
||||
const THEMES = ['light', 'dark'] as const;
|
||||
type Theme = (typeof THEMES)[number];
|
||||
const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [site-shots] ${m}`);
|
||||
|
||||
/** A view: the start route, what to do before the shot, and text that must be visible in it. */
|
||||
interface View { name: string; start: string; prepare: (page: any) => Promise<void>; expect: string[] }
|
||||
|
||||
const scrollTo = async (loc: any, offset: number): Promise<void> => {
|
||||
await loc.first().waitFor({ state: 'visible', timeout: WAIT });
|
||||
await loc.first().evaluate((el: HTMLElement, off: number) => {
|
||||
el.scrollIntoView({ block: 'start' });
|
||||
document.getElementById('content')?.scrollBy({ top: -off });
|
||||
}, offset);
|
||||
};
|
||||
/** The send-for-review section of the Releases tab, its store state read (fixture answers). */
|
||||
const submitSection = async (page: any): Promise<void> => {
|
||||
const section = page.locator('section[data-section="submit"]');
|
||||
await scrollTo(section, 20);
|
||||
// store reads run on demand: press each card's "Read state" button if it has not been read yet
|
||||
for (const title of ['App Store: submit for review', 'Google Play: production release']) {
|
||||
const card = section.locator('.card').filter({ hasText: title }).first();
|
||||
await card.waitFor({ state: 'visible', timeout: WAIT });
|
||||
const read = card.locator('button', { hasText: 'Read state' });
|
||||
if (await read.count()) await read.first().click({ timeout: WAIT });
|
||||
await card.locator('select').first().waitFor({ state: 'visible', timeout: WAIT });
|
||||
}
|
||||
};
|
||||
|
||||
const VIEWS: View[] = [
|
||||
{ name: 'portfolio', start: 'portfolio', expect: ['Portfolio', 'Prism Puddle', 'Tidy Lists', 'Orbit Hop'], prepare: async () => {} },
|
||||
{
|
||||
name: 'send-for-review', start: 'app:releases', expect: ['Send for review', 'Submit for review', 'Plan (dry run, nothing sent yet)', 'Continue: Submit for review', 'build 52'],
|
||||
prepare: async page => {
|
||||
await submitSection(page);
|
||||
const apple = page.locator('section[data-section="submit"] .card').filter({ hasText: 'App Store: submit for review' }).first();
|
||||
await apple.locator('select').first().selectOption('52', { timeout: WAIT });
|
||||
await apple.locator('button', { hasText: 'Submit for review' }).first().click({ timeout: WAIT });
|
||||
await page.locator('.oa-panel .plan-table').waitFor({ state: 'visible', timeout: WAIT });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'checks', start: 'app:checks', expect: ['Checks', 'Rejection risks'],
|
||||
prepare: async page => {
|
||||
const first = page.locator('#content .card').first();
|
||||
await first.waitFor({ state: 'visible', timeout: WAIT });
|
||||
if (!((await first.innerText({ timeout: WAIT })) as string).includes('Rejection risks')) throw new Error('checks: the first card is not "Rejection risks"');
|
||||
await first.locator('.li').first().waitFor({ state: 'visible', timeout: WAIT });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'growth', start: 'app:growth', expect: ['Growth', 'Store analytics'],
|
||||
prepare: async page => {
|
||||
await scrollTo(page.locator('.card').filter({ hasText: 'Store analytics' }), 20);
|
||||
await page.waitForTimeout(300);
|
||||
const svg = page.locator('.lchart svg').first();
|
||||
await svg.waitFor({ state: 'visible', timeout: WAIT });
|
||||
const b = await svg.boundingBox();
|
||||
if (!b) throw new Error('growth: no chart box');
|
||||
// a real pointer move, in steps, to the middle of the chart: crosshair and tooltip
|
||||
await page.mouse.move(b.x + b.width * 0.3, b.y + b.height * 0.42);
|
||||
await page.mouse.move(b.x + b.width * 0.55, b.y + b.height * 0.42, { steps: 12 });
|
||||
await page.locator('.vx-tip').first().waitFor({ state: 'visible', timeout: WAIT });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'catalog', start: 'app:catalog', expect: ['Catalog', 'Parity matrix', 'coins_small', 'vip_pass_monthly'],
|
||||
prepare: async page => { await scrollTo(page.locator('.card').filter({ hasText: 'Parity matrix' }), 20); },
|
||||
},
|
||||
{
|
||||
name: 'prices', start: 'app:prices', expect: ['Prices', 'Products x key territories'],
|
||||
prepare: async page => { await scrollTo(page.locator('.card').filter({ hasText: 'Products x key territories' }), 20); },
|
||||
},
|
||||
{
|
||||
name: 'testflight', start: 'app:testflight', expect: ['TestFlight', 'Recent builds'],
|
||||
prepare: async page => { await page.locator('#content .card').first().waitFor({ state: 'visible', timeout: WAIT }); },
|
||||
},
|
||||
{
|
||||
name: 'console-plan', start: 'app:releases', expect: ['Play Console: send the first production release for review', '4 steps', 'answers from your config', 'Never pressed by anyone else'],
|
||||
prepare: async page => {
|
||||
await submitSection(page);
|
||||
const play = page.locator('section[data-section="submit"] .card').filter({ hasText: 'Google Play: production release' }).first();
|
||||
await play.locator('button', { hasText: 'Send changes for review' }).first().click({ timeout: WAIT });
|
||||
await page.locator('.cp-panel .cp-steps').waitFor({ state: 'visible', timeout: WAIT });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Encodes one PNG to WebP, lowering the quality until it fits MAX_BYTES (never below Q_MIN). */
|
||||
async function encode(png: string, webp: string): Promise<{ bytes: number; q: number }> {
|
||||
for (let q = Q_START; q >= Q_MIN; q -= 3) {
|
||||
const p = Bun.spawn(['cwebp', '-quiet', '-q', String(q), '-m', '6', '-resize', String(VIEW.width), '0', png, '-o', webp], { stdout: 'ignore', stderr: 'pipe' });
|
||||
const timer = setTimeout(() => p.kill(), 60_000);
|
||||
const code = await p.exited;
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) throw new Error(`cwebp failed for ${png}: ${await new Response(p.stderr).text()}`);
|
||||
const bytes = statSync(webp).size;
|
||||
if (bytes <= MAX_BYTES) return { bytes, q };
|
||||
}
|
||||
throw new Error(`${webp}: over ${MAX_BYTES / 1024} KB even at q ${Q_MIN}`);
|
||||
}
|
||||
|
||||
/** Visible text checks: the expected strings, and every e-mail-like address on example.com. */
|
||||
async function checkText(page: any, view: View, theme: Theme): Promise<void> {
|
||||
const text: string = await page.evaluate(() => document.body.innerText);
|
||||
const missing = view.expect.filter(s => !text.includes(s));
|
||||
if (missing.length) throw new Error(`${view.name}-${theme}: expected text not visible: ${missing.join(', ')}`);
|
||||
const ats = text.match(/[^\s@]*@[^\s@]*/g) ?? [];
|
||||
const bad = ats.filter(a => !/^[\w.+-]+@([\w-]+\.)*example\.(com|org|net)\b/i.test(a));
|
||||
if (bad.length) throw new Error(`${view.name}-${theme}: '@' outside example.com in the visible text: ${bad.join(', ')}`);
|
||||
if (process.env.SHOTS_DEBUG) {
|
||||
// the text inside the frame only (for alt texts): text nodes whose box intersects the viewport, topmost layer
|
||||
const inView: string = await page.evaluate(() => {
|
||||
const out: string[] = [];
|
||||
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
||||
const s = n.textContent?.trim();
|
||||
if (!s) continue;
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(n);
|
||||
const b = r.getBoundingClientRect();
|
||||
if (b.width === 0 || b.bottom <= 0 || b.top >= innerHeight || b.right <= 0 || b.left >= innerWidth) continue;
|
||||
const top = document.elementFromPoint(Math.min(innerWidth - 1, Math.max(0, b.left + 2)), Math.min(innerHeight - 1, Math.max(0, b.top + b.height / 2)));
|
||||
if (top && !(top === n.parentElement || n.parentElement?.contains(top) || top.contains(n.parentElement))) continue;
|
||||
out.push(s);
|
||||
}
|
||||
return out.join(' | ');
|
||||
});
|
||||
console.log(`--- ${view.name}-${theme} ---\n${inView}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const which = Bun.spawnSync(['which', 'cwebp']);
|
||||
if (which.exitCode !== 0) throw new Error('cwebp not found (brew install webp)');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const tmp = process.env.SHOTS_TMP ?? mkdtempSync(join(tmpdir(), 'site-desktop-shots-'));
|
||||
mkdirSync(tmp, { recursive: true });
|
||||
const port = 14_800 + Math.floor(Math.random() * 500);
|
||||
const vite = Bun.spawn(['bunx', 'vite', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], {
|
||||
cwd: join(ROOT, 'apps/desktop'), env: { ...process.env, VITE_FIXTURES: '1' }, stdout: 'ignore', stderr: 'pipe',
|
||||
});
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
let browser: any = null;
|
||||
let total = 0;
|
||||
try {
|
||||
for (let i = 0; ; i++) {
|
||||
try { if ((await fetch(base, { signal: AbortSignal.timeout(2000) })).ok) break; } catch { /* not up yet */ }
|
||||
if (i > 120) throw new Error('vite dev server did not start within 30 s');
|
||||
if (i % 20 === 0) log('waiting for the vite dev server ...');
|
||||
await Bun.sleep(250);
|
||||
}
|
||||
browser = await launch(ROOT, { headless: true });
|
||||
for (const theme of THEMES) {
|
||||
const ctx = await browser.newContext({ viewport: VIEW, deviceScaleFactor: 2, colorScheme: theme, reducedMotion: 'reduce' });
|
||||
ctx.setDefaultTimeout(WAIT);
|
||||
ctx.setDefaultNavigationTimeout(30_000);
|
||||
try {
|
||||
for (const view of VIEWS) {
|
||||
const page = await ctx.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e: unknown) => errors.push(String(e)));
|
||||
await page.goto(`${base}/?start=${encodeURIComponent(view.start)}&theme=${theme}&lang=en`, { timeout: 30_000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
// the dev-build markers (not in a release build) and the transient "... read" toasts of the store reads
|
||||
await page.addStyleTag({ content: '.brand small, .dev-pill, .toasts { display: none !important; }' });
|
||||
await page.waitForTimeout(1000);
|
||||
if (view.name !== 'growth') await page.mouse.move(VIEW.width - 2, VIEW.height - 2);
|
||||
await view.prepare(page);
|
||||
if (view.name !== 'growth') await page.mouse.move(VIEW.width - 2, VIEW.height - 2);
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
await page.waitForTimeout(1000);
|
||||
await checkText(page, view, theme);
|
||||
if (errors.length) throw new Error(`${view.name}-${theme}: page errors:\n ${[...new Set(errors)].join('\n ')}`);
|
||||
const png = join(tmp, `${view.name}-${theme}.png`);
|
||||
await page.screenshot({ path: png, type: 'png', timeout: 30_000 });
|
||||
await page.close();
|
||||
const webp = join(OUT, `${view.name}-${theme}.webp`);
|
||||
const { bytes, q } = await encode(png, webp);
|
||||
total += bytes;
|
||||
console.log(`site/img/desktop/${view.name}-${theme}.webp ${(bytes / 1024).toFixed(1)} KB (q ${q})`);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
log(`${VIEWS.length * THEMES.length} files, ${(total / 1024).toFixed(1)} KB in all -> site/img/desktop/`);
|
||||
} finally {
|
||||
if (browser) await closeBrowser(browser).catch(() => {});
|
||||
vite.kill();
|
||||
if (!process.env.SHOTS_TMP) rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await main();
|
||||
303
tools/site-term-shots.ts
Normal file
303
tools/site-term-shots.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
#!/usr/bin/env bun
|
||||
// Terminal shots for the website and the README: runs real storebound CLI commands and saves, per command,
|
||||
// site/img/term/<name>.txt the cleaned output (first line "$ <command>"; no ANSI, no clock prefix, no "(Ns)" timer,
|
||||
// temp / repo / home paths made short and relative)
|
||||
// site/img/term/<name>.webp that text drawn as the site's terminal component (site/style.css .term, dark theme),
|
||||
// rendered by headless Chromium (cli/shots/browser.ts) and encoded with cwebp
|
||||
// Long outputs are cut for the image only, with a dim last line that says exactly how much was left out; the .txt
|
||||
// keeps the whole output. Store-reading commands run in a throwaway project (storebound init in a temp folder) whose
|
||||
// store answers come from the teaser store double (tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml),
|
||||
// with placeholder keys (generated here, sb@example.com): nothing reaches the network. Fails when a command's output
|
||||
// is missing its expected marker, or a command or the browser hangs past its timeout.
|
||||
// bun tools/site-term-shots.ts all shots
|
||||
// bun tools/site-term-shots.ts --text only the .txt files (no browser)
|
||||
// bun tools/site-term-shots.ts scan check only these shots
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeBrowser, launch } from '../cli/shots/browser';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const CLI = join(ROOT, 'cli', 'index.ts');
|
||||
const MOCK = join(ROOT, 'tests', 'helpers', 'teaser-store-mock.ts');
|
||||
const OUT = join(ROOT, 'site', 'img', 'term');
|
||||
const DEMO = join(ROOT, 'examples', 'demo-game');
|
||||
const CMD_TIMEOUT_MS = 90_000;
|
||||
const BROWSER_TIMEOUT_MS = 30_000;
|
||||
const MAX_WEBP = 120 * 1024;
|
||||
const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [term-shots] ${m}`);
|
||||
|
||||
/** Rejects after `ms` with a message naming what hung. */
|
||||
async function timed<T>(what: string, ms: number, p: Promise<T>): Promise<T> {
|
||||
let t: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([p, new Promise<T>((_, rej) => { t = setTimeout(() => rej(new Error(`${what}: no answer after ${ms / 1000} s`)), ms); })]);
|
||||
} finally { clearTimeout(t); }
|
||||
}
|
||||
|
||||
/** Runs `bun cli/index.ts <argv>` with NO_COLOR and empty store keys (plus `env`), killed after CMD_TIMEOUT_MS. */
|
||||
async function run(argv: string[], cwd: string, env: Record<string, string> = {}): Promise<{ code: number; out: string }> {
|
||||
const started = Date.now();
|
||||
const p = Bun.spawn(['bun', CLI, ...argv], {
|
||||
cwd, stdout: 'pipe', stderr: 'pipe',
|
||||
env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ...env },
|
||||
});
|
||||
const beat = setInterval(() => log(` still running: storebound ${argv.join(' ')} (${Math.round((Date.now() - started) / 1000)} s)`), 10_000);
|
||||
const kill = setTimeout(() => p.kill('SIGKILL'), CMD_TIMEOUT_MS);
|
||||
try {
|
||||
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
|
||||
if (p.signalCode === 'SIGKILL') throw new Error(`storebound ${argv.join(' ')}: killed after ${CMD_TIMEOUT_MS / 1000} s. Output so far:\n${out}${err}`);
|
||||
return { code, out: out + err };
|
||||
} finally { clearInterval(beat); clearTimeout(kill); }
|
||||
}
|
||||
|
||||
// --- the throwaway project -------------------------------------------------------------------------------------------
|
||||
|
||||
interface Project { dir: string; keys: Record<string, string> }
|
||||
async function makeProject(): Promise<Project> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-site-term-'));
|
||||
const init = await run(['init'], dir);
|
||||
if (!existsSync(join(dir, 'storebound.config.ts'))) throw new Error(`storebound init wrote no config:\n${init.out}`);
|
||||
writeFileSync(join(dir, 'bunfig.toml'), `preload = [${JSON.stringify(MOCK)}]\n`);
|
||||
const ec = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||
const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||
writeFileSync(join(dir, 'k.p8'), ec);
|
||||
writeFileSync(join(dir, 'sa.json'), JSON.stringify({ client_email: 'sb@example.com', private_key: rsa, token_uri: 'https://oauth2.googleapis.com/token' }));
|
||||
return { dir, keys: { ASC_KEY_ID: 'ABCDE12345', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: join(dir, 'k.p8'), PLAY_SERVICE_ACCOUNT: join(dir, 'sa.json') } };
|
||||
}
|
||||
|
||||
/** The two products the teaser test puts into the config (tests/teaser.test.ts), before `dataSafety`. */
|
||||
const PRODUCTS = ` products: [
|
||||
{ id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes every ad.' }, tr: { title: 'Reklamsız', description: 'Tüm reklamları kaldırır.' } } },
|
||||
{ id: 'com.example.app.gems_100', type: 'consumable', usd: 0.99, listings: { en: { title: '100 Gems', description: 'A pouch of 100 gems.' }, tr: { title: '100 Mücevher', description: '100 mücevherlik bir kese.' } } },
|
||||
],
|
||||
dataSafety: {`;
|
||||
|
||||
// --- cleaning --------------------------------------------------------------------------------------------------------
|
||||
|
||||
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
||||
/** Every spelling of a folder the output may use (macOS: /var is /private/var). */
|
||||
const spellings = (p: string): string[] => [...new Set([p, realpathSync(p)])].sort((a, b) => b.length - a.length);
|
||||
|
||||
/** Output lines without ANSI, the clock prefix and the "(Ns)" timer; `paths` folders become short relative names. */
|
||||
export function clean(out: string, paths: [string, string][]): string[] {
|
||||
const subs = paths.flatMap(([abs, short]) => spellings(abs).map(s => [s, short] as const));
|
||||
return out.replace(ANSI, '').split('\n').map(l => {
|
||||
let x = l.replace(/^\d\d:\d\d:\d\d /, '').replace(/ \(\d+s\)$/, '').trimEnd();
|
||||
for (const [abs, short] of subs) x = x.split(`${abs}/`).join(short ? `${short}/` : '').split(abs).join(short || '.');
|
||||
return x;
|
||||
}).filter((l, i, a) => !(l === '' && (i === 0 || a[i - 1] === '' || i === a.length - 1)));
|
||||
}
|
||||
|
||||
// --- the shots -------------------------------------------------------------------------------------------------------
|
||||
|
||||
interface Ran { code: number; lines: string[] }
|
||||
interface Shot {
|
||||
name: string;
|
||||
/** The command as a reader types it. */
|
||||
cmd: string;
|
||||
/** The term-bar title (the project folder). */
|
||||
title: string;
|
||||
/** Exit codes that are the expected result. */
|
||||
codes: number[];
|
||||
/** Every one must be in the output (string: a line contains it; RegExp: a line matches). */
|
||||
markers: (string | RegExp)[];
|
||||
run(p: Project): Promise<{ code: number; out: string; paths: [string, string][] }>;
|
||||
/** The lines for the .txt (default: all). */
|
||||
text?(lines: string[]): string[];
|
||||
/** The lines for the image; a line starting with "…" is drawn dim. */
|
||||
image(lines: string[]): string[];
|
||||
}
|
||||
|
||||
/** First `n` lines, then a dim line with the exact number left out. */
|
||||
/** A line cut to what fits one row of the 960 px terminal (the .txt keeps it whole). */
|
||||
const short = (l: string): string => l.length > 112 ? `${l.slice(0, 111).trimEnd()}…` : l;
|
||||
|
||||
const head = (lines: string[], n: number, what: string): string[] =>
|
||||
lines.length <= n ? lines : [...lines.slice(0, n), `… ${lines.length - n} more ${what}`];
|
||||
|
||||
const SHOTS: Shot[] = [
|
||||
{
|
||||
name: 'release-plan', cmd: 'storebound release plan 1.0.0', title: 'my-app',
|
||||
codes: [0], markers: [/^\s+1\. /, /^\s+2\. /],
|
||||
run: async p => ({ ...await run(['release', 'plan', '1.0.0'], p.dir, { ...p.keys, TEASER_STORE: 'new' }), paths: [[p.dir, '']] }),
|
||||
// the step headlines (each step also has a "->" line with the fix command), cut after whole steps
|
||||
image: lines => {
|
||||
const heads = lines.filter(l => !/^\s+->/.test(l) && l.trim());
|
||||
const arrows = lines.filter(l => /^\s+->/.test(l)).length;
|
||||
const isStep = (l: string) => /^\s+\d+\. /.test(l);
|
||||
const total = heads.filter(isStep).length;
|
||||
const shown = heads.slice(0, 24);
|
||||
while (shown.length && !isStep(shown.at(-1)!)) shown.pop();
|
||||
const left = total - shown.filter(isStep).length;
|
||||
return [...shown.map(short), `… ${left > 0 ? `${left} more steps; ` : ''}each of the ${total} steps also has a -> line with the command that fixes it (${arrows} lines)`];
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'scan', cmd: 'storebound scan', title: 'tests/fixtures/scan/teaser',
|
||||
codes: [1], markers: ['Rejection risks:', 'BLOCKER', /^\s+1\. /],
|
||||
run: async () => {
|
||||
const r = await run(['scan', '--dir', 'tests/fixtures/scan/teaser'], ROOT, { REVIEW_DEMO_USER: '', REVIEW_DEMO_PASSWORD: '' });
|
||||
return { ...r, paths: [[join(ROOT, 'tests/fixtures/scan/teaser'), ''], [ROOT, '']] };
|
||||
},
|
||||
// the findings part: everything up to the declarations proposal
|
||||
text: lines => {
|
||||
const end = lines.findIndex(l => l.startsWith('Declarations proposal'));
|
||||
if (end < 0) return lines;
|
||||
const rest = lines.slice(end).filter(l => l.trim()).length;
|
||||
return [...lines.slice(0, end), `… ${rest} more lines: the declarations proposal (Data safety, App Privacy, ATT, privacy manifest) and the config diff`];
|
||||
},
|
||||
image: lines => {
|
||||
const from = lines.findIndex(l => l.startsWith('Rejection risks:'));
|
||||
const body = lines.slice(from).filter(l => !l.startsWith('…') && l.trim());
|
||||
const shown = body.filter(l => !/^\s+(fix|skill):/.test(l));
|
||||
const hidden = body.length - shown.length;
|
||||
return [...lines.slice(0, from), ...shown.map(short), `… plus a fix: and a skill: line per finding (${hidden} lines)`];
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'products', cmd: 'storebound play products --dry', title: 'my-app',
|
||||
codes: [0], markers: ['com.example.app.no_ads', 'com.example.app.gems_100'],
|
||||
run: async p => {
|
||||
const cfg = join(p.dir, 'storebound.config.ts');
|
||||
const src = readFileSync(cfg, 'utf8');
|
||||
if (!src.includes(' dataSafety: {')) throw new Error('the init config has no " dataSafety: {" line to put the products before');
|
||||
writeFileSync(cfg, src.replace(' dataSafety: {', PRODUCTS));
|
||||
try { return { ...await run(['play', 'products', '--dry'], p.dir, { ...p.keys, TEASER_STORE: 'new' }), paths: [[p.dir, '']] }; }
|
||||
finally { writeFileSync(cfg, src); }
|
||||
},
|
||||
image: lines => head(lines.map(short), 24, 'lines'),
|
||||
},
|
||||
{
|
||||
name: 'check', cmd: 'storebound check', title: 'my-app',
|
||||
codes: [1], markers: ['config ok', /^error: \d+ problems?$/],
|
||||
run: async p => ({ ...await run(['check'], p.dir), paths: [[p.dir, '']] }),
|
||||
image: lines => head(lines.map(short), 24, 'lines'),
|
||||
},
|
||||
{
|
||||
name: 'console-plan', cmd: 'storebound console plan play-content-rating', title: 'examples/demo-game',
|
||||
codes: [0], markers: ['start: https://play.google.com/console', 'Steps:', 'Answer sheet:', 'Never press:'],
|
||||
run: async () => ({ ...await run(['console', 'plan', 'play-content-rating'], DEMO), paths: [[DEMO, ''], [ROOT, '']] }),
|
||||
image: lines => {
|
||||
// the head: title, start URL, the steps (first line of each), a sample of the answer sheet, the never-press list
|
||||
const at = (s: string) => lines.findIndex(l => l.startsWith(s));
|
||||
const steps = at('Steps:'), sheet = at('Answer sheet:'), never = at('Never press:'), facts = at('UI facts:');
|
||||
const stepLines = lines.slice(steps + 1, sheet).filter(l => /^\s+\d+\. /.test(l));
|
||||
const sheetRows = lines.slice(sheet + 1, never).filter(l => /^\s+\[/.test(l));
|
||||
const sample = [sheetRows.find(l => l.includes('[ok]')), sheetRows.find(l => l.includes('[you]')), sheetRows.find(l => l.includes('[ask]'))].filter((l): l is string => !!l);
|
||||
const after = lines.slice(facts).filter(l => l.trim()).length;
|
||||
return [
|
||||
...lines.slice(0, steps), 'Steps:', ...stepLines.slice(0, 6).map(short), `… ${stepLines.length - 6} more steps`, '',
|
||||
'Answer sheet:', ...sample.map(short), `… ${sheetRows.length - sample.length} more answers (${sheetRows.filter(l => l.includes('[ask]')).length} of all ${sheetRows.length} are "ask the owner")`, '',
|
||||
...lines.slice(never, facts).filter(l => l.trim()).map(l => l.startsWith('Never press:') ? l : short(l)), `… UI facts and driving notes (${after} lines)`,
|
||||
].filter((l, i, a) => !(l === '' && a[i - 1] === ''));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// --- drawing ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/** One output line as HTML: dim elisions, red blockers and errors, amber warnings, green ok and planned creates. */
|
||||
export function lineHtml(l: string): string {
|
||||
if (l.startsWith('$ ')) return `<span class="p">$</span> <span class="cmd">${esc(l.slice(2))}</span>`;
|
||||
if (l.trimStart().startsWith('…')) return `<span class="c">${esc(l)}</span>`;
|
||||
let h = esc(l);
|
||||
h = h.replace(/\b(BLOCKER|HIGH|error|ERROR|FAIL|failed|missing)\b|\[(you|ask)\]/g, m => `<span class="bad">${m}</span>`);
|
||||
h = h.replace(/\b(WARN|warn|MEDIUM|ask owner)\b/g, m => `<span class="warn">${m}</span>`);
|
||||
// green: "[ok]", "config ok", "all good", and a dry run's planned creates ("would create", the table's create column)
|
||||
h = h.replace(/\[ok\]|\b(ok|all good|would create)\b|(?<= {2})create(?= {2})/g, m => `<span class="ok">${m}</span>`);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The page: site/style.css's .term in its dark tokens (--code-bg, --code-ink, --code-muted, --mono). */
|
||||
const page = (title: string, lines: string[]): string => `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
:root { --code-bg: #060a14; --code-ink: #e2e8f0; --code-muted: #64748b; --radius: 14px;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 32px -16px rgba(0, 0, 0, .7);
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; }
|
||||
html, body { margin: 0; background: #0b1120; }
|
||||
body { padding: 24px; width: 960px; box-sizing: content-box; }
|
||||
.term { background: var(--code-bg); color: var(--code-ink); border-radius: var(--radius); box-shadow: var(--shadow); border: 1px solid color-mix(in srgb, var(--code-ink) 10%, transparent); overflow: hidden; }
|
||||
.term-bar { display: flex; align-items: center; gap: 6px; padding: 10px 14px; border-bottom: 1px solid color-mix(in srgb, var(--code-ink) 10%, transparent); }
|
||||
.term-bar span { width: 10px; height: 10px; border-radius: 50%; background: #334155; }
|
||||
.term-bar em { margin-left: 8px; font: 12px var(--mono); color: var(--code-muted); font-style: normal; }
|
||||
.term pre { margin: 0; padding: 16px 18px 20px; color: var(--code-ink); font: 13.5px/1.75 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.p { color: #818cf8; } .cmd { color: #a5b4fc; } .c { color: var(--code-muted); }
|
||||
.bad { color: #fca5a5; font-weight: 600; } .warn { color: #fcd34d; } .ok { color: #86efac; }
|
||||
</style></head><body><div class="term"><div class="term-bar"><span></span><span></span><span></span><em>${esc(title)}</em></div><pre><code>${lines.map(lineHtml).join('\n')}</code></pre></div></body></html>`;
|
||||
|
||||
/** cwebp at 1600 px wide: lossy q80 and lossless, the smaller one; when that is over MAX_WEBP, lossy at q76, q72, q68. */
|
||||
async function toWebp(png: string, out: string): Promise<{ bytes: number; mode: string }> {
|
||||
const encode = async (mode: string, args: string[]): Promise<{ bytes: number; mode: string; file: string }> => {
|
||||
const file = `${png}.${mode}.webp`;
|
||||
const p = Bun.spawn(['cwebp', '-quiet', ...args, '-resize', '1600', '0', png, '-o', file], { stdout: 'ignore', stderr: 'pipe' });
|
||||
const code = await timed(`cwebp ${mode}`, 60_000, p.exited);
|
||||
if (code !== 0) throw new Error(`cwebp ${mode} failed: ${await new Response(p.stderr).text()}`);
|
||||
return { bytes: statSync(file).size, mode, file };
|
||||
};
|
||||
const tried = [await encode('q80', ['-q', '80', '-m', '6']), await encode('lossless', ['-lossless', '-z', '9'])];
|
||||
let best = tried.reduce((a, b) => (b.bytes < a.bytes ? b : a));
|
||||
for (const q of [76, 72, 68]) {
|
||||
if (best.bytes <= MAX_WEBP) break;
|
||||
const t = await encode(`q${q}`, ['-q', String(q), '-m', '6']);
|
||||
tried.push(t);
|
||||
if (t.bytes < best.bytes) best = t;
|
||||
}
|
||||
writeFileSync(out, readFileSync(best.file));
|
||||
for (const t of tried) rmSync(t.file, { force: true });
|
||||
return { bytes: best.bytes, mode: best.mode };
|
||||
}
|
||||
|
||||
// --- main ------------------------------------------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const textOnly = args.includes('--text');
|
||||
const only = args.filter(a => !a.startsWith('--'));
|
||||
const shots = only.length ? SHOTS.filter(s => only.includes(s.name)) : SHOTS;
|
||||
if (only.length && shots.length !== only.length) throw new Error(`unknown shot(s); known: ${SHOTS.map(s => s.name).join(', ')}`);
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const project = await makeProject();
|
||||
const home = homedir();
|
||||
const done: { shot: Shot; image: string[] }[] = [];
|
||||
try {
|
||||
for (const shot of shots) {
|
||||
log(`run: ${shot.cmd}`);
|
||||
const r = await shot.run(project);
|
||||
const all = clean(r.out, [...r.paths, [project.dir, ''], [home, '~']]);
|
||||
if (!shot.codes.includes(r.code)) throw new Error(`${shot.cmd}: exit ${r.code}, expected ${shot.codes.join(' or ')}:\n${all.join('\n')}`);
|
||||
for (const m of shot.markers) {
|
||||
const hit = typeof m === 'string' ? all.some(l => l.includes(m)) : all.some(l => m.test(l));
|
||||
if (!hit) throw new Error(`${shot.cmd}: the output has no ${String(m)}:\n${all.join('\n')}`);
|
||||
}
|
||||
for (const bad of [project.dir, home, ROOT]) if (all.some(l => l.includes(bad))) throw new Error(`${shot.cmd}: an absolute path is left in the output (${bad})`);
|
||||
const text = [`$ ${shot.cmd}`, ...(shot.text ? shot.text(all) : all)];
|
||||
writeFileSync(join(OUT, `${shot.name}.txt`), `${text.join('\n')}\n`);
|
||||
const image = [`$ ${shot.cmd}`, ...shot.image(shot.text ? shot.text(all) : all)];
|
||||
log(` ${shot.name}.txt: ${text.length} lines (image: ${image.length})`);
|
||||
done.push({ shot, image });
|
||||
}
|
||||
} finally { rmSync(project.dir, { recursive: true, force: true }); }
|
||||
if (textOnly) return;
|
||||
|
||||
const browser = await timed('browser launch', BROWSER_TIMEOUT_MS, launch(ROOT, { headless: true }));
|
||||
try {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1008, height: 800 }, deviceScaleFactor: 2, colorScheme: 'dark' });
|
||||
const tab = await ctx.newPage();
|
||||
tab.setDefaultTimeout(BROWSER_TIMEOUT_MS);
|
||||
for (const { shot, image } of done) {
|
||||
await timed(`render ${shot.name}`, BROWSER_TIMEOUT_MS, tab.setContent(page(shot.title, image), { waitUntil: 'load' }));
|
||||
const png = join(tmpdir(), `sb-term-${shot.name}-${process.pid}.png`);
|
||||
await timed(`screenshot ${shot.name}`, BROWSER_TIMEOUT_MS, tab.locator('.term').screenshot({ path: png }));
|
||||
const out = join(OUT, `${shot.name}.webp`);
|
||||
const w = await toWebp(png, out);
|
||||
rmSync(png, { force: true });
|
||||
log(` ${shot.name}.webp: ${(w.bytes / 1024).toFixed(1)} KB (${w.mode})`);
|
||||
if (w.bytes > MAX_WEBP) throw new Error(`${shot.name}.webp is ${w.bytes} bytes, over ${MAX_WEBP}`);
|
||||
}
|
||||
} finally { await closeBrowser(browser); }
|
||||
}
|
||||
|
||||
if (import.meta.main) await main();
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
# storebound desktop downloads on storebound.dev (included in the :443 vhost of /etc/apache2/sites-available/storebound.dev.conf
|
||||
# as Include /etc/apache2/storebound-desktop.conf ; see docs/desktop-release.md).
|
||||
# The page itself (site/desktop/index.html) comes with the site; the binaries and latest.json live outside the site's
|
||||
# The downloads are a section of the home page (storebound.dev/#download, it reads latest.json); site/desktop/index.html
|
||||
# only redirects there and comes with the site. The binaries and latest.json live outside the site's
|
||||
# document root, because the site's publish rsync deletes everything that is not in the repository.
|
||||
Alias /desktop/latest.json /var/www/storebound-desktop/latest.json
|
||||
Alias /desktop/beta/latest.json /var/www/storebound-desktop/beta/latest.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue