microsoft / microsoft/playwright

[Bug]: WebKit offline emulation rejects service-worker navigation, including a literal response

Open
#42,775 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
96.3k
Forks
6.5k
Avg merge
1d 6h
Merged PRs (30d)
180

Description

### Version

1.63.0

### Steps to reproduce

After a service worker is active and controlling the page, `browserContext.setOffline(true)` causes a subsequent navigation to fail in WebKit. This also happens when the navigation handler returns a literal HTML `Response` without making a network request. The same standalone reproduction passes in Chromium in the same Linux container.

The complete synthetic reproduction is included below. It uses only Node.js, `@playwright/test`, and an ephemeral loopback HTTP server; no application or framework is required.

1. In a new directory containing a `package.json`, install `@playwright/test@1.63.0`. Ensure the matching WebKit and Chromium browsers and their Linux dependencies are installed. The measured runs used the official Playwright container listed under Environment.
2. Save the code below as `Minimal Offline Diagnostic -26-0918 -v1.cjs` in that directory.
3. Run both commands from that directory, supplying a **different, not-yet-existing absolute output directory** for every run. The parent directory must already exist.

```sh
node "Minimal Offline Diagnostic -26-0918 -v1.cjs" webkit "/tmp/webkit-offline-repro-unique"
node "Minimal Offline Diagnostic -26-0918 -v1.cjs" chromium "/tmp/chromium-offline-repro-unique"
```

The driver runs each combination once, without retries or skips. Each case gets a fresh browser context and loopback server. For service-worker cases, it registers the worker, waits for readiness, reloads, then verifies both a controller and the cached offline document before applying the disruption.

It compares `context.setOffline(true)` while the origin is still listening against stopping the origin server without setting the offline flag. The latter is a server-unavailability control, not a claim that both disruptions emulate identical network conditions.

For each service-worker case it then navigates to `/uncached-target` and requires status 200, `response.fromServiceWorker() === true`, and the heading `Synthetic offline fallback`. The no-worker cases are negative controls that must fail navigation.

Complete standalone reproduction

```javascript
// Standalone synthetic, loopback-only service-worker diagnostic.
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs/promises');
const path = require('node:path');
const { createRequire } = require('node:module');

const projectRequire = createRequire(path.join(process.cwd(), 'package.json'));
const playwright = projectRequire('@playwright/test');
const browserName = process.argv[2];
const outputDirectory = process.argv[3];
assert(['chromium', 'webkit'].includes(browserName), 'Specify chromium or webkit');
assert(outputDirectory && path.isAbsolute(outputDirectory), 'Specify a new absolute output directory');
const offlineHtml = '

Synthetic offline fallback

';
const modes = ['no-worker', 'literal', 'cache-only', 'network-fallback', 'preload-fallback'];

function workerSource(mode) {
const response = mode === 'literal'
? `Promise.resolve(new Response(${JSON.stringify(offlineHtml)}, {headers:{'Content-Type':'text/html'}}))`
: mode === 'cache-only'
? "caches.match('/offline')"
: mode === 'network-fallback'
? "fetch(event.request).catch(() => caches.match('/offline'))"
: "(async () => { try { return await event.preloadResponse || await fetch(event.request); } catch { return caches.match('/offline'); } })()";
return `
self.addEventListener('install', event => event.waitUntil((async () => {
const cache = await caches.open('synthetic-offline-v1');
await cache.add('/offline'); await self.skipWaiting();
})()));
self.addEventListener('activate', event => event.waitUntil((async () => {
${mode === 'preload-fallback' ? 'if (self.registration.navigationPreload) await self.registration.navigationPreload.enable();' : ''}
await self.clients.claim();
})()));
self.addEventListener('fetch', event => {
if (event.request.mode !== 'navigate') return;
event.respondWith(${response});
});`;
}

async function caseRun(browser, mode, outage) {
const requests = [];
const server = http.createServer((request, response) => {
requests.push(request.url);
response.setHeader('Cache-Control', 'no-store');
if (request.url === '/sw.js') {
response.writeHead(200, { 'Content-Type': 'application/javascript' });
response.end(workerSource(mode));
} else {
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end(request.url === '/offline' ? offlineHtml : '

Synthetic origin online

');
}
});
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
const origin = `http://127.0.0.1:${server.address().port}`;
const stopOrigin = () => new Promise((resolve, reject) => {
if (!server.listening) return resolve();
server.close(error => error ? reject(error) : resolve());
server.closeAllConnections();
});
const context = await browser.newContext({ serviceWorkers: 'allow' });
context.setDefaultTimeout(10_000);
const page = await context.newPage();
const events = [];
page.on('requestfailed', request => events.push({ type: 'requestfailed', url: request.url(), error: request.failure()?.errorText }));
page.on('console', message => { if (message.type() === 'error') events.push({ type: 'console', text: message.text() }); });
const result = { mode, outage, origin, expected: mode === 'no-worker' ? 'network-failure' : 'offline-fallback' };
try {
await page.goto(origin);
if (mode !== 'no-worker') {
await page.evaluate(async () => { await navigator.serviceWorker.register('/sw.js'); await navigator.serviceWorker.ready; });
await page.reload();
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
result.preconditions = await page.evaluate(async () => ({
controlled: Boolean(navigator.serviceWorker.controller),
offlineCached: Boolean(await caches.match('/offline')),
preloadSupported: Boolean((await navigator.serviceWorker.ready).navigationPreload),
}));
assert(result.preconditions.controlled && result.preconditions.offlineCached);
}
if (outage === 'setOffline') await context.setOffline(true);
else { await stopOrigin(); assert.equal(server.listening, false); }
result.originListeningBeforeNavigation = server.listening;
const requestsBefore = requests.length;
try {
const response = await page.goto(`${origin}/uncached-target`, { timeout: 10_000 });
result.navigation = { status: response?.status(), fromServiceWorker: response?.fromServiceWorker(), error: null };
result.heading = await page.locator('h1').textContent();
} catch (error) {
result.navigation = { error: error.message };
}
result.originRequestsDuringOutage = requests.slice(requestsBefore);
result.passed = mode === 'no-worker'
? Boolean(result.navigation.error)
: !result.navigation.error && result.navigation.status === 200 && result.navigation.fromServiceWorker === true && result.heading === 'Synthetic offline fallback';
} catch (error) {
result.setupError = error.message;
result.passed = false;
} finally {
result.events = events;
result.requests = requests;
await context.close();
await stopOrigin();
}
return result;
}

