Skip to content

Commit

Deploy ends with a smoke test: sign-in, sign-up and the waitlist still work on g1t.sh

scripts/ops/smoke.mjs loads the landing page, sign-in, sign-up and pricing, and sends the waitlist's Request access form an address identity refuses before it keeps or counts anything, so the form is proved to reach identity without touching the real waitlist. The Deploy workflow's new smoke job runs it after every stage that deployed; DEPLOYING.md and the workflow tests cover when it runs.

syntaqxcommitted Parentf3accceBrowse files
5 files+159−20/5 viewed
+16−0
77 # migrate pending D1 migrations, before any code
88 # core, edge, front the units of each stage, in jobs that share a build;
99 # a stage starts only when the one before it succeeded
10+# smoke sign-in, sign-up and the waitlist still work on g1t.sh
1011 #
1112 # Each run that deploys is one production deployment of g1t.sh, made by the
1213 # jobs that name `environment: production` (one per run, however many jobs):
252253 max-parallel: 4
253254 matrix: ${{ fromJSON(needs.plan.outputs.front) }}
254255 steps: *deploy
256+
257+ # Once everything has deployed: the ways in for someone new still work.
258+ # The pages a visitor lands on load, and the waitlist form reaches
259+ # identity, sent an address it refuses before keeping anything, so the
260+ # real waitlist is never touched. scripts/ops/smoke.mjs.
261+ smoke:
262+ name: Smoke
263+ needs: [plan, core, edge, front]
264+ if: ${{ !failure() && !cancelled() && inputs.dry_run != true && (needs.plan.outputs.has_core == 'true' || needs.plan.outputs.has_edge == 'true' || needs.plan.outputs.has_front == 'true') }}
265+ runs-on: ubuntu-latest
266+ timeout-minutes: 5
267+ steps:
268+ - uses: actions/checkout@v5
269+ - name: Sign-in, sign-up and the waitlist work
270+ run: node scripts/ops/smoke.mjs
+11−2
124124 let concurrency = deploy.concurrency.as_ref().unwrap();
125125 assert_eq!(concurrency.group, "deploy-production");
126126 assert_eq!(concurrency.cancel_in_progress, json!(false));
127− assert_eq!(deploy.job_order(), ["check", "plan", "migrate", "core", "edge", "front"]);
127+ assert_eq!(deploy.job_order(), ["check", "plan", "migrate", "core", "edge", "front", "smoke"]);
128128 // The stages share their steps (a YAML alias), and read the token only
129129 // where they deploy.
130130 let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len();
168168 assert!(!starts_after(&deploy, "core", &skipped, &all, push.clone(), false, true));
169169 assert!(!starts_after(&deploy, "front", &skipped, &all, push.clone(), false, true));
170170
171+ // Smoke follows the last stage that ran, and not a failed one.
172+ assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "success"), ("front", "success")], &all, push.clone(), false));
173+ assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "skipped"), ("front", "success")], &none, push.clone(), false));
174+ assert!(!starts(&deploy, "smoke", &[("core", "success"), ("edge", "failure"), ("front", "skipped")], &all, push.clone(), false));
175+ // Nothing deployed: nothing to smoke-test.
176+ let nothing = plan_outputs(false, &[], &[], &[]);
177+ assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &nothing, push.clone(), false));
178+
171179 // A dry run plans and stops.
172180 let dry = json!({ "dry_run": true, "units": "", "all": false });
173181 assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false));
174− assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry, false));
182+ assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry.clone(), false));
183+ assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &all, dry, false));
175184 }
176185
177186 #[test]
+1−0
429429 | `plan` | `plan --github-output`: outputs per stage, the plan in the run's summary | `check` |
430430 | `migrate` | `migrate --only <units with pending migrations>` | `plan`; skipped when none are pending |
431431 | `core`, `edge`, `front` | `deploy --only <units> --force --no-migrations`, one job per build group | the stages before; skipped when empty |
432+| `smoke` | `node scripts/ops/smoke.mjs`: the landing page, sign-in, sign-up and pricing load, and the waitlist form reaches identity (sent an address identity refuses before keeping or counting anything, so the real waitlist is never touched) | every stage; skipped when nothing deployed |
432433
433434 - **One at a time:** `concurrency: deploy-production`, never cancelled in
434435 progress; a second push waits.
+100−0
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(/&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. */
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+}
+31−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { CHECKS, pageText, verdict } from "./smoke.mjs";
5+
6+test("a page's text leaves out scripts, styles and tags", () => {
7+ const html = `<html><style>.a{}</style><script>var x="Request access"</script><h1>Have an&nbsp;<b>invite?</b></h1></html>`;
8+ assert.equal(pageText(html), "Have an invite?");
9+ assert.equal(pageText("<p>Don&#x27;t</p>"), "Don't");
10+});
11+
12+test("a check passes on its status with every phrase on the page", () => {
13+ const check = { path: "/register", expect: ["Have an invite?", "Request access"] };
14+ assert.equal(verdict(check, 200, "<p>Have an invite?</p><button>Request access</button>"), null);
15+ assert.equal(verdict(check, 500, ""), "answered 500, expected 200");
16+ assert.equal(verdict(check, 200, "<p>Have an invite?</p>"), 'missing "Request access"');
17+});
18+
19+test("a phrase only inside a script does not count", () => {
20+ const check = { path: "/", expect: ["Request access"] };
21+ assert.equal(verdict(check, 200, `<script>"Request access"</script>`), 'missing "Request access"');
22+});
23+
24+test("the waitlist check sends an address identity refuses before writing", () => {
25+ const waitlist = CHECKS.find((check) => check.form);
26+ assert.ok(waitlist);
27+ assert.equal(waitlist.form.intent, "request-access");
28+ assert.ok(!waitlist.form.email.includes("@"), "never a real-looking address, so nothing is kept");
29+ assert.equal(waitlist.status, 422);
30+ assert.equal(verdict(waitlist, 422, "<p>Enter a valid email address.</p>"), null);
31+});