Skip to content
96 linesCodeBlameRaw
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 sign-up form reaches identity and
4// answers. It writes nothing: the form is sent an address identity
5// refuses before it keeps or counts anything, so no account is made and
6// the sign-up rate limit is not used up.
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: ["Sign up", "Get started"] },
19 { name: "Sign in", path: "/login", expect: ["Sign in"] },
20 { name: "Sign up", path: "/register", expect: ["Create your account", "Create account"] },
21 { name: "Pricing", path: "/pricing", expect: ["Pricing"] },
22 {
23 name: "Sign up reaches identity",
24 path: "/register",
25 // Not an address, so identity answers "Enter a valid email address."
26 // before its sign-up rate limit and before any write.
27 form: { username: "smoke-test", email: "smoke-test-not-an-address", password: "not-a-real-password" },
28 status: 422,
29 expect: ["Enter a valid email address."],
30 },
31];
32
33/** A page's visible text: scripts and styles dropped, tags removed, spaces collapsed. */
34export function pageText(html) {
35 return html
36 .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, " ")
37 .replace(/<[^>]+>/g, " ")
38 .replace(/&nbsp;|&#160;/g, " ")
39 .replace(/&amp;/g, "&")
40 .replace(/&#x27;|&#39;/g, "'")
41 .replace(/&quot;/g, '"')
42 .replace(/\s+/g, " ")
43 .trim();
44}
45
46/** What is wrong with one answer, or null when it is as expected. */
47export function verdict(check, status, html) {
48 const want = check.status ?? 200;
49 if (status !== want) return `answered ${status}, expected ${want}`;
50 const text = pageText(html);
51 const missing = check.expect.filter((phrase) => !text.includes(phrase));
52 return missing.length ? `missing ${missing.map((m) => JSON.stringify(m)).join(", ")}` : null;
53}
54
55async function run(site) {
56 const origin = new URL(site).origin;
57 let failed = 0;
58 for (const check of CHECKS) {
59 const url = new URL(check.path, origin);
60 const started = Date.now();
61 let problem;
62 try {
63 const response = await fetch(url, {
64 method: check.form ? "POST" : "GET",
65 redirect: "manual",
66 headers: {
67 "user-agent": "g1t-smoke/1 (+https://g1t.sh)",
68 ...(check.form ? { origin, "content-type": "application/x-www-form-urlencoded" } : {}),
69 },
70 body: check.form ? new URLSearchParams(check.form).toString() : undefined,
71 signal: AbortSignal.timeout(20_000),
72 });
73 problem = verdict(check, response.status, await response.text());
74 } catch (error) {
75 problem = String(error?.message ?? error);
76 }
77 const took = `${Date.now() - started} ms`;
78 if (problem) {
79 failed += 1;
80 console.log(`FAIL ${check.name} (${check.form ? "POST" : "GET"} ${url.pathname}): ${problem} · ${took}`);
81 } else {
82 console.log(`ok ${check.name} · ${took}`);
83 }
84 }
85 return failed;
86}
87
88if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, "/").replace(/^(?=[A-Za-z]:)/, "/")}`) {
89 const at = process.argv.indexOf("--site");
90 const site = at > -1 ? process.argv[at + 1] : "https://g1t.sh";
91 const failed = await run(site);
92 if (failed) {
93 console.log(`${failed} of ${CHECKS.length} checks failed on ${site}`);
94 process.exit(1);
95 }
96}