Rukon
Full-Stack Development5 min read

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.

By

PWAChrome DevTools ProtocolOfflineTestingService workerCI

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.

  1. 01Build the app
  2. 02Start the static server with production headers
  3. 03Launch headless Chromium with a remote-debugging port and a fresh profile
  4. 04Connect a WebSocket to the page target
  5. 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;
};
jsFrom tools/offline-check.mjs.

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

  1. Fetch the app shell and check every production header is present, and that sw.js is served with Cache-Control: no-cache.
  2. Load the app online. Check it is a secure context with the service worker API.
  3. Wait for an active service worker registration, polling for up to ten seconds.
  4. Open every cache and count entries; require index.html and the label logo to be cached.
  5. Check the local IndexedDB database exists, that no Bluetooth chooser opened on load, and that the scan field is shown.
  6. Cut the network with Network.emulateNetworkConditions set to offline, then reload.
  7. Check the Router page boots with its scan field and the offline indicator shows.
  8. 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.
  9. Check no network fetch failed while offline, and the System panel reports "Offline Ready: YES".
  10. 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.

RunBrowserPrecached entriesResult
25 Sep 2026headless Chrome95All steps pass
27 Sep 2026headless Brave, production headers103All 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.