-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat(demo): a Cloudflare snapshot, so people who are not in the room can open it #880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * Captures the API fixtures the Cloudflare snapshot serves. | ||
| * | ||
| * It drives the REAL local demo in a browser, as each synthetic persona, and records | ||
| * every /api response the app actually makes. Recording what the app requests -- rather | ||
| * than guessing endpoints from the API surface -- is the whole point: a hand-written | ||
| * fixture set drifts silently and produces a plausible, wrong screen, which is the | ||
| * worst thing a demo can do. | ||
| * | ||
| * Run it against a demo that has just passed its gate; the fixtures inherit whatever | ||
| * that run was showing, so they are only as honest as the run that produced them. | ||
| */ | ||
| import { mkdir, writeFile, readFile } from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const scriptDir = path.dirname(fileURLToPath(import.meta.url)); | ||
| const repoRoot = path.resolve(scriptDir, "../.."); | ||
| const outDir = path.join(repoRoot, "src/web/public/demo-snapshot"); | ||
| const registryPath = path.join(repoRoot, "src/web/lib/guided-tour/registry.ts"); | ||
|
|
||
| const webBase = process.env.STYX_DEMO_WEB_URL; | ||
| const demoPassword = process.env.STYX_DEMO_PASSWORD; // allow-secret: synthetic seed credential | ||
| if (!webBase) throw new Error("STYX_DEMO_WEB_URL must be set."); | ||
| if (!demoPassword) throw new Error("STYX_DEMO_PASSWORD must be set."); | ||
|
|
||
| const PERSONAS = { | ||
| river: "river@demo.styx.protocol", | ||
| moira: "dr.moira@demo.styx.protocol", | ||
| hr: "hr.lead@acheron.example", | ||
| alecto: "alecto@demo.styx.protocol", | ||
| sage: "sage@demo.styx.protocol", | ||
| }; | ||
|
|
||
| /** Reads route + persona pairs straight out of the tour registry, so coverage tracks it. */ | ||
| async function readRegistryRoutes() { | ||
| const source = await readFile(registryPath, "utf8"); | ||
| const entries = []; | ||
| const blocks = source.split(/\n\s*\{\s*\n/); | ||
| for (const block of blocks) { | ||
| const routePath = block.match(/^\s*path:\s*"([^"]+)"/m)?.[1]; | ||
| const persona = block.match(/^\s*persona:\s*"([^"]+)"/m)?.[1]; | ||
| if (routePath && persona) entries.push({ path: routePath, persona }); | ||
| } | ||
| return entries; | ||
| } | ||
|
|
||
| const registryRoutes = await readRegistryRoutes(); | ||
| if (!registryRoutes.length) throw new Error("no routes parsed from the guided-tour registry."); | ||
|
|
||
| // Dynamic segments are exported with concrete synthetic ids; visit the same ones so the | ||
| // fixtures match the pages that actually exist in the export. | ||
| const CONCRETE = { | ||
| "[id]": "c1000000-0000-0000-0000-000000000001", | ||
| "[slug]": "recovery-abstinence", | ||
| "[linkId]": "demo", | ||
| }; | ||
| const concretise = (route) => | ||
| route.replace(/\[[^\]]+\]/g, (segment) => CONCRETE[segment] ?? "demo"); | ||
|
|
||
| const { chromium } = await import("playwright"); | ||
| const browser = await chromium.launch({ headless: true }); | ||
|
|
||
| await mkdir(outDir, { recursive: true }); | ||
| const written = []; | ||
|
|
||
| for (const [persona, email] of Object.entries(PERSONAS)) { | ||
| const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); | ||
|
|
||
| // POST /auth/login is throttled at 5 per 60s per IP; five personas is right at the | ||
| // edge, so pace them rather than tripping it half way through a capture. | ||
| let token = ""; | ||
| for (let attempt = 1; attempt <= 4; attempt += 1) { | ||
| const response = await context.request.post(`${webBase}/api/auth/login`, { | ||
| data: { email, password: demoPassword }, // allow-secret: synthetic seed credential | ||
| }); | ||
| if (response.status() === 429) { | ||
| const waitMs = 21000; | ||
| console.log(` login throttled for ${persona}; waiting ${waitMs / 1000}s ...`); | ||
| await new Promise((resolve) => setTimeout(resolve, waitMs)); | ||
| continue; | ||
| } | ||
| if (!response.ok()) throw new Error(`login ${persona}: HTTP ${response.status()}`); | ||
| token = (await response.json()).token; // allow-secret: short-lived synthetic session token | ||
| break; | ||
| } | ||
| if (!token) throw new Error(`login ${persona}: exhausted attempts`); | ||
| await context.addCookies([{ name: "styx_auth_token", value: token, url: webBase }]); | ||
|
|
||
| const fixtures = {}; | ||
| let unreadable = 0; | ||
| // Body reads must be started in the handler AND awaited before the next navigation. | ||
| // Playwright discards response bodies once the page navigates away, so an async | ||
| // read left dangling resolves to nothing -- silently, which reads as "the app made | ||
| // no API calls" rather than "the capture raced the navigation". | ||
| let pending = []; | ||
| const page = await context.newPage(); | ||
| page.on("response", (response) => { | ||
| const url = new URL(response.url()); | ||
| if (!url.pathname.startsWith("/api/")) return; | ||
| if (response.request().method() !== "GET") return; | ||
| if (!response.ok()) return; // a failed call is not a fixture | ||
| const apiPath = url.pathname.slice("/api".length) + (url.search || ""); | ||
| pending.push( | ||
| response | ||
| .json() | ||
| .then((body) => { | ||
| fixtures[`GET ${apiPath}`] = body; | ||
| const bare = `GET ${url.pathname.slice("/api".length)}`; | ||
| if (!(bare in fixtures)) fixtures[bare] = body; | ||
| }) | ||
| .catch(() => { | ||
| unreadable += 1; | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| const drain = async () => { | ||
| const inflight = pending; | ||
| pending = []; | ||
| await Promise.allSettled(inflight); | ||
| }; | ||
|
|
||
| const routes = registryRoutes | ||
| .filter((entry) => entry.persona === persona || entry.persona === "none") | ||
| .map((entry) => concretise(entry.path)); | ||
|
|
||
| for (const route of routes) { | ||
| try { | ||
| await page.goto(`${webBase}${route}`, { waitUntil: "networkidle", timeout: 30000 }); | ||
| await page.waitForTimeout(600); | ||
| } catch { | ||
| console.log(` (skipped ${route} — did not settle)`); | ||
| } | ||
| // Before the next navigation, not after the loop. | ||
| await drain(); | ||
| } | ||
|
|
||
| await drain(); | ||
| await page.close(); | ||
| await context.close(); | ||
|
|
||
| const file = path.join(outDir, `${persona}.json`); | ||
| await writeFile(file, `${JSON.stringify(fixtures, null, 2)}\n`, "utf8"); | ||
| written.push({ persona, routes: routes.length, fixtures: Object.keys(fixtures).length }); | ||
| const note = unreadable ? ` (${unreadable} body/bodies unreadable)` : ""; | ||
| console.log(` ${persona}: ${routes.length} routes → ${Object.keys(fixtures).length} fixtures${note}`); | ||
| } | ||
|
|
||
| await browser.close(); | ||
|
|
||
| const empty = written.filter((entry) => entry.fixtures === 0); | ||
| if (empty.length) { | ||
|
Comment on lines
+153
to
+154
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The success check accepts any persona with one captured response, even when the persona's primary tour route failed. The committed Useful? React with 👍 / 👎. |
||
| // A persona with no fixtures produces a hosted screen full of nothing, which reads | ||
| // as a broken product rather than a missing capture. Refuse to ship that quietly. | ||
| throw new Error( | ||
| `no fixtures captured for: ${empty.map((entry) => entry.persona).join(", ")}.\n` + | ||
| "The usual cause is that the running demo is serving a SNAPSHOT build: a snapshot\n" + | ||
| "build overwrites .next, and its client answers from fixtures instead of calling\n" + | ||
| "/api, so the capture sees no API traffic at all. Confusingly, /api still proxies\n" + | ||
| "correctly, because rewrites were loaded when the server started.\n" + | ||
| "Rebuild the demo normally first: npm run demo:reset:verify", | ||
| ); | ||
| } | ||
|
|
||
| console.log(`PASS: captured ${written.reduce((sum, e) => sum + e.fixtures, 0)} fixtures into ${outDir}`); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| #!/usr/bin/env bash | ||
| # Builds and ships the Cloudflare snapshot: a fully static export of the demo running | ||
| # on captured synthetic fixtures, for the people who are NOT in the room. | ||
| # | ||
| # It is a strictly smaller claim than the local demo. Reads work, the guided tour works, | ||
| # and every write is refused in plain language, because there is no API, no database and | ||
| # no Redis behind it -- only JSON captured from a demo run that passed its gate. | ||
| set -euo pipefail | ||
|
|
||
| repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" | ||
| web_dir="$repo_root/src/web" | ||
| out_dir="$web_dir/out" | ||
| fixtures_dir="$web_dir/public/demo-snapshot" | ||
| native_state="$repo_root/artifacts/styx-demo-native.env" | ||
| project="${STYX_SNAPSHOT_PAGES_PROJECT:-styx-demo-snapshot}" | ||
|
|
||
| die() { echo "FAIL: $*" >&2; exit 1; } | ||
| info() { echo "▸ $*"; } | ||
| ok() { echo "✓ $*"; } | ||
|
|
||
| node24() { | ||
| if command -v mise >/dev/null 2>&1; then | ||
| mise x node@24 -- "$@" | ||
| else | ||
| "$@" | ||
| fi | ||
| } | ||
|
|
||
| capture() { | ||
| [ -f "$native_state" ] || die "start the demo first (npm run demo:reset:verify); fixtures are captured from a live run." | ||
| local web_url="" demo_password="" key value | ||
| while IFS='=' read -r key value; do | ||
| case "$key" in | ||
| STYX_DEMO_WEB_URL) web_url="$value" ;; | ||
| STYX_DEMO_PASSWORD) demo_password="$value" ;; | ||
| *) ;; | ||
| esac | ||
| done < "$native_state" | ||
| [ -n "$web_url" ] || die "no web URL in the demo state file." | ||
| [ -n "$demo_password" ] || die "no synthetic password in the demo state file." | ||
|
|
||
| info "Capturing fixtures from the live demo at ${web_url} ..." | ||
| STYX_DEMO_WEB_URL="$web_url" STYX_DEMO_PASSWORD="$demo_password" \ | ||
| node24 node "$repo_root/scripts/demo/capture-snapshot.mjs" | ||
| } | ||
|
|
||
| build() { | ||
| [ -d "$fixtures_dir" ] || die "no fixtures. Run: npm run snapshot:capture" | ||
| local count | ||
| count="$(find "$fixtures_dir" -name '*.json' | wc -l | tr -d ' ')" | ||
| [ "$count" -gt 0 ] || die "fixtures directory is empty. Run: npm run snapshot:capture" | ||
|
|
||
| info "Building the static snapshot (${count} persona fixture file(s)) ..." | ||
| # A snapshot build overwrites .next, so the running demo would afterwards serve | ||
| # snapshot client code that answers from fixtures and never calls /api. Say so; | ||
| # this exact confusion cost two capture runs to diagnose. | ||
| cd "$web_dir" | ||
| NEXT_PUBLIC_STYX_SNAPSHOT=true \ | ||
| NEXT_PUBLIC_STYX_TEST_MONEY_MODE=true \ | ||
| NEXT_PUBLIC_STYX_PRIVATE_BETA=true \ | ||
| NEXT_PUBLIC_STYX_ENV_LABEL=cloudflare-snapshot \ | ||
| NEXT_PUBLIC_STYX_FEATURE_B2B_HR_UI=true \ | ||
|
Comment on lines
+58
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This enables the guided tour in the hosted build through Useful? React with 👍 / 👎. |
||
| NODE_ENV=production \ | ||
| node24 npx next build | ||
| cd "$repo_root" | ||
|
|
||
| [ -f "$out_dir/index.html" ] || die "export produced no index.html." | ||
| ok "Static snapshot in ${out_dir} ($(find "$out_dir" -name '*.html' | wc -l | tr -d ' ') pages)." | ||
| echo " NOTE: this overwrote .next — rebuild the local demo before capturing again:" | ||
| echo " npm run demo:reset:verify" | ||
| } | ||
|
|
||
| serve() { | ||
| [ -f "$out_dir/index.html" ] || die "nothing built. Run: npm run snapshot:build" | ||
| info "Serving the snapshot on http://127.0.0.1:4315 (Ctrl-C to stop) ..." | ||
| # NOT `serve -s`. SPA mode rewrites every unmatched path to index.html, so each | ||
| # route returns the landing page with a 200 while the client router still shows the | ||
| # right URL -- a local preview that looks fine and tests nothing. This export has a | ||
| # real HTML file per route, which is also how Cloudflare Pages serves it. | ||
| node24 npx --yes serve "$out_dir" -l 4315 | ||
| } | ||
|
|
||
| deploy() { | ||
| [ -f "$out_dir/index.html" ] || die "nothing built. Run: npm run snapshot:build" | ||
| command -v npx >/dev/null 2>&1 || die "npx is required to run wrangler." | ||
| info "Deploying to Cloudflare Pages project '${project}' ..." | ||
| npx --yes wrangler pages deploy "$out_dir" --project-name "$project" | ||
| } | ||
|
|
||
| case "${1:-}" in | ||
| capture) capture ;; | ||
| build) build ;; | ||
| serve) serve ;; | ||
| deploy) deploy ;; | ||
| *) die "usage: bash scripts/demo/snapshot.sh {capture|build|serve|deploy}" ;; | ||
| esac | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { snapshotContractParams } from '../../../lib/snapshot-params'; | ||
|
|
||
| /** | ||
| * Exists only to declare the pages the Cloudflare snapshot export generates for this | ||
| * dynamic segment -- `output: export` refuses to build a dynamic route without it, and | ||
| * the page itself is a client component, which cannot carry this export. | ||
| * | ||
| * It covers both /contracts/[id] and /contracts/[id]/attest, since both live under this | ||
| * segment. Outside a snapshot build the helper returns [], leaving these routes to | ||
| * render on demand exactly as they did before. | ||
| */ | ||
| export function generateStaticParams() { | ||
| return snapshotContractParams(); | ||
| } | ||
|
|
||
| export default function ContractSegmentLayout({ children }: { children: React.ReactNode }) { | ||
| return children; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a capture produces no API responses—the failure mode explicitly handled below—this write has already replaced the persona's previously valid fixture with
{}before the script throws.snapshot.sh buildchecks only that JSON files exist, not that they contain entries, so a user who retries the build after the failed capture can publish an empty snapshot; collect and validate all persona results before replacing the committed files.Useful? React with 👍 / 👎.