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..3a97495 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/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..3175217 --- /dev/null +++ b/frontend/scripts/screenshots.mjs @@ -0,0 +1,89 @@ +// 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`). +// +// This file is a THIN RUNNER. The harness lives in shots/ (matching the +// datasette-paper / datasette-places convention): +// * shots/config.mjs — constants + out(name) +// * shots/server.mjs — boot/teardown of the throwaway datasette +// * shots/helpers.mjs — STABILITY_CSS / freezeVolatile / makeContext +// * shots/defineShot.mjs — per-shot context → goto → wait → freeze → capture +// * shots/defs/.mjs — ONE FILE PER SHOT, auto-discovered below. +// +// libfec's pages are anonymous, read-only views over the fixture db — no cookies +// or seeding, just open the access gate and browse. +import { chromium } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { mkdir, readdir } from 'node:fs/promises'; +import { BASE, OUT, out } from './shots/config.mjs'; +import { startServer, stopServer } from './shots/server.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +// Auto-discover the shots: every shots/defs/.mjs default-exports a +// defineShot() descriptor. The file name IS the shot id — asserted below so a +// typo can't silently mis-name an output PNG. No central registry to edit. +async function discoverShots() { + const dir = resolve(HERE, 'shots/defs'); + const files = (await readdir(dir)).filter((f) => f.endsWith('.mjs')); + const shots = []; + for (const f of files) { + const name = f.replace(/\.mjs$/, ''); + const mod = await import(resolve(dir, f)); + const run = mod.default; + if (typeof run !== 'function') { + throw new Error(`shots/defs/${f} must default-export a defineShot() descriptor`); + } + if (run.shotName !== name) { + throw new Error(`shots/defs/${f}: declared name "${run.shotName}" != file name "${name}"`); + } + shots.push({ name, run, order: run.order ?? 0 }); + } + shots.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)); + return shots; +} + +async function main() { + const requested = new Set(process.argv.slice(2)); + + const shots = await discoverShots(); + const names = shots.map((s) => s.name); + const unknown = [...requested].filter((n) => !names.includes(n)); + if (unknown.length) { + throw new Error(`unknown shot(s): ${unknown.join(', ')} (have: ${names.join(', ')})`); + } + const todo = requested.size ? shots.filter((s) => requested.has(s.name)) : shots; + + await mkdir(OUT, { recursive: true }); + console.log(`booting datasette on ${BASE} …`); + const server = await startServer(); + const onSignal = () => { + stopServer(server); + process.exit(130); + }; + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + + const browser = await chromium.launch(); + try { + for (const { name, run } of todo) { + await run(browser, {}); + console.log(`✓ ${name} → ${out(name)}`); + } + } finally { + await browser.close(); + stopServer(server); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/scripts/shots/config.mjs b/frontend/scripts/shots/config.mjs new file mode 100644 index 0000000..4e04e82 --- /dev/null +++ b/frontend/scripts/shots/config.mjs @@ -0,0 +1,35 @@ +// Shared constants for the doc-screenshot harness. Imported by every other +// shots/*.mjs module and by each shot in shots/defs/. +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +// Free high port unique to this plugin (others in the family: paper 8486, +// sheets 8487, town 8489, skill-default 8490). +export const PORT = Number(process.env.SHOTS_PORT || 8491); +export const BASE = `http://localhost:${PORT}`; +// datasette database name == the data-db filename stem. +export const DB_NAME = 'data'; +// The plugin's base UI path; each shot builds its per-page URL off this. +export const APP = `${BASE}/${DB_NAME}/-/libfec`; + +// Fixed --secret for the throwaway server; stable so runs stay reproducible. +export const SECRET = 'screenshots-secret-not-for-prod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); // frontend/scripts/shots +// Pre-built demo FEC database (run `just shots-fixture` to (re)generate). Unlike +// the other plugins in this family, libfec's data is a real FEC fixture .db, not +// something a startup plugin can seed — so this is the "seed". +export const FIXTURE_DB = resolve(HERE, '../shot-data/libfec.db'); +export const DATA_DIR = '/tmp/datasette-libfec-shots-data'; // stem == DB_NAME +export const DATA_DB = `${DATA_DIR}/${DB_NAME}.db`; +export const OUT = resolve(HERE, '../../../docs/screenshots'); + +export const VIEWPORT = { width: 1200, height: 900 }; + +// --- Concrete demo entities (from the fixture: 2024 Ohio Senate + big PACs) --- +export const SHERROD_BROWN = 'S6OH00163'; // candidate id +export const FAIRSHAKE = 'C00835959'; // crypto super PAC committee id +export const BROWN_YE_FILING = '1870297'; // Friends of Sherrod Brown, F3 YE 2024 + +// Absolute path of a shot's output PNG. +export const out = (name) => resolve(OUT, `${name}.png`); diff --git a/frontend/scripts/shots/defineShot.mjs b/frontend/scripts/shots/defineShot.mjs new file mode 100644 index 0000000..7298542 --- /dev/null +++ b/frontend/scripts/shots/defineShot.mjs @@ -0,0 +1,52 @@ +// defineShot turns a declarative descriptor into the `async (browser, ids) => +// {…}` the runner calls, owning the per-shot boilerplate: open a fresh context → +// navigate → wait for readiness → interact → freeze → capture → close. +// +// Descriptor fields: +// name (required) — output PNG base name; MUST equal the shot's file name +// (asserted by the runner). +// order — run sequence (ascending, then by name). libfec shots are +// independent, so this just fixes a stable order. +// url (required) — (ids) => string, the page to screenshot. +// viewport — context viewport (default VIEWPORT). +// ready — a selector waited on after navigation (the common case). +// prepare — async (page, {ids}) for custom waits / interaction. +// capture — async (page, file, {ids}); default = full-page screenshot. +// freeze — default true; runs freezeVolatile before capture. +import { makeContext, freezeVolatile } from './helpers.mjs'; +import { VIEWPORT, out } from './config.mjs'; + +export function defineShot(desc) { + const { + name, + order = 0, + url, + viewport = VIEWPORT, + ready, + prepare, + capture, + freeze = true, + } = desc; + if (!name) throw new Error('defineShot: missing `name`'); + if (typeof url !== 'function') { + throw new Error(`defineShot(${name}): \`url\` must be a function`); + } + + const run = async (browser, ids) => { + const ctx = await makeContext(browser, { viewport }); + try { + const page = await ctx.newPage(); + await page.goto(url(ids), { waitUntil: 'networkidle' }); + if (ready) await page.locator(ready).first().waitFor({ timeout: 15_000 }); + if (prepare) await prepare(page, { ids }); + if (freeze) await freezeVolatile(page); + if (capture) await capture(page, out(name), { ids }); + else await page.screenshot({ path: out(name) }); + } finally { + await ctx.close(); + } + }; + run.shotName = name; + run.order = order; + return run; +} diff --git a/frontend/scripts/shots/defs/alerts.mjs b/frontend/scripts/shots/defs/alerts.mjs new file mode 100644 index 0000000..98d6fd8 --- /dev/null +++ b/frontend/scripts/shots/defs/alerts.mjs @@ -0,0 +1,10 @@ +import { defineShot } from '../defineShot.mjs'; +import { APP } from '../config.mjs'; + +// Alerts: build watchlists that notify on new filings / contributors. +export default defineShot({ + name: 'alerts', + order: 9, + url: () => `${APP}/alerts`, + ready: '.create-section form', +}); diff --git a/frontend/scripts/shots/defs/candidate.mjs b/frontend/scripts/shots/defs/candidate.mjs new file mode 100644 index 0000000..5baacee --- /dev/null +++ b/frontend/scripts/shots/defs/candidate.mjs @@ -0,0 +1,10 @@ +import { defineShot } from '../defineShot.mjs'; +import { APP, SHERROD_BROWN } from '../config.mjs'; + +// A candidate: Sherrod Brown. +export default defineShot({ + name: 'candidate', + order: 3, + url: () => `${APP}/candidate/${SHERROD_BROWN}?cycle=2024`, + ready: '.candidate-page .filings-table', +}); diff --git a/frontend/scripts/shots/defs/committee.mjs b/frontend/scripts/shots/defs/committee.mjs new file mode 100644 index 0000000..82cc822 --- /dev/null +++ b/frontend/scripts/shots/defs/committee.mjs @@ -0,0 +1,10 @@ +import { defineShot } from '../defineShot.mjs'; +import { APP, FAIRSHAKE } from '../config.mjs'; + +// A committee: Fairshake super PAC. +export default defineShot({ + name: 'committee', + order: 4, + url: () => `${APP}/committee/${FAIRSHAKE}?cycle=2024`, + ready: '.committee-page .filings-table', +}); diff --git a/frontend/scripts/shots/defs/contest.mjs b/frontend/scripts/shots/defs/contest.mjs new file mode 100644 index 0000000..1e24a7d --- /dev/null +++ b/frontend/scripts/shots/defs/contest.mjs @@ -0,0 +1,10 @@ +import { defineShot } from '../defineShot.mjs'; +import { APP } from '../config.mjs'; + +// A race: 2024 Ohio Senate (Brown vs Moreno + field). +export default defineShot({ + name: 'contest', + order: 2, + url: () => `${APP}/contest?state=OH&office=S&cycle=2024`, + ready: '.candidates-table tbody tr', +}); diff --git a/frontend/scripts/shots/defs/filing-day.mjs b/frontend/scripts/shots/defs/filing-day.mjs new file mode 100644 index 0000000..030d5d0 --- /dev/null +++ b/frontend/scripts/shots/defs/filing-day.mjs @@ -0,0 +1,10 @@ +import { defineShot } from '../defineShot.mjs'; +import { APP } from '../config.mjs'; + +// Filing Day: compare F3 reports across a field for one reporting period. +export default defineShot({ + name: 'filing-day', + order: 6, + url: () => `${APP}/filing-day?report=Q3&year=2024&office=S`, + ready: '.reports-table tbody tr', +}); diff --git a/frontend/scripts/shots/defs/filing.mjs b/frontend/scripts/shots/defs/filing.mjs new file mode 100644 index 0000000..d7916d7 --- /dev/null +++ b/frontend/scripts/shots/defs/filing.mjs @@ -0,0 +1,10 @@ +import { defineShot } from '../defineShot.mjs'; +import { APP, BROWN_YE_FILING } from '../config.mjs'; + +// A single filing's cover detail. +export default defineShot({ + name: 'filing', + order: 5, + url: () => `${APP}/filing/${BROWN_YE_FILING}`, + ready: '.filing-detail .info-section', +}); diff --git a/frontend/scripts/shots/defs/import.mjs b/frontend/scripts/shots/defs/import.mjs new file mode 100644 index 0000000..d84358b --- /dev/null +++ b/frontend/scripts/shots/defs/import.mjs @@ -0,0 +1,10 @@ +import { defineShot } from '../defineShot.mjs'; +import { APP } from '../config.mjs'; + +// Import form: pull FEC filings for a committee/candidate/contest. +export default defineShot({ + name: 'import', + order: 7, + url: () => `${APP}/import`, + ready: '.import-section form', +}); diff --git a/frontend/scripts/shots/defs/index.mjs b/frontend/scripts/shots/defs/index.mjs new file mode 100644 index 0000000..babb6a3 --- /dev/null +++ b/frontend/scripts/shots/defs/index.mjs @@ -0,0 +1,10 @@ +import { defineShot } from '../defineShot.mjs'; +import { APP } from '../config.mjs'; + +// Landing page: nav cards + "Most Recent Filings" table. +export default defineShot({ + name: 'index', + order: 1, + url: () => APP, + ready: '.recent-filings table tbody tr', +}); diff --git a/frontend/scripts/shots/defs/rss.mjs b/frontend/scripts/shots/defs/rss.mjs new file mode 100644 index 0000000..f888ef6 --- /dev/null +++ b/frontend/scripts/shots/defs/rss.mjs @@ -0,0 +1,10 @@ +import { defineShot } from '../defineShot.mjs'; +import { APP } from '../config.mjs'; + +// RSS watcher: auto-import new filings from the FEC RSS feed. +export default defineShot({ + name: 'rss', + order: 8, + url: () => `${APP}/rss`, + ready: '.rss-section .status-card', +}); diff --git a/frontend/scripts/shots/helpers.mjs b/frontend/scripts/shots/helpers.mjs new file mode 100644 index 0000000..0c71432 --- /dev/null +++ b/frontend/scripts/shots/helpers.mjs @@ -0,0 +1,37 @@ +// Page-stabilization + capture helpers shared by every shot. +import { VIEWPORT } from './config.mjs'; + +// Injected on the context (survives navigation) so re-runs produce no binary +// diff: kill the caret, transitions and animations, and hide the debug bar. +export const STABILITY_CSS = `*, *::before, *::after { + caret-color: transparent !important; + transition: none !important; + animation: none !important; +} +#datasette-debug-bar { display: none !important; }`; + +// Rewrite any moving text to fixed strings just before each capture. libfec's +// pages carry none, so this only removes the dev debug bar if present. +export async function freezeVolatile(page) { + await page.evaluate(() => { + document.getElementById('datasette-debug-bar')?.remove(); + }); +} + +// New context: viewport + retina, with the stability stylesheet injected on +// every navigation. +export async function makeContext(browser, { viewport = VIEWPORT } = {}) { + const ctx = await browser.newContext({ viewport, deviceScaleFactor: 2 }); + 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; +} diff --git a/frontend/scripts/shots/server.mjs b/frontend/scripts/shots/server.mjs new file mode 100644 index 0000000..f04a20a --- /dev/null +++ b/frontend/scripts/shots/server.mjs @@ -0,0 +1,106 @@ +// Boot/teardown of the throwaway datasette the shots drive. +// +// The data comes from a pre-built FEC fixture .db, copied to a mutable /tmp copy +// so datasette opens it writable (the plugin's startup hook adds its alert-queue +// table) without touching the committed fixture. The instance permission gates +// every libfec page checks are opened globally. +import { mkdir, copyFile, access } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { APP, BASE, SECRET, FIXTURE_DB, DATA_DIR, DATA_DB, PORT } from './config.mjs'; + +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Is something already answering on our port? (status < 500 = "alive"). +export 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; + } +} + +// Fresh mutable copy of the fixture db. +async function setupDataDb() { + 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); +} + +export async function startServer() { + await setupDataDb(); + // Refuse to start over an already-listening server rather than screenshot + // whatever is there — the poll can't tell a stale server from ours. + if (await reachable()) { + throw new Error( + `something is already serving on ${BASE}. Stop it (or set SHOTS_PORT) and retry.` + ); + } + // `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', + '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', + '-p', + String(PORT), + ], + { + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + // PYTHONHASHSEED=0 → any hash-derived ordering/colours stay stable. + 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}`); +} + +// Kill the server's whole process group (datasette is uv's child). Idempotent. +export function stopServer(child) { + if (!child || child.exitCode !== null) return; + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + try { + child.kill('SIGKILL'); + } catch { + // already gone + } + } +}