Proving a PWA Boots Offline: A Headless Chrome Test Over the DevTools Protocol
An automated test that installs a PWA under production headers, cuts the network at the protocol level, reloads, and checks the app still works, with no test framework beyond a WebSocket.
Why offline needs its own test
The RPOMS Print Engine is a label-printing workstation for a production line, and the line cannot stop because the internet does. The requirement was that a workstation, once opened online, boots and prints from cache and local data with the network unplugged. Unit tests can prove that the print path makes no network request. They cannot prove that a real browser will load the app with no network, because that depends on the service worker, the cache, the build and the response headers all working together.
The first offline run showed why this needs automating. The service worker registered and the app looked installed, but the cache was empty and an offline reload showed the browser's error page. The cause is in the Workbox duplicate-entry article. After that I wanted a test that fails loudly the next time. This article describes it: a single Node script, no Playwright, that talks to a headless Chromium browser over the DevTools protocol.
Serve the build the way production does
A test against vite preview would test the wrong thing. Production serves the app through nginx with a strict Content Security Policy, a Permissions-Policy that allows Bluetooth for the app's own origin only, and cache headers that decide whether a stale service worker survives. So the test starts a small Node static server that serves the built dist/ with exactly the production headers.
Those headers are defined once, in a module that both the static server and a unit test import. The unit test reads the nginx site file and its header snippet and fails unless every header appears in both, verbatim, with the module's value. So the offline test runs against the same policy nginx sends, and a header change cannot slip into one place and not the other.
- 01Build the app
- 02Start the static server with production headers
- 03Launch headless Chromium with a remote-debugging port and a fresh profile
- 04Connect a WebSocket to the page target
- 05Run the steps; collect console, exception and CSP log entries throughout
The script prefers Brave when it is installed, because Brave is the browser the workstations use, and falls back to Chrome, Chromium or Edge. In CI it runs under headless Chromium as the step called "Production headers and offline boot".
Talking to the browser without a framework
The DevTools protocol is JSON over a WebSocket. Every command has an id, and the reply carries the same id. Events arrive on the same socket without one. A promise map by id is all the plumbing needed:
const send = (method, params = {}) => new Promise((res, rej) => {
const i = ++id;
pending.set(i, { res, rej });
ws.send(JSON.stringify({ id: i, method, params }));
});
const evaluate = async (expression) => {
const r = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails));
return r.result.value;
};The message handler resolves pending commands and also watches three event streams for the whole run: log entries that mention the Content Security Policy, uncaught exceptions, and console.error calls. Any of those at the end fails the test, even if every functional step passed.
The steps, in order
- Fetch the app shell and check every production header is present, and that
sw.jsis served withCache-Control: no-cache. - Load the app online. Check it is a secure context with the service worker API.
- Wait for an active service worker registration, polling for up to ten seconds.
- Open every cache and count entries; require
index.htmland the label logo to be cached. - Check the local IndexedDB database exists, that no Bluetooth chooser opened on load, and that the scan field is shown.
- Cut the network with
Network.emulateNetworkConditionsset to offline, then reload. - Check the Router page boots with its scan field and the offline indicator shows.
- Open the template page and read the preview canvas's pixels: the frozen router label must render from cached assets with real ink on it.
- Check no network fetch failed while offline, and the System panel reports "Offline Ready: YES".
- Go back online, open another page, and require zero CSP violations and zero uncaught errors for the whole run.
Two of these are worth explaining. Cutting the network through the protocol, rather than stopping the static server, means the browser genuinely believes it is offline: navigator.onLine turns false and every request fails as it would with a cable pulled. And the pixel check on step 8 is what proves the fonts and logo came from cache. A page can boot offline and still draw a label with missing images. Counting dark pixels on the canvas catches that. The Brave run recorded 31,289 black dots.
What it has caught
The first version of the test found the empty precache. The version that runs under production headers found something else on its first run: eight fonts had been inlined into the bundle as data: URLs, and the production policy, font-src 'self', blocked every one of them. A development server with no policy would never have shown it. On a label printer it matters, because the renderer draws label text with bundled fonts so every workstation produces the same raster. Fonts are now always emitted as files.
| Run | Browser | Precached entries | Result |
|---|---|---|---|
| 25 Sep 2026 | headless Chrome | 95 | All steps pass |
| 27 Sep 2026 | headless Brave, production headers | 103 | All steps pass; no CSP violation after the font fix |
What it does not prove
The test proves the app boots and renders offline. It does not print, because there is no printer in CI or on the machine that ran it. A separate unit test replaces fetch with a function that records calls and rejects, then runs scans all the way through a simulated printer and requires the call list to be empty. That proves the print path makes no network request. A physical offline run, scanning real routers on a workstation with the cable pulled and a B1 Pro connected, is part of the hardware validation that has not been recorded yet.
The cost of the whole approach is one Node script of about a hundred lines and no dependencies beyond the browser. The gain is that "works offline" is a check that fails in CI rather than a property someone discovers on the line.
Keep reading
The Service Worker Was Active and the Cache Was Empty: A Workbox Duplicate-Entry Bug
An offline PWA that registered its service worker, cached nothing, and failed silently. The cause was one file listed twice in the precache manifest by vite-plugin-pwa's includeAssets.
Security Headers for a Web Bluetooth PWA, Pinned by a Test
The CSP, Permissions-Policy and COOP for a PWA that drives a Bluetooth printer, why each source is there, a separate policy for the API, and a test that keeps nginx and the app in step.
Printing Labels From the Browser Over Web Bluetooth: The RPOMS Print Engine
A scan-to-label workflow that drives a NIIMBOT B1 Pro directly from Chrome, with no vendor app and no backend. What the protocol work looked like, how 116 tests run without a printer, and what the first real print taught.