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.
5 files+159−20/5 viewed
| 7 | 7 | # migrate pending D1 migrations, before any code | |
| 8 | 8 | # core, edge, front the units of each stage, in jobs that share a build; | |
| 9 | 9 | # a stage starts only when the one before it succeeded | |
| 10 | + | # smoke sign-in, sign-up and the waitlist still work on g1t.sh | |
| 10 | 11 | # | |
| 11 | 12 | # Each run that deploys is one production deployment of g1t.sh, made by the | |
| 12 | 13 | # jobs that name `environment: production` (one per run, however many jobs): | |
| ⋯ | |||
| 252 | 253 | max-parallel: 4 | |
| 253 | 254 | matrix: ${{ fromJSON(needs.plan.outputs.front) }} | |
| 254 | 255 | 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 | |
| 124 | 124 | let concurrency = deploy.concurrency.as_ref().unwrap(); | |
| 125 | 125 | assert_eq!(concurrency.group, "deploy-production"); | |
| 126 | 126 | 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"]); | |
| 128 | 128 | // The stages share their steps (a YAML alias), and read the token only | |
| 129 | 129 | // where they deploy. | |
| 130 | 130 | let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len(); | |
| ⋯ | |||
| 168 | 168 | assert!(!starts_after(&deploy, "core", &skipped, &all, push.clone(), false, true)); | |
| 169 | 169 | assert!(!starts_after(&deploy, "front", &skipped, &all, push.clone(), false, true)); | |
| 170 | 170 | ||
| 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")], ¬hing, push.clone(), false)); | |
| 178 | + | ||
| 171 | 179 | // A dry run plans and stops. | |
| 172 | 180 | let dry = json!({ "dry_run": true, "units": "", "all": false }); | |
| 173 | 181 | 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)); | |
| 175 | 184 | } | |
| 176 | 185 | ||
| 177 | 186 | #[test] | |
| 429 | 429 | | `plan` | `plan --github-output`: outputs per stage, the plan in the run's summary | `check` | | |
| 430 | 430 | | `migrate` | `migrate --only <units with pending migrations>` | `plan`; skipped when none are pending | | |
| 431 | 431 | | `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 | | |
| 432 | 433 | ||
| 433 | 434 | - **One at a time:** `concurrency: deploy-production`, never cancelled in | |
| 434 | 435 | progress; a second push waits. |
| 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 | + | } |
| 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 <b>invite?</b></h1></html>`; | |
| 8 | + | assert.equal(pageText(html), "Have an invite?"); | |
| 9 | + | assert.equal(pageText("<p>Don'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 | + | }); |