diff --git a/AGENTS.md b/AGENTS.md index ff6ca94..c064ed7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,8 @@ just types # Generate frontend/api.d.ts from OpenAPI schema | `just format-frontend` | Format frontend code with Prettier | | `just dev` | Run Datasette on port 8004 | | `just dev-with-hmr` | Run Datasette with HMR + auto-restart on file changes | +| `just shots-fixture` | (Re)build the demo FEC database used for doc screenshots | +| `just shots [names...]` | Regenerate committed doc screenshots → `docs/screenshots/*.png` (Playwright drives a throwaway Datasette against the fixture db) | ## Key Files diff --git a/Justfile b/Justfile index 0bd42f5..427c298 100644 --- a/Justfile +++ b/Justfile @@ -41,6 +41,20 @@ format: just format-backend just format-frontend +# (Re)build the small demo FEC database the doc screenshots run against. +shots-fixture: + bash frontend/scripts/build-fixture.sh + +# Regenerate committed doc screenshots → docs/screenshots/*.png. Self-contained: +# builds the frontend, boots a throwaway datasette against the fixture db, drives +# Playwright, then tears it down. Run all, or a subset by name: +# `just shots` / `just shots index contest`. +shots *names: + just frontend + npm --prefix frontend install + npm --prefix frontend exec -- playwright install chromium + node frontend/scripts/screenshots.mjs {{names}} + format-check: just format-backend-check just format-frontend-check diff --git a/docs/screenshots/alerts.png b/docs/screenshots/alerts.png new file mode 100644 index 0000000..4d71f61 Binary files /dev/null and b/docs/screenshots/alerts.png differ diff --git a/docs/screenshots/candidate.png b/docs/screenshots/candidate.png new file mode 100644 index 0000000..07d2bb1 Binary files /dev/null and b/docs/screenshots/candidate.png differ diff --git a/docs/screenshots/committee.png b/docs/screenshots/committee.png new file mode 100644 index 0000000..e299d50 Binary files /dev/null and b/docs/screenshots/committee.png differ diff --git a/docs/screenshots/contest.png b/docs/screenshots/contest.png new file mode 100644 index 0000000..71ea4a1 Binary files /dev/null and b/docs/screenshots/contest.png differ diff --git a/docs/screenshots/filing-day.png b/docs/screenshots/filing-day.png new file mode 100644 index 0000000..de2b008 Binary files /dev/null and b/docs/screenshots/filing-day.png differ diff --git a/docs/screenshots/filing.png b/docs/screenshots/filing.png new file mode 100644 index 0000000..b27f8e6 Binary files /dev/null and b/docs/screenshots/filing.png differ diff --git a/docs/screenshots/import.png b/docs/screenshots/import.png new file mode 100644 index 0000000..d4b6bbe Binary files /dev/null and b/docs/screenshots/import.png differ diff --git a/docs/screenshots/index.png b/docs/screenshots/index.png new file mode 100644 index 0000000..bb8c94c Binary files /dev/null and b/docs/screenshots/index.png differ diff --git a/docs/screenshots/paper.png b/docs/screenshots/paper.png new file mode 100644 index 0000000..94071d4 Binary files /dev/null and b/docs/screenshots/paper.png differ diff --git a/docs/screenshots/rss.png b/docs/screenshots/rss.png new file mode 100644 index 0000000..b7515a6 Binary files /dev/null and b/docs/screenshots/rss.png differ diff --git a/frontend/.gitignore b/frontend/.gitignore index e5aaaa8..674396f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -24,4 +24,7 @@ dist-ssr *.sw? # Vite -.vite \ No newline at end of file +.vite + +# Generated screenshot fixture database (rebuild with `just shots-fixture`) +scripts/shot-data/ \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0487d6f..7161ccc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "openapi-typescript": "^7.10.1" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tsconfig/svelte": "^5.0.6", "@types/d3": "^7.4.3", @@ -566,6 +567,22 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@redocly/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.17.1.tgz", @@ -2619,6 +2636,53 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 87acdfb..321ef4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "format:check": "prettier --check 'src/**/*.{ts,svelte}'" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tsconfig/svelte": "^5.0.6", "@types/d3": "^7.4.3", diff --git a/frontend/scripts/build-fixture.sh b/frontend/scripts/build-fixture.sh new file mode 100755 index 0000000..d8171d1 --- /dev/null +++ b/frontend/scripts/build-fixture.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Build the deterministic demo database the doc screenshots run against. +# +# Unlike the other plugins in the screenshot family (which seed via internal +# plugin APIs), datasette-libfec browses real FEC data, so the "seed" is a small +# fixed SQLite file produced by `libfec export`. We pin a marquee 2024 race plus +# a couple of big PACs, cover-only (no itemizations) so it stays small and fast. +# +# Network + the libfec cache are used the first time; subsequent runs are quick. +# Output is NOT committed (see frontend/.gitignore); regenerate with `just shots-fixture`. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT="${1:-$HERE/shot-data/libfec.db}" +mkdir -p "$(dirname "$OUT")" + +# Keep downloaded .fec files around so re-runs don't re-fetch. +export LIBFEC_CACHE_DIRECTORY="${LIBFEC_CACHE_DIRECTORY:-$HOME/.cache/libfec}" +mkdir -p "$LIBFEC_CACHE_DIRECTORY" + +echo "Building fixture → $OUT" + +# 2024 Ohio Senate: Sherrod Brown (D, incumbent) vs Bernie Moreno (R) and the +# rest of the field — gives candidate, contest, and filing pages real content. +echo " • OH-S 2024 (candidates + committees + F3 covers)" +libfec export OH-S --cycle 2024 --cover-only --clobber -o "$OUT" + +# Big 2024 PACs — committee pages with F3X covers. +echo " • Fairshake (C00835959) — crypto super PAC" +libfec export C00835959 --cycle 2024 --cover-only -o "$OUT" +echo " • Senate Majority PAC (C00484642)" +libfec export C00484642 --cycle 2024 --cover-only -o "$OUT" + +echo "Done. Tables:" +sqlite3 "$OUT" "SELECT ' ' || name FROM sqlite_master WHERE type='table' AND name LIKE 'libfec%';" diff --git a/frontend/scripts/screenshots.mjs b/frontend/scripts/screenshots.mjs new file mode 100644 index 0000000..463ac79 --- /dev/null +++ b/frontend/scripts/screenshots.mjs @@ -0,0 +1,438 @@ +// Programmatic doc screenshots of datasette-libfec → docs/screenshots/*.png. +// +// SELF-CONTAINED: boots its own throwaway datasette on a fixed port, pointed at +// a small pre-built FEC database (frontend/scripts/build-fixture.sh), drives +// Playwright through the plugin's pages, then tears the server down. One command, +// reproducible — so the committed PNGs only change when the UI actually changes. +// +// Output is committed; the README embeds these, so re-run + commit when the UI +// look changes: `just shots` (or a subset, e.g. `just shots index contest`). +// +// libfec's own pages are plain read-only views gated by a single instance +// permission, so they need no signed actor cookies or ACL grants — just open the +// access gate and browse; the "seed" is the fixture .db, not a startup plugin. +// The one exception is the `paper` shot: it boots the server with `--extra +// paper` (datasette-paper, from PyPI) and seeds a paper document that embeds FEC +// candidates as cards, viewed as a signed-in owner (see seedPaperDoc + PAPER_*). +import { chromium } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { mkdir, copyFile, access } from 'node:fs/promises'; +import { rmSync } from 'node:fs'; +import { spawn, execFileSync } from 'node:child_process'; + +// Free high port unique to this plugin (others: paper 8486, sheets 8487, +// town 8489, screenshot-skill default 8490). +const PORT = Number(process.env.SHOTS_PORT || 8491); +const BASE = `http://localhost:${PORT}`; +// datasette database name == the data-db filename stem. +const DB_NAME = 'data'; +const APP = `${BASE}/${DB_NAME}/-/libfec`; +const SECRET = 'screenshots-secret-not-for-prod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +// Pre-built demo database (run `just shots-fixture` to (re)generate). +const FIXTURE_DB = resolve(HERE, 'shot-data/libfec.db'); +const DATA_DIR = '/tmp/datasette-libfec-shots-data'; // dir name is irrelevant; file stem == DB_NAME +const DATA_DB = `${DATA_DIR}/${DB_NAME}.db`; +// datasette-paper stores docs in the internal DB; a throwaway one, wiped each +// run, keeps the seeded doc's id deterministic. Only used for the `paper` shot. +const INTERNAL_DB = `${DATA_DIR}/internal.db`; +const OUT = resolve(HERE, '../../docs/screenshots'); + +const VIEWPORT = { width: 1200, height: 900 }; +// Cropped to fit the paper editor chrome + heading + the two stacked embed +// cards, with a little breathing room below. +const VIEWPORT_TALL = { width: 1200, height: 800 }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// --- Concrete demo entities (from the fixture: 2024 Ohio Senate + big PACs) --- +const SHERROD_BROWN = 'S6OH00163'; // candidate id +const BERNIE_MORENO = 'S4OH00192'; // candidate id (his 2024 Senate opponent) +const FAIRSHAKE = 'C00835959'; // crypto super PAC committee id +const BROWN_YE_FILING = '1870297'; // Friends of Sherrod Brown, F3 year-end 2024 + +// --- Paper embed demo (only when the `paper` shot runs) --------------------- +// datasette-paper isn't a hard dependency (it owns the paper_embed_provider +// spec, so our hookimpl is a no-op without it). The `paper` shot boots the +// server with `--extra paper` so a doc can embed live FEC-candidate cards; refs +// use the plugin's namespace, `/-/libfec/candidate/{db}/{candidate_id}` (see +// datasette_libfec/paper.py + frontend/src/paper_embed.ts). +const PAPER = `${BASE}/-/paper`; +const PAPER_ACTOR = 'alice'; // doc owner — create seeds an owner acl grant to it +const PAPER_EMBED_CANDIDATES = [SHERROD_BROWN, BERNIE_MORENO]; // the 2024 matchup + +// --------------------------------------------------------------------------- +async function reachable() { + try { + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), 500); + const r = await fetch(APP, { redirect: 'manual', signal: ac.signal }); + clearTimeout(t); + return r.status < 500; + } catch { + return false; + } +} + +// Copy the pre-built fixture to a throwaway mutable db so datasette opens it +// writable (the plugin's startup hook adds its alert-queue table) without ever +// touching the committed-ish fixture. `needsPaper` also wipes the internal DB +// so the seeded paper doc's id is deterministic across runs. +async function setupDataDb(needsPaper) { + try { + await access(FIXTURE_DB); + } catch { + throw new Error(`fixture db missing: ${FIXTURE_DB}\nRun \`just shots-fixture\` first.`); + } + await mkdir(DATA_DIR, { recursive: true }); + await copyFile(FIXTURE_DB, DATA_DB); + if (needsPaper) rmSync(INTERNAL_DB, { force: true }); +} + +// Extra `uv run` / datasette args that pull in datasette-paper + open its gates. +// `--extra paper` resolves it from PyPI; the `>=0.0.2a3` specifier is an alpha, +// so `--prerelease=allow` lets uv pick it (and any prerelease transitive deps). +function paperServerArgs() { + return { + // Go right after `run`, before `datasette`. + uv: ['--prerelease=allow', '--extra', 'paper'], + // datasette flags: persist docs in a throwaway internal DB, and let alice + // create + everyone view docs (per-doc edit/manage still derive from the + // owner grant the create endpoint seeds). + ds: [ + '--internal', + INTERNAL_DB, + '-s', + 'permissions.datasette-paper-create', + 'true', + '-s', + 'permissions.paper-view', + 'true', + ], + }; +} + +async function startServer(needsPaper) { + await setupDataDb(needsPaper); + if (await reachable()) { + throw new Error( + `something is already serving on ${BASE}. Stop it (or set SHOTS_PORT) and retry.` + ); + } + const paper = needsPaper ? paperServerArgs() : { uv: [], ds: [] }; + // `detached: true` puts datasette in its own process group. datasette is a + // grandchild of `uv run`, so we kill the whole group in stopServer. + const child = spawn( + 'uv', + [ + 'run', + ...paper.uv, + 'datasette', + DATA_DB, + '--secret', + SECRET, + // Open every gate the UI checks so all features render: read access, the + // write gate (import + RSS pages and their index cards), and alerts access + // (datasette-alerts is a dependency, so the Alerts page resolves). + '-s', + 'permissions.datasette_libfec_access', + 'true', + '-s', + 'permissions.datasette_libfec_write', + 'true', + '-s', + 'permissions.datasette-alerts-access', + 'true', + ...paper.ds, + '-p', + String(PORT), + ], + { + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + env: { ...process.env, PYTHONHASHSEED: '0' }, + } + ); + let log = ''; + child.stdout.on('data', (d) => (log += d)); + child.stderr.on('data', (d) => (log += d)); + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`datasette exited early (code ${child.exitCode}):\n${log}`); + } + if (await reachable()) return child; + await sleep(250); + } + stopServer(child); + throw new Error(`datasette never came up on ${BASE}:\n${log}`); +} + +// Mint a signed `ds_actor` cookie for the paper doc owner. There's no Node port +// of itsdangerous, so shell out to the same URLSafeSerializer(salt="actor") +// datasette signs with. One actor, so a single cached value. +let _paperCookie; +function paperCookie() { + if (_paperCookie) return _paperCookie; + _paperCookie = execFileSync( + 'uv', + [ + 'run', + '--prerelease=allow', + 'python', + '-c', + 'import sys, json; from itsdangerous import URLSafeSerializer; ' + + 'print(URLSafeSerializer(sys.argv[1]).dumps(json.loads(sys.argv[2]), salt="actor"))', + SECRET, + JSON.stringify({ a: { id: PAPER_ACTOR } }), + ], + { encoding: 'utf8' } + ).trim(); + return _paperCookie; +} + +// Create a paper doc that embeds the demo FEC candidates as block cards, over +// paper's real create API. A block embed is a fenced ```paper-embed code block +// whose body is {config, mode, ref}; paper renders it via the provider that owns +// the ref's prefix (libfec, here). Returns the new doc id. +async function seedPaperDoc() { + const blocks = PAPER_EMBED_CANDIDATES.map( + (cid) => + '```paper-embed\n' + + JSON.stringify({ + config: {}, + mode: 'block', + ref: `/-/libfec/candidate/${DB_NAME}/${cid}`, + }) + + '\n```' + ).join('\n\n'); + const content = + '# 2024 Ohio Senate\n\n' + + 'The two campaigns in the race, embedded as live FEC-candidate cards:\n\n' + + blocks + + '\n'; + const resp = await fetch(`${PAPER}/api/docs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `ds_actor=${paperCookie()}`, + }, + body: JSON.stringify({ name: '2024 Ohio Senate', content }), + }); + if (!resp.ok) { + throw new Error(`create paper doc → ${resp.status}: ${await resp.text()}`); + } + const { id } = await resp.json(); + return id; +} + +// Kill the server's whole process group (datasette is uv's child). Idempotent. +function stopServer(child) { + if (!child || child.exitCode !== null) return; + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + try { + child.kill('SIGKILL'); + } catch { + // already gone + } + } +} + +// --------------------------------------------------------------------------- +// Per-page stabilization: kill carets / transitions and hide dev-only widgets so +// a re-run with no UI change produces no binary diff. +const STABILITY_CSS = `*, *::before, *::after { + caret-color: transparent !important; + transition: none !important; + animation: none !important; +} +#datasette-debug-bar { display: none !important; }`; + +async function freezeVolatile(page) { + await page.evaluate(() => { + document.getElementById('datasette-debug-bar')?.remove(); + }); +} + +async function makeContext(browser, viewport = VIEWPORT, cookieActor = null) { + const ctx = await browser.newContext({ viewport, deviceScaleFactor: 2 }); + // The paper shot views a doc owned by `alice`; the rest browse anonymously. + if (cookieActor) { + await ctx.addCookies([{ name: 'ds_actor', value: paperCookie(), url: BASE }]); + } + await ctx.addInitScript((css) => { + const inject = () => { + if (document.getElementById('__shots_stability')) return; + const s = document.createElement('style'); + s.id = '__shots_stability'; + s.textContent = css; + (document.head || document.documentElement).appendChild(s); + }; + inject(); + document.addEventListener('DOMContentLoaded', inject); + }, STABILITY_CSS); + return ctx; +} + +// --------------------------------------------------------------------------- +// Per-plugin shot map. Each entry opens a context, navigates, WAITS ON A REAL +// READINESS SELECTOR (not sleep), then screenshots. +function buildShots(browser, { paperDocId } = {}) { + const out = (n) => resolve(OUT, `${n}.png`); + + async function shotPage(name, url, readySelector, viewport = VIEWPORT) { + const ctx = await makeContext(browser, viewport); + const page = await ctx.newPage(); + await page.goto(url, { waitUntil: 'networkidle' }); + await page.locator(readySelector).first().waitFor({ timeout: 15_000 }); + await freezeVolatile(page); + await page.screenshot({ path: out(name) }); + await ctx.close(); + } + + // A datasette-paper document embedding FEC-candidate cards. Unlike the plain + // pages, this loads paper's editor (a live collab connection that never goes + // network-idle), so wait on domcontentloaded + explicit readiness: the editor + // surface, then both embed cards resolved (each card's `mount()` swaps its + // "Loading candidate…" placeholder for a card carrying a "Candidate ID" row). + async function shotPaperDoc(name) { + if (!paperDocId) throw new Error('paper doc was not seeded'); + const ctx = await makeContext(browser, VIEWPORT_TALL, PAPER_ACTOR); + const page = await ctx.newPage(); + await page.goto(`${PAPER}/doc/${paperDocId}`, { + waitUntil: 'domcontentloaded', + }); + await page.locator('.ProseMirror').first().waitFor({ timeout: 15_000 }); + await page.waitForFunction( + (n) => + [...document.querySelectorAll('span')].filter((s) => s.textContent === 'Candidate ID') + .length >= n, + PAPER_EMBED_CANDIDATES.length, + { timeout: 15_000 } + ); + await freezeVolatile(page); + await sleep(300); // let the cards' final layout settle before capture + await page.screenshot({ path: out(name) }); + await ctx.close(); + } + + return { + // Landing page: nav cards + "Most Recent Filings" table. + index: () => shotPage('index', APP, '.recent-filings table tbody tr'), + + // A race: 2024 Ohio Senate (Brown vs Moreno + field). + contest: () => + shotPage( + 'contest', + `${APP}/contest?state=OH&office=S&cycle=2024`, + '.candidates-table tbody tr' + ), + + // A candidate: Sherrod Brown. + candidate: () => + shotPage( + 'candidate', + `${APP}/candidate/${SHERROD_BROWN}?cycle=2024`, + '.candidate-page .filings-table' + ), + + // A committee: Fairshake super PAC. + committee: () => + shotPage( + 'committee', + `${APP}/committee/${FAIRSHAKE}?cycle=2024`, + '.committee-page .filings-table' + ), + + // A single filing's cover detail. + filing: () => + shotPage('filing', `${APP}/filing/${BROWN_YE_FILING}`, '.filing-detail .info-section'), + + // Filing Day: compare F3 reports across a field for one reporting period. + 'filing-day': () => + shotPage( + 'filing-day', + `${APP}/filing-day?report=Q3&year=2024&office=S`, + '.reports-table tbody tr' + ), + + // Import form: pull FEC filings for a committee/candidate/contest. + import: () => shotPage('import', `${APP}/import`, '.import-section form'), + + // RSS watcher: auto-import new filings from the FEC RSS feed. + rss: () => shotPage('rss', `${APP}/rss`, '.rss-section .status-card'), + + // Alerts: build watchlists that notify on new filings / contributors. + alerts: () => shotPage('alerts', `${APP}/alerts`, '.create-section form'), + + // Paper: an FEC candidate embedded as a live card inside a paper document + // (the datasette-paper embed provider). Needs `--extra paper` + a seeded doc. + paper: () => shotPaperDoc('paper'), + }; +} + +// The full shot set, in capture order. Kept as a static list (not derived from +// buildShots) so we can resolve the requested subset — and thus whether the +// server needs datasette-paper — before booting. buildShots() is asserted to +// cover exactly these below. +const SHOT_NAMES = [ + 'index', + 'contest', + 'candidate', + 'committee', + 'filing', + 'filing-day', + 'import', + 'rss', + 'alerts', + 'paper', +]; + +// --------------------------------------------------------------------------- +async function main() { + const requested = new Set(process.argv.slice(2)); + const unknown = [...requested].filter((n) => !SHOT_NAMES.includes(n)); + if (unknown.length) { + throw new Error(`unknown shot(s): ${unknown.join(', ')} (have: ${SHOT_NAMES.join(', ')})`); + } + const todo = requested.size ? SHOT_NAMES.filter((n) => requested.has(n)) : SHOT_NAMES; + const needsPaper = todo.includes('paper'); + + await mkdir(OUT, { recursive: true }); + console.log(`booting datasette on ${BASE}${needsPaper ? ' (with paper)' : ''} …`); + const server = await startServer(needsPaper); + const onSignal = () => { + stopServer(server); + process.exit(130); + }; + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + + const paperDocId = needsPaper ? await seedPaperDoc() : null; + + const browser = await chromium.launch(); + try { + const shotsByName = buildShots(browser, { paperDocId }); + const missing = SHOT_NAMES.filter((n) => !shotsByName[n]); + if (missing.length) { + throw new Error(`buildShots missing: ${missing.join(', ')}`); + } + + for (const name of todo) { + await shotsByName[name](); + console.log(`✓ ${name} → ${resolve(OUT, name + '.png')}`); + } + } finally { + await browser.close(); + stopServer(server); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});