A browser test can display exactly the JSON you expected and still fail the isolation claim you thought it proved. The route may have executed route.fetch() before patching the response. A popup may have escaped an opener-level page.route() on its first navigation. A service worker may have supplied the response itself, meaning the Playwright handler credited with the “mock” never ran. Or a matching handler may have called continue(), allowing the owned localhost origin to receive the request despite a later UI assertion turning green.
That is the acceptance problem here: not “did the page look right?” but did this exact HTTP request pass through the intended handler, under the intended ownership model, without reaching the origin?
The laboratory is deliberately narrow. It uses Playwright Test with Node.js and TypeScript, the Playwright-managed Chromium revision pinned with the runner, an owned localhost HTTP server, inert JSON and HTML, fresh browser contexts, and zero retries. Worker-routing inspection is restricted to Chromium because Playwright documents service-worker inspection support in that scope. No real third-party system participates.
The evidence model counts four things separately: the logical browser action, Playwright-visible request observations, the route-handler receipt, and the server’s independent origin receipt. Only that combination supports a meaningful mock-coverage decision.
One limitation must be explicit before any examples are read as results: the canonical experiment could not be accepted as executed in the available production environment. The available browser environment administratively blocked localhost navigation and did not provide the requested locked Node/Playwright/Playwright-Chromium tuple. Therefore the demonstrations below are reproducible proposed tests with vendor-supported expected assertions, not invented measurements. Under the acceptance policy defined here, the publication status of the unexecuted canonical batch is HOLD until those rows are rerun in the frozen environment.
Define the mock claim and freeze the evidence contract
Start with the failure mode that matters: suppose a click causes GET /probe, the application renders {"source":"mock-page"}, and the test passes. That establishes only that the application consumed a particular response. It does not establish that the local origin saw zero requests, nor that the intended Playwright route supplied that response.
The claim must therefore be written before the action:
For logical action direct-fetch, with service workers blocked, the frame-owned GET /probe?run=<runId>&probe=<probeId> must match handler page-direct-fulfill, that handler must terminate the route with fulfill(), the browser must receive marker mock-page, and the origin ledger for that runId/probeId must remain at zero.
That formulation makes a retry irrelevant to the network-ownership question. Retries are disabled here; diagnosis of tests that become green on a later attempt belongs in the separate discussion of retaining failures hidden by Playwright retries. The same evidence contract applies whether a test was handwritten or produced through tooling discussed in reviewing AI-assisted test generation: generation method does not relax request-ownership evidence.
The locked environment should be an artifact, not a paragraph in somebody’s memory. For the September 22, 2026 cutoff, a suitable proposed baseline is Node.js 22.20.0 LTS and Playwright Test 1.63.0. The Node.js 22.20.0 release page identifies that version as an LTS release, while Playwright’s v1.63 browser metadata pins its Chromium build to browser revision 1243, corresponding to Chromium 153.0.8010.12.
{
"schema": 1,
"researchCutoff": "2026-09-22",
"node": "22.20.0",
"playwrightTest": "1.63.0",
"browser": {
"name": "chromium",
"playwrightRevision": "1243",
"browserVersion": "153.0.8010.12"
},
"runner": {
"retries": 0,
"workers": 1
},
"contextCommon": {
"locale": "en-US",
"timezoneId": "UTC",
"ignoreHTTPSErrors": false,
"viewport": { "width": 1280, "height": 720 }
},
"projects": {
"chromium-sw-blocked": { "serviceWorkers": "block" },
"chromium-sw-allowed": { "serviceWorkers": "allow" }
},
"fixtureCommit": "REQUIRED_GIT_COMMIT_BEFORE_ACCEPT",
"serverSourceSha256": "REQUIRED_SHA256_BEFORE_ACCEPT"
}
The placeholders are deliberate stop conditions. Do not publish an observed ACCEPT row until the actual fixture commit and server digest replace them and the worktree is clean. The package-lock.json should also pin the exact package graph, and Chromium should be installed from that locked Playwright package rather than substituted with whichever system Chrome happens to be present.
The two service-worker modes are not aliases. Playwright’s documented serviceWorkers option defaults to allow; allow permits registration, while block prevents service-worker registration. The Playwright Service Workers guide contains, in the retained documentation, a nearby example whose value is inconsistent with prose describing how to disable workers. Do not copy the contradictory example mechanically. The API option semantics are the acceptance authority: use block for the blocked project and allow for the worker-enabled project.
That distinction matters because blocking workers creates a deliberately different browser environment. It can be an excellent control for ordinary Playwright routing, but it is not evidence that the application behaves the same way when its service worker is active.
A compact Playwright configuration can make those modes explicit:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
retries: 0,
workers: 1,
fullyParallel: false,
timeout: 30_000,
projects: [
{
name: 'chromium-sw-blocked',
grep: /@blocked/,
use: {
browserName: 'chromium',
serviceWorkers: 'block',
locale: 'en-US',
timezoneId: 'UTC',
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: false
}
},
{
name: 'chromium-sw-allowed',
grep: /@allowed/,
use: {
browserName: 'chromium',
serviceWorkers: 'allow',
locale: 'en-US',
timezoneId: 'UTC',
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: false
}
}
]
});
The evidence vocabulary should remain equally explicit. A logical action ID identifies what the test meant to do, such as one button click. A browser request observation records a Playwright Request; there may be more than one observation associated with one logical action. A handler receipt is emitted by the exact route callback that ran and records its terminal action. An origin receipt is written independently by the localhost server when /probe actually arrives.
Never force those cardinalities to match. A worker forwarding a fetch can legitimately produce a frame-owned observation and a service-worker-owned observation for one logical action. Playwright specifically documents that pattern. The server oracle, not the number of browser events, tells you how many probe requests reached the origin.
Also record routing itself as an environment change. Both page.route() and browserContext.route() documentation state that enabling routing disables the HTTP cache. A route-enabled acceptance run therefore proves behavior in a cache-disabled routing environment; it should not silently be described as proof of ordinary browser cache behavior.
Build a local origin that independently counts probe requests
The origin oracle needs to be simpler than the system under test. Its job is not to emulate a production API. It needs to answer four questions reliably: is the server ready, can the page and service worker be loaded, did /probe reach the server, and what exact receipts belong to this run?
Use distinct paths:
/app serves inert test HTML.
/sw.js serves the controlled service worker.
/probe is the only request that increments the origin denominator.
/healthz proves readiness without affecting probe counts.
/evidence?run=... reports receipts and likewise does not increment them.
Each probe carries a run identifier and a probe identifier. That makes stale traffic obvious and prevents one test from interpreting another test’s receipt as its own. A source marker in the origin response makes the pass-through control visible to the UI, but that marker is secondary evidence; the in-memory receipt ledger is the oracle.
A minimal TypeScript server can remain small enough to review completely:
// fixtures/oracle-server.ts
import http, { IncomingMessage, ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
export type OriginReceipt = {
receiptId: string;
runId: string;
probeId: string;
method: string;
url: string;
source: 'origin';
at: string;
};
export async function startOracleServer() {
const receipts = new Map<string, OriginReceipt[]>();
function send(
res: ServerResponse,
status: number,
contentType: string,
body = ''
): void {
res.writeHead(status, {
'content-type': contentType,
'cache-control': 'no-store'
});
res.end(body);
}
function urlFor(req: IncomingMessage): URL {
return new URL(req.url ?? '/', 'http://localhost');
}
const server = http.createServer((req, res) => {
try {
const url = urlFor(req);
if (url.pathname === '/healthz') {
send(res, 204, 'text/plain');
return;
}
if (url.pathname === '/evidence') {
const runId = url.searchParams.get('run') ?? '';
send(
res,
200,
'application/json',
JSON.stringify(receipts.get(runId) ?? [])
);
return;
}
if (url.pathname === '/sw.js') {
const behavior = url.searchParams.get('behavior') ?? 'synthetic';
const worker = ;
const behavior = ${JSON.stringify(behavior)};
self.addEventListener('install', event => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname !== '/probe') return;
if (behavior === 'synthetic') {
event.respondWith(new Response(
JSON.stringify({ source: 'sw-synthetic' }),
{
status: 200,
headers: {
'content-type': 'application/json',
'cache-control': 'no-store'
}
}
));
return;
}
if (behavior === 'forward') {
event.respondWith(fetch(event.request));
}
});
send(res, 200, 'text/javascript; charset=utf-8', worker);
return;
}
if (url.pathname === '/app') {
const html = <!doctype html>;
<meta charset="utf-8">
<title>routing fixture</title>
<button id="fetch">fetch probe</button>
<a id="popup" target="_blank">open probe</a>
<pre id="result"></pre>
<script>
const params = new URLSearchParams(location.search);
const run = params.get('run');
window.readyWorker = async behavior => {
await navigator.serviceWorker.register(
'/sw.js?behavior=' + encodeURIComponent(behavior)
);
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise(resolve => {
navigator.serviceWorker.addEventListener(
'controllerchange', resolve, { once: true }
);
});
}
};
window.runProbe = async probe => {
const r = await fetch(
'/probe?run=' + encodeURIComponent(run) +
'&probe=' + encodeURIComponent(probe)
);
const text = await r.text();
document.querySelector('#result').textContent = text;
return { text, fromUrl: r.url };
};
document.querySelector('#fetch').onclick = () =>
window.runProbe('button');
document.querySelector('#popup').href =
'/probe?format=html&run=' + encodeURIComponent(run) +
'&probe=popup';
</script>
send(res, 200, 'text/html; charset=utf-8', html);
return;
}
if (url.pathname === '/probe') {
const runId = url.searchParams.get('run') ?? 'missing-run';
const probeId = url.searchParams.get('probe') ?? 'missing-probe';
const receipt: OriginReceipt = {
receiptId: randomUUID(),
runId,
probeId,
method: req.method ?? 'GET',
url: url.pathname + url.search,
source: 'origin',
at: new Date().toISOString()
};
const list = receipts.get(runId) ?? [];
list.push(receipt);
receipts.set(runId, list);
if (url.searchParams.get('format') === 'html') {
send(
res,
200,
'text/html; charset=utf-8',
'<!doctype html><body data-source="origin">origin-popup</body>'
);
return;
}
send(
res,
200,
'application/json',
JSON.stringify({ source: 'origin', runId, probeId })
);
return;
}
send(res, 404, 'text/plain', 'not found');
} catch (error) {
send(
res,
500,
'application/json',
JSON.stringify({
error: error instanceof Error ? error.message : 'unknown'
})
);
}
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, 'localhost', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string')
throw new Error('Expected TCP server address');
const baseURL = http://localhost:${address.port};
const health = await fetch${baseURL}/healthz);
if (health.status !== 204)
throw new ErrorHealth check failed: ${health.status});
return {
baseURL,
evidence(runId: string): OriginReceipt[] {
return [...(receipts.get(runId) ?? [])];
},
clear(runId: string): void {
receipts.delete(runId);
},
async close(): Promise<void> {
await new Promise<void>((resolve, reject) =>
server.close(error => (error ? reject(error) : resolve()))
);
}
};
}
There are two useful design properties here. First, /healthz and /evidence cannot contaminate the denominator because only the /probe branch appends a receipt. Second, tests can inspect oracle.evidence(runId) directly from Node after the browser action rather than creating another browser request whose ownership might itself become part of the analysis.
Start the server before the test suite, perform its readiness check before opening a browser context, and stop it in afterAll even when a test fails. Generate a unique run ID per case and clear only that run’s bucket. Do not reuse a run ID after a failed case; keeping its evidence intact is more useful than sanitizing history.
Before trusting a zero, prove that the oracle can count a one. The positive control is intentionally unmocked:
const response = await page.evaluate(
url => fetch(url).then(r => r.json()),
${oracle.baseURL}/probe?run=${runId}&probe=positive-control
);
expect(response.source).toBe('origin');
expect(oracle.evidence(runId)).toHaveLength(1);
If this control does not increment the server ledger exactly once, all subsequent “zero origin calls” are meaningless. That is a HOLD, not an invitation to loosen the assertion.
The server should contain no credentials, API keys, private hostnames, or redirects to non-owned destinations. The fixture is intended to answer a routing-accounting question, not to demonstrate a firewall. A green localhost isolation test says nothing by itself about unrelated destinations that the browser, Node process, extensions, DNS stack, or other contexts might contact.
Prove page-route coverage and expose the popup boundary
The direct blocked-worker case is the easiest acceptance control because request ownership is uncomplicated. Register page.route() before the action, let the handler write its own receipt, and terminate the request with fulfill().
Playwright’s general Network guide describes request routing as a way to handle HTTP traffic and distinguishes supplying a mocked response from fetching a real response and modifying it. The acceptance test uses the first form.
type HandlerReceipt = {
runId: string;
probeId: string;
handler: string;
owner: 'frame' | 'service-worker';
action: 'fulfill' | 'continue' | 'fallback' | 'fetch-fulfill';
};
const handlers: HandlerReceipt[] = [];
await page.route('**/probe?**', async route => {
const request = route.request();
const url = new URL(request.url());
handlers.push({
runId: url.searchParams.get('run') ?? '',
probeId: url.searchParams.get('probe') ?? '',
handler: 'page-direct-fulfill',
owner: request.serviceWorker() ? 'service-worker' : 'frame',
action: 'fulfill'
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ source: 'mock-page' })
});
});
const result = await page.evaluate(
() => window.runProbe('direct-page')
);
expect(JSON.parse(result.text).source).toBe('mock-page');
expect(handlers).toEqual([
expect.objectContaining({
handler: 'page-direct-fulfill',
probeId: 'direct-page',
owner: 'frame',
action: 'fulfill'
})
]);
expect(oracle.evidence(runId)).toHaveLength(0);
The acceptance evidence is conjunctive: marker correct, intended handler present, owner correct, terminal action correct, and origin delta zero. “The handler ran” alone is not enough, because later cases will show handlers that run and still use the network.
The popup boundary is more interesting. The Playwright page routing documentation states that page.route() does not intercept the first request of a popup; the recommended scope for that initial request is browserContext.route(). That makes the popup case a useful proof that route scope and test scope are not the same object.
For the negative control, install a route only on the opener:
await page.route('**/probe?**', async route => {
handlers.push({
runId,
probeId: 'popup',
handler: 'opener-page-route',
owner: 'frame',
action: 'fulfill'
});
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<!doctype html><body data-source="page-route">page-route</body>'
});
});
const popupPromise = page.waitForEvent('popup');
await page.locator('#popup').click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
Waiting for the popup event correctly acquires the new Page object, but that does not travel backward in time and intercept navigation that has already started. The expected assertion for the documented popup boundary is therefore: no opener-page-route receipt for the first popup request, the server ledger increments once for probe=popup, and the popup carries the harmless origin marker.
That case should fail a claim of complete popup mock coverage by design. Its value is diagnostic: it proves the harness can expose the scope gap without contacting any real service.
Repeat in a fresh context, but register the context route before clicking:
await page.context().route('**/probe?**', async route => {
const request = route.request();
handlers.push({
runId,
probeId: 'popup',
handler: 'context-popup-fulfill',
owner: request.serviceWorker() ? 'service-worker' : 'frame',
action: 'fulfill'
});
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<!doctype html><body data-source="context-route">context-popup</body>'
});
});
const popupPromise = page.waitForEvent('popup');
await page.locator('#popup').click();
const popup = await popupPromise;
await expect(popup.locator('body')).toHaveAttribute(
'data-source',
'context-route'
);
expect(handlers).toContainEqual(
expect.objectContaining({ handler: 'context-popup-fulfill' })
);
expect(oracle.evidence(runId)).toHaveLength(0);
The browser context API explains that browserContext.route() applies routing across pages in that context, which is precisely the scope needed for the popup’s first navigation. The accepted statement should remain narrow: the first HTTP navigation of this popup in this context matched the registered context route and did not reach this owned origin. It does not prove interception of WebSockets, Node-side requests, unrelated contexts, future protocols, or arbitrary browser egress.
The same discipline applies to registration timing. A correct handler registered after the network operation begins is still the wrong harness. Fix timing rather than weakening the expectation.
Separate service-worker responses from worker-owned network requests
Service workers change the accounting because “the page requested /probe” no longer tells you which object had the opportunity to contact the origin.
The worker-enabled project must use serviceWorkers: 'allow', and each case must start from a fresh browser context. Readiness should be protocol-driven. The Playwright Service Workers documentation notes that the context’s serviceworker event occurs before the worker has necessarily taken control and shows waiting for activation/controller change rather than relying on an arbitrary delay. The fixture’s readyWorker() follows that model: registration, navigator.serviceWorker.ready, then controllerchange if a controller is not already present.
That is important because a fixed waitForTimeout(1000) turns ownership into a race. A worker may or may not control the page by then, and an intermittent origin hit becomes easy to misclassify as “flakiness.”
For the synthetic mode, the worker’s fetch listener sees /probe and returns a locally constructed Response. It performs no fetch(event.request). The expected evidence is:
1. The logical page action is one fetch.
2. The page receives {"source":"sw-synthetic"}.
3. The page response can report response.fromServiceWorker() === true.
4. No intended Playwright route-handler receipt exists.
5. The origin ledger remains zero.
Playwright documents response.fromServiceWorker() as true for a page request handled by a service worker’s fetch handler. That result proves something useful, but not what a careless “mock coverage” report might claim. The response came from the worker, not from page.route() or the intended Playwright route double.
The verdict therefore depends on wording. “No origin request occurred because the service worker synthesized the response” may be accepted if independently observed. “The Playwright mock intercepted /probe” must be RESTRICT THE CLAIM or rejected because the specific handler receipt is absent.
The forwarding mode changes only a few lines in the worker:
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname === '/probe') {
event.respondWith(fetch(event.request));
}
});
Conceptually that small difference creates two ownership layers. Playwright’s Chromium-scoped worker documentation describes a transparent worker where one logical page fetch leads to both a frame-owned request observation and a service-worker-owned outgoing request observation. Crucially, it says only the worker-owned outgoing resource request is routable through browserContext.route() in that scenario.
This reconciles wording that can otherwise look contradictory. The general browserContext.route() documentation warns that requests intercepted by a service worker are not themselves intercepted by ordinary context routing and recommends blocking service workers for straightforward interception. The dedicated worker guide then documents a distinct Chromium case: the worker’s own outgoing network request is visible at context level and is routable there.
Those are different objects. The frame request that the service worker has intercepted is not the same thing as the network request the service worker subsequently creates with fetch().
Ownership-aware instrumentation should therefore look like this:
const browserEvents: Array<{
url: string;
owner: 'frame' | 'service-worker';
}> = [];
page.context().on('request', request => {
if (!new URL(request.url()).pathname.endsWith('/probe')) return;
const worker = request.serviceWorker();
browserEvents.push({
url: request.url(),
owner: worker ? 'service-worker' : 'frame'
});
// Never call request.frame() on worker-owned requests.
if (!worker) {
void request.frame();
}
});
Playwright explicitly warns that request.frame() and response.frame() throw when the request belongs to a service worker. Treat request.serviceWorker() as the ownership discriminator before touching frame-specific APIs.
Now add a context route that fulfills only worker-owned requests:
await page.context().route('**/probe?**', async route => {
const request = route.request();
const worker = request.serviceWorker();
if (!worker) {
await route.continue();
return;
}
const url = new URL(request.url());
handlers.push({
runId: url.searchParams.get('run') ?? '',
probeId: url.searchParams.get('probe') ?? '',
handler: 'worker-context-fulfill',
owner: 'service-worker',
action: 'fulfill'
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ source: 'mock-worker-outgoing' })
});
});
Under Playwright’s Chromium service-worker model, the expected evidence is not “one browser event.” There can be a frame-owned observation and a worker-owned observation linked to one logical action. The acceptance assertions are that the worker-owned observation exists, worker-context-fulfill receives it, the page consumes mock-worker-outgoing, and the server records zero /probe calls.
The pass-through control uses the same ownership model but changes the route action to continue(). In that case, the handler receipt is expected to exist and the server count is expected to become one. The page can still receive a perfectly valid response. That control is essential because it demonstrates why “my route handler executed” is not synonymous with “my mock isolated the origin.”
Do not infer two server requests merely because Playwright reports two request observations in the forwarding case. The dedicated guide explicitly presents two data.json observations, one frame-owned and one worker-owned, in its transparent-worker example. The origin server remains the authority on actual origin contact.
Finally, keep this claim browser-specific. Playwright states that service-worker inspection support is currently limited to Chromium-based browsers. The worker-ownership experiment therefore belongs to the frozen Chromium project and should not be promoted into a Firefox or WebKit guarantee by analogy.
Audit handler precedence and network-producing route actions
Ownership answers “whose request is this?” The next question is “which matching handler actually took responsibility for it?”
When page and context routes both match a frame request, Playwright documents that the page route takes precedence over the context route. An audit case should make that precedence observable rather than assuming it from registration order:
await page.context().route('**/probe?**', async route => {
handlers.push({
runId,
probeId: 'precedence',
handler: 'context-match',
owner: 'frame',
action: 'fulfill'
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ source: 'context' })
});
});
await page.route('**/probe?**', async route => {
handlers.push({
runId,
probeId: 'precedence',
handler: 'page-match',
owner: 'frame',
action: 'fulfill'
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ source: 'page' })
});
});
The expected direct-frame result is page-match only, source page, and origin delta zero. The point is not that context routing is defective; it simply did not own this match because Playwright specifies page-route precedence.
Within a single scope, handler chaining has a different rule. The Route API states that matching routes run in reverse registration order when chaining via fallback(). fallback() allows subsequent matching handlers to participate before the request is finally sent, while continue() immediately sends the request to the network and prevents later matching route handlers from running.
That deserves a receipt-level test:
await page.route('**/probe?**', async route => {
handlers.push({
runId,
probeId: 'chain',
handler: 'registered-first',
owner: 'frame',
action: 'fulfill'
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ source: 'first-registered-fulfill' })
});
});
await page.route('**/probe?**', async route => {
handlers.push({
runId,
probeId: 'chain',
handler: 'registered-last',
owner: 'frame',
action: 'fallback'
});
await route.fallback();
});
The expected order is registered-last followed by registered-first; the origin stays untouched because the first-registered handler ultimately fulfills. Multiple handler receipts are therefore not inherently evidence of multiple requests.
Replace the last handler’s fallback() with continue() and the semantics change materially:
await page.route('**/probe?**', async route => {
handlers.push({
runId,
probeId: 'continue',
handler: 'continue-handler',
owner: 'frame',
action: 'continue'
});
await route.continue();
});
Playwright defines route.continue() as sending the request to the network, and it does not continue through other matching route handlers. If /probe is the destination, the expected origin delta is one. A handler receipt plus a green page therefore cannot establish zero-origin isolation.
The strongest counterexample is route.fetch() followed by route.fulfill(). It often produces exactly the sort of visual result people call a mock:
await page.route('**/probe?**', async route => {
handlers.push({
runId,
probeId: 'fetch-patch',
handler: 'fetch-then-fulfill',
owner: 'frame',
action: 'fetch-fulfill'
});
const upstream = await route.fetch();
const original = await upstream.json();
await route.fulfill({
response: upstream,
contentType: 'application/json',
body: JSON.stringify({
...original,
source: 'patched-after-fetch'
})
});
});
The final browser-visible body says patched-after-fetch. Yet Playwright’s Route documentation is explicit that route.fetch() performs the request and obtains its result before the caller fulfills a modified response. The independent server should therefore record an origin delta of one.
Compare that with direct route.fulfill({ body: ... }), where the intended acceptance expectation is zero origin receipts. The pair is a high-value harness control: same general category of “mock-shaped” UI output, different network truth.
Every branch in production test code should end intentionally in fulfill(), abort(), continue(), or fallback(). Playwright notes that a matching routed request stalls until handled. An ambiguous route branch that simply returns from the JavaScript function without resolving the route is not a coverage outcome; it is a harness defect likely to become a timeout.
Reconcile the ownership, handler, and origin ledger
The acceptance ledger is where the experiment becomes reviewable. It should be generated from artifacts, not reconstructed after somebody sees a green result. Each row starts with one logical action and then records the worker mode, request owner, routing eligibility, handler that actually ran, response marker, independent origin delta, and decision.
Because the canonical locked execution could not be completed in the available environment, the table below contains expected assertions derived from the fixture design and Playwright’s documented semantics, not claimed measurements. Actual publication evidence must add measured values beside these expectations rather than silently converting “expected” into past tense.
Case | Configuration and ownership | Expected evidence | Acceptance interpretation |
Direct page fulfill | Worker mode: blocked | Handler receipt: page-direct-fulfill | ACCEPT MOCK COVERAGE if measured |
Unmocked positive control | Worker mode: blocked | Handler receipt: none | Oracle must detect this before zeroes are trusted |
Popup with opener page route only | Worker mode: blocked | Handler receipt: no intended popup handler | REPAIR THE HARNESS |
Popup with context route | Worker mode: blocked | Handler receipt: context-popup-fulfill | ACCEPT MOCK COVERAGE if measured |
Worker synthetic response | Worker mode: allowed | Handler receipt: none | RESTRICT THE CLAIM if called a Playwright mock |
Worker forward, pass through | Worker mode: allowed | Handler receipt: worker handler using continue | Not origin-isolated |
Worker forward, context fulfill | Worker mode: allowed | Handler receipt: worker-context-fulfill | ACCEPT MOCK COVERAGE if measured |
Page + context both match | Worker mode: blocked | Handler receipt: page handler | Accept only the page handler as owner |
Same-scope fallback chain | Worker mode: blocked | Handler receipt: last registered, then earlier fulfiller | Multiple receipts can still represent one request |
continue | Worker mode: blocked | Handler receipt: continue-handler | RESTRICT THE CLAIM; handler execution is not mocking |
fetch() + fulfill() | Worker mode: blocked | Handler receipt: fetch-then-fulfill | RESTRICT THE CLAIM; upstream was contacted |
The popup rows follow Playwright’s explicit first-navigation boundary for page.route(), while the precedence rows follow the documented rule that page routes outrank context routes. The worker rows follow the Chromium worker guide’s distinction between a frame-owned request intercepted by the worker and the worker-owned outgoing fetch that context routing can see. The action rows follow the Route API’s distinction among continue(), fallback(), and fetch().
The ledger should preserve identifiers instead of relying on counts alone. A practical JSON artifact might look like this:
{
"runId": "2dc1274d-...",
"logicalAction": "worker-forward-context-fulfill",
"environment": {
"project": "chromium-sw-allowed",
"serviceWorkers": "allow",
"fixtureCommit": "a-real-git-sha"
},
"browserRequests": [
{
"probeId": "worker-forward",
"owner": "frame"
},
{
"probeId": "worker-forward",
"owner": "service-worker"
}
],
"handlerReceipts": [
{
"handler": "worker-context-fulfill",
"probeId": "worker-forward",
"owner": "service-worker",
"action": "fulfill"
}
],
"originReceipts": [],
"response": {
"source": "mock-worker-outgoing",
"fromServiceWorker": true
}
}
That shape avoids the common accounting mistake of asserting:
expect(browserRequestEvents.length).toBe(handlerReceipts.length);
There is no general contract requiring that equality. The transparent service-worker example in Playwright’s own guide demonstrates why it can be false while everything is functioning correctly.
Instead, assert relationships. For the worker-forwarded fulfilled case: one logical action should be associated with a frame observation; a worker-owned outgoing observation should carry the same probe identity; exactly one intended worker route receipt should exist; and origin receipts for that probe should be zero. For the pass-through counterpart, the same ownership observations can occur while origin receipts become one.
The response marker is useful for linking application behavior to the route outcome, but it is deliberately not the isolation oracle. fetch() plus fulfill() demonstrates why: the marker can truthfully say “patched” while the server truthfully says “I was contacted.”
Likewise, fromServiceWorker() is ownership evidence, not proof that a Playwright handler did anything. In the synthetic-worker case it is entirely possible for a worker response to be the correct application result while the Playwright handler ledger remains empty.
A reviewer should therefore be able to answer four separate questions from one row: What browser action caused the traffic? Who owned each relevant request? Which handler, if any, supplied or forwarded it? Did the owned origin actually receive it? If any of those fields is inferred from UI text instead of recorded, the row is incomplete.
Repair the harness and make a release-review decision
Coverage repair should be surgical. When a case fails, preserve the first run’s browser observations, handler receipts, origin ledger, manifest, and fixture revision before changing anything. Then repair one cause at a time.
A popup origin hit with no intended handler receipt is a page-route scope problem: move the necessary route to the browser context and register it before the popup action. A worker-owned outgoing request that never reaches a page route is an ownership problem: use the documented context-level worker routing path in the Chromium experiment. A direct route whose receipt says continue but whose acceptance claim says “no origin” is an action problem: decide whether the route should fulfill instead. A synthetic worker response being credited to Playwright is a classification problem: change the claim, not the browser.
After each repair, destroy the context and create a fresh one. Do not keep a service-worker registration, route stack, cache state, popup, or storage partition alive merely because it makes the next run faster. The origin server may stay alive if its evidence is keyed by unique run IDs, but the new case must receive a new ID and a clean counter bucket.
Then rerun the origin-positive control. A harness that cannot still prove +1 for an unmocked request cannot credibly prove 0 for the repaired mock. That independent control is more important than preserving a previously green UI expectation.
Do not repair a coverage defect by restoring retries. Retries may change which worker process or test attempt becomes visible, but they do not transform a request that reached the origin into one that did not. The network-isolation gate should remain retries: 0.
Also set explicit coverage boundaries before calling the suite “isolated.” This fixture does not establish interception for Node-side HTTP clients, Playwright APIRequestContext calls made outside the browser path being audited, other browser contexts, WebSockets, arbitrary redirect destinations beyond the fixture, or browser processes outside the controlled context. It is not a third-party egress firewall.
Service-worker scope has another specific limit: Playwright’s worker documentation states that requests for updated main service-worker script code currently cannot be routed. That is intentionally outside this laboratory. Installation/update deployment correctness belongs in a different test family; here the worker exists only to expose request ownership and forwarding.
Likewise, routing changes HTTP-cache behavior by disabling the cache. Record that fact in the evidence manifest rather than describing the resulting test as a complete model of production networking. The neighboring subject of browser-protocol coverage and migration boundaries is separate work; this acceptance fixture stays inside Playwright’s public routing and request-ownership APIs.
Integration testing also has a broader responsibility than this local oracle. Once a team needs to establish compatibility, authentication behavior, rate limits, vendor contracts, or actual external-system semantics, that moves into integration-test responsibilities. The local fixture deliberately avoids claiming those outcomes.
For release review, assign four owners: the endpoint-contract owner states which method and path must be mocked; the test-double owner maintains the route and its receipt instrumentation; the browser-platform owner owns Playwright, Chromium, worker mode, and context settings; and the evidence owner maintains the independent origin oracle and publication artifact. Those may be the same people on a small team, but the responsibilities should remain distinguishable.
The decision vocabulary should be mechanical:
Decision | Required interpretation |
ACCEPT MOCK COVERAGE | Locked environment is complete; intended ownership is understood; intended handler receipt exists; route action matches the claim; response linkage is correct; origin delta is zero; and the origin-positive control still detects a real request. |
HOLD | A material observation is missing, the environment does not match the lock, the oracle is unproven, an unexpected origin receipt exists, worker ownership is unresolved, or canonical execution has not occurred. |
RESTRICT THE CLAIM | Evidence is valid but proves less than the original wording; for example, a worker synthesized the response without a Playwright handler, or route.fetch() patched a response after contacting the origin. |
REPAIR THE HARNESS | The cause is an identifiable test defect such as late registration, wrong route scope, wrong service-worker mode, incorrect owner assumption, or a network-producing route action inconsistent with the claim. |
A release record should never jump directly from “UI assertion passed” to ACCEPT MOCK COVERAGE. The minimum chain is endpoint identity → request ownership → intended handler receipt → handler action → origin ledger → oracle positive control.
Where the boundary has security significance, pair that decision with the broader discipline of API security and observable evidence. The localhost receipt ledger is useful evidence for this fixture; it is not itself an access-control mechanism or production security control.
For the demonstrations in this article, the present overall status remains HOLD. The reason is not an expected semantic discrepancy in Playwright; it is missing canonical execution evidence. The available execution environment could not run the requested frozen Node/Playwright/Playwright-managed-Chromium combination against localhost. No measured counts have therefore been promoted into the ledger. The acceptance path is straightforward: run the exact committed fixture with the manifest filled, verify Chromium revision and Node version before test start, execute each case once with retries disabled, persist receipts, and replace expected rows with observed rows.
Only then should individual cases receive ACCEPT, RESTRICT, REPAIR, or HOLD according to the table above.
Build QA skills that connect assertions to trustworthy evidence
The practical skill in network-mock review is not writing another route.fulfill() callback. It is learning to distinguish a plausible response from a proven intercepted request.
For a direct page request, that means showing the intended page.route() receipt and zero origin calls. For the first popup navigation, it means recognizing that the opener’s page route has the wrong scope and registering the context route before the popup begins. For service workers, it means separating the frame request from a worker-owned outgoing fetch, inspecting request.serviceWorker() before using frame APIs, and keeping Chromium-specific conclusions inside Chromium. For route actions, it means treating continue() and fetch() as network-producing behavior instead of assuming every route callback is a mock. Those distinctions align with Playwright’s documented page-routing model and the service-worker ownership model described earlier.
Engineers building those foundations may also find the Quality Assurance Automation Engineering / Quality Assurance Engineering program relevant as broader training context. Its published page lists a three-month format at 12 to 14 hours per week and includes automated test scripts/frameworks, continuous integration and continuous delivery integration, and applied testing projects. It names tools including Selenium, JUnit, and Jenkins. Those are provider-published curriculum facts, not evidence that this particular Playwright or Chromium service-worker laboratory is taught there.
The release standard should remain stricter than “the response looked mocked.” A trustworthy mock-coverage claim names the logical action, identifies the request owner, proves which handler ran, records what that handler actually did, and independently shows that the origin received nothing. Until that evidence exists in the pinned environment, the technically correct decision is HOLD, not optimism.
