zudo-test-wisdom
GitHub repository

Type to search...

to open search from anywhere

Post-Deploy Smoke Tests

The only tier that can see a deploy-time failure -- a wrong DNS record, an unattached domain, an expired certificate -- plus the skip-path discipline that stops such a test from quietly passing through the outage it exists to catch.

A build can be green, the unit suite green, the built output validated, and the site still be unreachable -- because the failure happened at deploy time. A custom domain never got attached, a route bound to the wrong Worker, a certificate expired, a binding resolved to an empty resource. None of those live in the repository, so nothing that runs against the repository can see them. The only test that can is one that talks to the deployed origin over the public network, after the deploy.

This page is about that tier: what to assert, and -- the part that actually decides whether the test is worth having -- how to handle "the site isn't up yet" without turning the check into a rubber stamp.

What Only This Tier Can See

Level 3 build-output tests read dist/ on disk, and site-integrity gates crawl a locally served build. Both stop at the artifact. A post-deploy smoke test starts where they end:

  • The hostname resolves, and to the right thing. A route can be created against a Worker that is never deployed -- for example a custom domain attached at the top level of a wrangler config while production actually deploys with --env ai to a differently-named Worker. Everything upstream is green; the domain serves nothing.

  • TLS is valid for this host. Certificates expire on already-working domains. No build-time check will ever notice.

  • Bindings resolve against real infrastructure. A D1 or KV binding can typecheck, build, and deploy, then return empty because the id in the committed config points at a resource that does not exist.

  • The request actually reaches your code. With a static-asset Worker, the asset layer may answer before the Worker runs -- so a gate, a redirect, or an API route silently never executes.

Assert the Contract, Not the Weather

A post-deploy check runs against a live system, which means it will encounter states that are not your bug: a third-party origin having a bad minute, an edge cache that has not warmed, a model endpoint rate-limiting. Assert what your deploy controls.

  • Do assert the status code, that the response came from your Worker rather than the asset layer, the content type, a marker string unique to this site, and the specific behavior the deploy is supposed to enable.

  • Don't assert a cache HIT on a first request (edge state is not deterministic on a cold deploy, and the answering PoP varies -- prime, then re-request, with bounds), or that a live model returned a real answer when your code documents a deterministic fallback. A test that demands the lucky branch is flaky by construction, and a flaky post-deploy gate gets ignored, which is worse than not having one.

For an endpoint with a documented fallback, assert the response shape -- either branch is a correctly working endpoint.

The Skip Path Is Where These Tests Go Wrong

A freshly attached domain is not instantly reachable, so a smoke test that hard-fails on "not reachable" goes red on a perfectly good deploy. The usual fix is a skip path: recognise a set of "not wired up yet" conditions, emit a notice, exit 0.

That skip path is the single most dangerous part of the test, because an unreachable site and a skipped test look identical from the outside -- both are a green build with no assertions run. Three rules keep it honest.

1. Never put an expired certificate on the skip list

The most common way this breaks is a broad TLS matcher:

// WRONG: also swallows CERT_HAS_EXPIRED
if (/CERT|TLS|SSL|HANDSHAKE/.test(code)) return skip();

A domain mid-provisioning presents a cert that does not cover the host, or no usable cert. It never presents an expired one -- a freshly issued certificate cannot already be expired. So an expiry can only mean an already-working domain broke, which is exactly the outage a post-deploy check exists to catch. A broad pattern exits 0 straight through it.

Use an allow-list of provisioning-shaped codes instead, and leave expiry out:

const PROVISIONING = new Set([
  "ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT",
  "ENETUNREACH", "EHOSTUNREACH",
  "ERR_TLS_CERT_ALTNAME_INVALID", "SELF_SIGNED_CERT_IN_CHAIN",
  "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "DEPTH_ZERO_SELF_SIGNED_CERT",
]);
// CERT_HAS_EXPIRED deliberately absent -- it fails.

2. Stop skipping once the host has answered

The skip path exists for a host that is not up. The moment any request in the run comes back, the site is demonstrably deployed, and a later connection failure is a real fault. Latch it:

let answered = false;
// ...on any successful response: answered = true
// ...on a connection failure: if (answered) throw; else maybeSkip();

Without the latch, a site that dies halfway through the run gets reclassified as "not deployed yet" and passes.

3. Retire the skip path with a flag once the domain is live

