Skip to content
100 linesCodeBlameRaw

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.sh1#!/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
14import { pathToFileURL } from "node:url";
15
16/** The checks, in order. `body` is the page's text with tags removed. */
17export 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. */
38export function pageText(html) {
39 return html
40 .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, " ")
41 .replace(/<[^>]+>/g, " ")
42 .replace(/&nbsp;|&#160;/g, " ")
43 .replace(/&amp;/g, "&")
44 .replace(/&#x27;|&#39;/g, "'")
45 .replace(/&quot;/g, '"')
46 .replace(/\s+/g, " ")
47 .trim();
48}
49
50/** What is wrong with one answer, or null when it is as expected. */
51export 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
59async 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
92if (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}