async function main() {
await fs.mkdir(outputDirectory); // Exclusive new leaf; never replace retained results.
const browser = await playwright[browserName].launch({ headless: true });
const summary = {
createdAt: new Date().toISOString(), node: process.version,
platform: process.platform, playwright: projectRequire('@playwright/test/package.json').version,
browser: browserName, browserVersion: browser.version(),
scope: 'Minimal synthetic service-worker diagnostic without an application framework',
results: [],
};
try {
for (const outage of ['setOffline', 'origin-stopped']) {
for (const mode of modes) {
const result = await caseRun(browser, mode, outage);
summary.results.push(result);
console.log(JSON.stringify({ browser: browserName, mode, outage, passed: result.passed, error: result.navigation?.error ?? result.setupError ?? null }));
}
}
} finally {
await browser.close();
summary.passed = summary.results.length === modes.length * 2 && summary.results.every(result => result.passed);
await fs.writeFile(path.join(outputDirectory, 'Diagnostic Results -26-0918 -v1.json'), JSON.stringify(summary, null, 2), { flag: 'wx' });
}
process.exitCode = summary.passed ? 0 : 1;
}

main().catch(error => { console.error(error.message); process.exitCode = 1; });
```

### Expected behavior

Offline emulation should allow an active service worker to fulfill a navigation without a network request. In particular, the `literal` and `cache-only` navigation handlers should return their local responses. A failed network request in the fallback variants should allow the worker's cached response to be used.

### Actual behavior

In WebKit, all four service-worker variants fail after `context.setOffline(true)` with:

```text
page.goto: WebKit encountered an internal error
```

All four WebKit variants succeed when the origin server is stopped instead. Chromium succeeds in all eight service-worker cases in the same container. Both browsers correctly fail the two no-worker negative controls.

| Worker response | WebKit: `setOffline(true)` | WebKit: origin stopped | Chromium: `setOffline(true)` | Chromium: origin stopped |
| --- | --- | --- | --- | --- |
| no-worker | Expected network failure | Expected network failure | Expected network failure | Expected network failure |
| literal | Internal error | 200, from service worker | 200, from service worker | 200, from service worker |
| cache-only | Internal error | 200, from service worker | 200, from service worker | 200, from service worker |
| network-fallback | Internal error | 200, from service worker | 200, from service worker | 200, from service worker |
| preload-fallback | Internal error | 200, from service worker | 200, from service worker | 200, from service worker |

Measured totals: WebKit **6/10 expectations passed, 4 failed** (process exit 1); Chromium **10/10 expectations passed** (process exit 0). These totals include the expected failures of the no-worker controls. In all service-worker cases, the pre-navigation checks reported `controlled: true` and `offlineCached: true`.

### Additional context

The `literal` variant creates a new HTML `Response` directly in the fetch handler. The `cache-only` variant uses only `caches.match('/offline')` during navigation. This makes the failure reproducible without a framework or a network-first caching strategy.

The reproduction writes a JSON result file into the requested output directory, including preconditions, navigation results, request failures, and origin requests. The publication copy changes only the original diagnostic's introductory comment and scope label; its executed browser logic is unchanged.

While inspecting the pinned Playwright WebKit patch, I found an early offline failure in the resource-loading path: [bootstrap.diff at the v1.63.0 source commit](https://github.com/microsoft/playwright/blob/1b025d7e20a026371cd5f98ba0cdce48892737c8/browser_patches/webkit/patches/bootstrap.diff#L18238-L18249). This may be relevant, but the reproduction does not establish the precise internal cause.

Is this an unsupported WebKit offline-emulation case, or should the offline flag permit service-worker fulfillment and fail only requests that actually require the network?

### Environment

```shell
Manually recorded from the measured runs (not an envinfo dump):
Playwright / @playwright/test: 1.63.0
Official container: mcr.microsoft.com/playwright:v1.63.0-noble
Container image digest: sha256:eff16c30e6f3f4af0a03fa4b706120d5e9b0891c344a27d64559aff5900a4a27
OS inside container: Ubuntu 24.04.4
Node.js inside container: 24.20.0
WebKit: 26.6, Playwright browser revision 2359
Chromium comparison: 153.0.8010.12, Playwright browser revision 1243
Headless browsers, non-root user, fresh contexts, loopback origins
```

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Run the standalone Minimal Offline Diagnostic CJS reproduction with WebKit and Chromium to confirm the differing results. Then inspect the referenced browser_patches/webkit/patches/bootstrap.diff offline resource-loading path. Done means WebKit permits active service workers to fulfill offline navigations while preserving the expected no-worker failures.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, node.js
Domain
testing-qa, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.