"Not wired up yet" is a real state exactly once per domain. After that it is an outage. Gate the whole skip path behind an environment variable and set it in CI once the domain is confirmed:

- name: Smoke-test the custom domain
  env:
    SMOKE_REQUIRE_LIVE: "1"   # skips become failures
  run: node scripts/smoke.mjs

Keeping the skip logic in the script rather than deleting it means the same script still serves a sibling project whose domain is not attached yet -- the flag, not a code edit, is what distinguishes them.

Warning

Scope the flag to the right concern. SMOKE_REQUIRE_LIVE should retire the domain-not-ready skip, not every tolerance in the script. Upstream third-party flakiness and edge-cache nondeterminism are different concerns: a proxied origin having an outage is not your deploy being broken. Collapsing them into one switch either makes the gate flaky or makes it blind.

Send What a Browser Sends, or You Test a Path No User Takes

A post-deploy check speaks to a live system over HTTP, so it is tempting to treat any HTTP client as equivalent to a browser. They are not, and the gap has already shipped an outage.

Cloudflare's static-asset layer applies not_found_handling only to navigation requests -- ones carrying sec-fetch-mode: navigate, which every browser sends when a person opens a URL. A request without it that matches no asset falls through to the Worker instead. On a site whose index is server-rendered, those two paths return completely different things:

curl bare                            -> 200  the real homepage
curl -H 'sec-fetch-mode: navigate'   -> 404  the 404 page
headless browser                     -> 404

The site was broken for every human visitor while the smoke suite was green, because the suite requested it in a shape no user produces.

Danger

fetch() cannot send that header, so adding it to a Node smoke test does nothing. Sec- prefixed names are forbidden header names in the Fetch spec, so undici -- and therefore Node's global fetch() -- strips them silently. No error, no warning; the request goes out as an ordinary one:

await fetch(url, { headers: { "sec-fetch-mode": "navigate" } });  // -> 200, header dropped

This is worse than not trying, because the code now looks like it reproduces a browser navigation. Verify against a server that echoes request headers, or compare against curl, before believing it.

Use a client that writes headers verbatim. node:https is built in and needs no dependency:

import { request as httpsRequest } from "node:https";

function navigationGet(url) {
  return new Promise((resolve, reject) => {
    const u = new URL(url);
    const req = httpsRequest(
      {
        hostname: u.hostname,
        path: u.pathname + u.search,
        method: "GET",
        headers: {
          accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
          "sec-fetch-mode": "navigate",
          "sec-fetch-dest": "document",
          "sec-fetch-site": "none",
        },
      },
      (res) => {
        let body = "";
        res.setEncoding("utf8");
        res.on("data", (c) => (body += c));
        res.on("end", () => resolve({ status: res.statusCode, body }));
      },
    );
    req.on("error", reject);
    req.end();
  });
}

undici.request() also works -- it does not enforce forbidden header names -- as does driving a real headless browser, at higher cost.

Tip

Prove the new assertion fails against the broken deploy before you trust it. Run it against production while the bug is still live: it should go red with a specific message. An assertion added after the fix is deployed has never been observed failing, and a check that cannot fail is indistinguishable from one that is not running.

Verifying the Test Itself

A smoke test asserts against a live site, so its own failure modes are invisible unless you deliberately provoke them. Run it against hosts with known-bad TLS and confirm the exit codes -- badssl.com provides stable ones:

TargetExpected without the flagWith REQUIRE_LIVE
expired.badssl.com1 -- expiry is an outage1
wrong.host.badssl.com0 (skip) or 1, per your stance1
a hostname with no DNS record0 (skip)1
the real domain0 (pass)0 (pass)

Danger

Confirm the override actually took effect on every row. Scripts differ in how they accept a target -- process.argv[2], SMOKE_URL, a constant. Point one at a URL it does not read and it silently tests its own live domain instead, so every row returns exit 0 and the matrix looks like it passes while proving nothing.

This is not hypothetical; it is easy to produce a clean-looking 4/4 that is entirely meaningless. Have the script print the URL it is checking, and verify the printed host on each row. A row that passes because the override was ignored is worse than no test at all.

There is one more assertion worth singling out, because it is the only one that can catch its failure mode: on a site where a Worker gates every request, assert that a static asset is also gated. If the asset layer is consulted before the Worker, the homepage may still look protected while every real file is served straight past the gate. Only a request for an actual asset shows it.

Revision History

CreatedUpdated