Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Deploy ends with a smoke test: sign-in, sign-up and the waitlist still work on g1t.sh | 1 | #!/usr/bin/env node |
| 2 | // After a deploy: the ways in for someone new still work. The pages a | |
| 3 | // visitor lands on load, and the waitlist's Request access form reaches | |
| 4 | // identity and answers. It writes nothing: the form is sent an address | |
| 5 | // identity refuses before it keeps or counts anything, so a real waitlist | |
| 6 | // is never touched. | |
| 7 | // | |
| 8 | // node scripts/ops/smoke.mjs # https://g1t.sh | |
| 9 | // node scripts/ops/smoke.mjs --site http://localhost:5173 | |
| 10 | // | |
| 11 | // Exits 1 with what failed. The Deploy workflow runs it once every stage | |
| 12 | // has deployed. | |
| 13 | ||
| 14 | import { pathToFileURL } from "node:url"; | |
| 15 | ||
| 16 | /** The checks, in order. `body` is the page's text with tags removed. */ | |
| 17 | export const CHECKS = [ | |
| 18 | { name: "Landing page", path: "/", expect: ["Request access"] }, | |
| 19 | { name: "Sign in", path: "/login", expect: ["Sign in"] }, | |
| 20 | { | |
| 21 | name: "Sign up: invite and waitlist", | |
| 22 | path: "/register", | |
| 23 | expect: ["Have an invite?", "Request access"], | |
| 24 | }, | |
| 25 | { name: "Pricing", path: "/pricing", expect: ["Pricing"] }, | |
| 26 | { | |
| 27 | name: "Waitlist reaches identity", | |
| 28 | path: "/register", | |
| 29 | // Not an address, so identity answers "Enter a valid email address." | |
| 30 | // before its rate limit and before any write. | |
| 31 | form: { intent: "request-access", email: "smoke-test-not-an-address", about: "" }, | |
| 32 | status: 422, | |
| 33 | expect: ["Enter a valid email address."], | |
| 34 | }, | |
| 35 | ]; | |
| 36 | ||
| 37 | /** A page's visible text: scripts and styles dropped, tags removed, spaces collapsed. */ | |
| 38 | export function pageText(html) { | |
| 39 | return html | |
| 40 | .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, " ") | |
| 41 | .replace(/<[^>]+>/g, " ") | |
| 42 | .replace(/ | /g, " ") | |
| 43 | .replace(/&/g, "&") | |
| 44 | .replace(/'|'/g, "'") | |
| 45 | .replace(/"/g, '"') | |
| 46 | .replace(/\s+/g, " ") | |
| 47 | .trim(); | |
| 48 | } | |
| 49 | ||
| 50 | /** What is wrong with one answer, or null when it is as expected. */ | |
| 51 | export function verdict(check, status, html) { | |
| 52 | const want = check.status ?? 200; | |
| 53 | if (status !== want) return `answered ${status}, expected ${want}`; | |
| 54 | const text = pageText(html); | |
| 55 | const missing = check.expect.filter((phrase) => !text.includes(phrase)); | |
| 56 | return missing.length ? `missing ${missing.map((m) => JSON.stringify(m)).join(", ")}` : null; | |
| 57 | } | |
| 58 | ||
| 59 | async function run(site) { | |
| 60 | const origin = new URL(site).origin; | |
| 61 | let failed = 0; | |
| 62 | for (const check of CHECKS) { | |
| 63 | const url = new URL(check.path, origin); | |
| 64 | const started = Date.now(); | |
| 65 | let problem; | |
| 66 | try { | |
| 67 | const response = await fetch(url, { | |
| 68 | method: check.form ? "POST" : "GET", | |
| 69 | redirect: "manual", | |
| 70 | headers: { | |
| 71 | "user-agent": "g1t-smoke/1 (+https://g1t.sh)", | |
| 72 | ...(check.form ? { origin, "content-type": "application/x-www-form-urlencoded" } : {}), | |
| 73 | }, | |
| 74 | body: check.form ? new URLSearchParams(check.form).toString() : undefined, | |
| 75 | signal: AbortSignal.timeout(20_000), | |
| 76 | }); | |
| 77 | problem = verdict(check, response.status, await response.text()); | |
| 78 | } catch (error) { | |
| 79 | problem = String(error?.message ?? error); | |
| 80 | } | |
| 81 | const took = `${Date.now() - started} ms`; | |
| 82 | if (problem) { | |
| 83 | failed += 1; | |
| 84 | console.log(`FAIL ${check.name} (${check.form ? "POST" : "GET"} ${url.pathname}): ${problem} · ${took}`); | |
| 85 | } else { | |
| 86 | console.log(`ok ${check.name} · ${took}`); | |
| 87 | } | |
| 88 | } | |
| 89 | return failed; | |
| 90 | } | |
| 91 | ||
| 92 | if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, "/").replace(/^(?=[A-Za-z]:)/, "/")}`) { | |
| 93 | const at = process.argv.indexOf("--site"); | |
| 94 | const site = at > -1 ? process.argv[at + 1] : "https://g1t.sh"; | |
| 95 | const failed = await run(site); | |
| 96 | if (failed) { | |
| 97 | console.log(`${failed} of ${CHECKS.length} checks failed on ${site}`); | |
| 98 | process.exit(1); | |
| 99 | } | |
| 100 | } |