storebound/tools/site-term-shots.ts

339 lines
22 KiB
TypeScript

#!/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 { copyFileSync, 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: {`;
/** What the owner adds to a fresh `storebound init` project before the first release, so that `check` passes:
* the icon artwork (examples/demo-game's real icon files), the App Store name with a suffix, the Play App content
* answers (the batch `console plan play-app-content` asks for) and the App Review demo account in
* .storebound/credentials.json (placeholder values). Returns a function that puts the project back as init wrote it. */
export function makeReady(dir: string): () => void {
const cfg = join(dir, 'storebound.config.ts'), creds = join(dir, '.storebound', 'credentials.json');
const src = readFileSync(cfg, 'utf8'), credSrc = readFileSync(creds, 'utf8');
for (const anchor of [" listing: 'store/appstore.md',\n", ' renders: ']) if (!src.includes(anchor)) throw new Error(`the init config has no ${JSON.stringify(anchor)} line`);
writeFileSync(cfg, src
.replace(" listing: 'store/appstore.md',\n", " listing: 'store/appstore.md',\n name: 'Example App: Lists & Notes',\n")
.replace(' renders: ', `${READY_CONSOLE}\n renders: `));
const c = JSON.parse(credSrc) as Record<string, unknown>;
writeFileSync(creds, JSON.stringify({ ...c, reviewDemoAccount: { username: 'demo@example.com', password: 'placeholder-demo-password' } }, null, 2));
const icons = join(dir, 'store', 'icon');
mkdirSync(icons, { recursive: true });
for (const f of ['icon.png', 'foreground.png', 'background.png']) copyFileSync(join(DEMO, 'store', 'icon', f), join(icons, f));
return () => { writeFileSync(cfg, src); writeFileSync(creds, credSrc); rmSync(icons, { recursive: true, force: true }); };
}
/** The Play App content answers of a plain utility app without ads, for everyone 18+ (config `console`). */
const READY_CONSOLE = ` console: {
contentRating: {
category: 'other',
answers: { violence: false, fear: false, sexuality: false, language: false, controlledSubstances: false, crudeHumor: false, gambling: false,
simulatedGambling: false, lootBoxes: false, userInteraction: false, sharesLocation: false, digitalPurchases: false, unrestrictedInternet: false },
details: { miscellaneous: { naziSymbols: false, koreaHistory: false, terrorism: false, crimes: false } },
},
targetAudience: { ages: ['18-over'], appealsToChildren: false, containsAds: false },
appContent: { government: false, financialFeatures: false, healthFeatures: false },
},
`;
// --- 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: [0], markers: ['config ok', 'all good'],
run: async p => {
const restore = makeReady(p.dir);
try { return { ...await run(['check'], p.dir, { REVIEW_DEMO_USER: '', REVIEW_DEMO_PASSWORD: '' }), paths: [[p.dir, '']] }; }
finally { restore(); }
},
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).map(short), 'Steps:', ...stepLines.slice(0, 3).map(short), `… ${stepLines.length - 3} 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** 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();