123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120 | // Takes the README's screenshots of the rendered preview site. `make
// screenshots` runs it after rendering:
//
// node preview/screenshots.mjs BIN CONFIG OUTDIR
//
// It serves the site with `BIN --config CONFIG serve`, drives headless
// Firefox over WebDriver BiDi (Node's built-in WebSocket, no packages),
// and writes one PNG per shot into OUTDIR.
import { spawn } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const [bin, config, outDir] = process.argv.slice(2);
if (!outDir) {
console.error('usage: node preview/screenshots.mjs BIN CONFIG OUTDIR');
process.exit(2);
}
const W = 1400, H = 900;
// Each shot is a page of the site, the theme to show it in, and an
// optional condition to wait for after the page loads.
const shots = [
{ name: 'repo', path: '/code/repo-renderer', theme: 'dark' },
{ name: 'file', path: '/code/repo-renderer/internal/queue/queue.go', theme: 'light' },
{ name: 'search', path: '/search/repo-renderer/?q=RenderRepo', theme: 'dark',
until: `document.getElementById('status')?.getAttribute('aria-busy') === 'false' &&
document.getElementById('results')?.children.length > 0` },
{ name: 'index', path: '/', theme: 'light' },
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function freePort() {
return new Promise((res, rej) => {
const s = createServer().once('error', rej).listen(0, '127.0.0.1', () => {
const { port } = s.address();
s.close(() => res(port));
});
});
}
// retry calls fn until it resolves, for up to about 15 seconds.
async function retry(what, fn) {
for (let i = 0; i < 50; i++) {
try { return await fn(); } catch { await sleep(300); }
}
throw new Error(`timed out waiting for ${what}`);
}
const sitePort = await freePort();
const ffPort = await freePort();
const base = `http://127.0.0.1:${sitePort}`;
const profile = mkdtempSync(join(tmpdir(), 'repo-render-shots-'));
const children = [];
const cleanup = () => {
for (const c of children) c.kill();
rmSync(profile, { recursive: true, force: true });
};
process.on('exit', cleanup);
process.on('SIGINT', () => process.exit(130));
children.push(spawn(bin, ['--config', config, 'serve', '--listen', `127.0.0.1:${sitePort}`],
{ stdio: ['ignore', 'ignore', 'inherit'] }));
children.push(spawn('firefox', ['--headless', '--no-remote', '--profile', profile,
`--remote-debugging-port=${ffPort}`, `--window-size=${W},${H}`], { stdio: 'ignore' }));
await retry('the preview server', async () => {
if (!(await fetch(base + '/')).ok) throw new Error('not ready');
});
const ws = await retry('Firefox', () => new Promise((res, rej) => {
const s = new WebSocket(`ws://127.0.0.1:${ffPort}/session`);
s.onopen = () => res(s);
s.onerror = rej;
}));
let nextId = 1;
const pending = new Map();
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
const p = pending.get(m.id);
if (!p) return;
pending.delete(m.id);
if (m.type === 'error') p.rej(new Error(`${p.method}: ${m.error}: ${m.message}`));
else p.res(m.result);
};
const send = (method, params = {}) => new Promise((res, rej) => {
const id = nextId++;
pending.set(id, { method, res, rej });
ws.send(JSON.stringify({ id, method, params }));
});
await send('session.new', { capabilities: {} });
const { context } = await send('browsingContext.create', { type: 'tab' });
await send('browsingContext.setViewport', { context, viewport: { width: W, height: H } });
const evaluate = async (expression) => (await send('script.evaluate',
{ expression, target: { context }, awaitPromise: true })).result;
mkdirSync(outDir, { recursive: true });
for (const s of shots) {
// Remember the theme the way the site's toggle does, so the page and
// the file drawer's frame both pick it up.
await send('browsingContext.navigate', { context, url: base + '/', wait: 'complete' });
await evaluate(`localStorage.setItem('repo-render-theme', '${s.theme}')`);
await send('browsingContext.navigate', { context, url: base + s.path, wait: 'complete' });
if (s.until) {
await retry(s.name, async () => {
if (!(await evaluate(`!!(${s.until})`)).value) throw new Error('not yet');
});
}
await sleep(800); // let frames and fonts settle
const { data } = await send('browsingContext.captureScreenshot', { context });
writeFileSync(join(outDir, `${s.name}.png`), Buffer.from(data, 'base64'));
console.log(`wrote ${join(outDir, s.name)}.png`);
}
await send('session.end').catch(() => {});
ws.close();
process.exit(0);
|