| 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 | } |