Local mappings and WebRequest
Serve static fixtures with Firefox Local Mode and test fulfilled responses through WebExtension WebRequest.
Use both response paths
localMappings and Playwright routing solve different test needs:
localMappingsserves static files from a directory on an HTTPS*.localhostorigin through Firefox DevTools Local Mode.page.route()plusroute.fulfill()creates a dynamic response directly in the browser test.
Both paths work without an HTTP server. The harness installs the Firefox compatibility patch during global setup; localMappings requires Firefox 153 or newer. See Fixtures and evaluation for mapping lifecycle and configuration details.
import { test as baseTest, expect } from '@playwright/test'
import { createFirefoxFixtures } from '@openpolyfill/firefox-addon-playwright-harness/fixtures'
const origin = 'https://web-request.localhost'
const test = createFirefoxFixtures(baseTest, {
extensionPaths: ['./tests/fixtures/web-request-local-mode-extension'],
headless: true,
localMappings: {
'web-request.localhost': './tests/fixtures/local-web-request-mode'
}
})
test('observe a fulfilled response', async ({ page }) => {
await page.route(`${origin}/status`, (route) =>
route.fulfill({
status: 207,
headers: { 'x-harness-response': 'original' },
body: Buffer.from([0x52, 0x53, 0x50])
})
)
await page.goto(`${origin}/`)
const result = await page.evaluate(async () => {
const response = await fetch('/status')
return {
status: response.status,
marker: response.headers.get('x-harness-response'),
blocking: response.headers.get('x-webrequest-blocking'),
bytes: Array.from(new Uint8Array(await response.arrayBuffer()))
}
})
expect(result).toEqual({
status: 207,
marker: 'modified-by-webRequest',
blocking: 'changed',
bytes: [0x52, 0x53, 0x50]
})
const mappedBytes = await page.evaluate(async () =>
Array.from(new Uint8Array(await (await fetch('/payload.bin')).arrayBuffer()))
)
expect(mappedBytes).toEqual([0x5a, 0x42, 0x43])
})Register page.route() before the request. A routed URL still needs to match the add-on’s URL filters and host permissions for the WebRequest listener to observe it.
WebRequest sees fulfilled responses
Firefox normally dispatches WebRequest phases from its channel observers. A Playwright route.fulfill() response is synthesized by Juggler, so the harness bridges that fulfilled response through Firefox’s WebRequest manager. The bridge uses the manager that owns the add-on listeners and preserves Firefox’s URL, host-permission, tab, window, container, and proxy-auth matching.
For a successful fulfilled request, assert events as one request-ID-correlated sequence:
onBeforeRequestonBeforeSendHeadersonSendHeadersonHeadersReceivedonResponseStartedonCompleted
The status and response headers in the response-phase events describe the fulfilled response. If a blocking onHeadersReceived listener returns replacement headers, those headers are applied to the response that fetch() receives downstream.
browser.webRequest.onHeadersReceived.addListener(
(details) => ({
responseHeaders: (details.responseHeaders ?? []).map((header) =>
header.name.toLowerCase() === 'x-harness-response'
? { ...header, value: 'modified-by-webRequest' }
: header
)
}),
{ urls: ['https://web-request.localhost/status'] },
['blocking', 'responseHeaders']
)Check the downstream header to confirm that the blocking listener’s change was applied:
const value = await page.evaluate(async () => {
const response = await fetch('/status')
return response.headers.get('x-harness-response')
})
expect(value).toBe('modified-by-webRequest')Transform response bytes
Firefox’s webRequest.filterResponseData() can inspect or transform the response stream. Write the modified Uint8Array back to the filter and close it when the stream stops:
browser.webRequest.onBeforeRequest.addListener(
(details) => {
if (details.url !== 'https://web-request.localhost/payload.bin') return {}
const filter = browser.webRequest.filterResponseData(details.requestId)
filter.ondata = ({ data }) => {
const bytes = new Uint8Array(data)
if (bytes.length) bytes[0] = 0x5a
filter.write(bytes)
}
filter.onstop = () => filter.close()
return {}
},
{ urls: ['https://web-request.localhost/*'] },
['blocking']
)Compare the exact bytes received by downstream JavaScript, not just decoded text. The integration fixture also uses a static payload.bin from localMappings to verify the ordinary Firefox response-filter path.
Fulfillment inputs and conditional events
route.fulfill() accepts equivalent ways to provide response data:
bodyfor explicit bytes or text;jsonfor a serialized JSON body;contentTypeto set the content type for a body;pathto load a body from a local file;responseto reuse anAPIResponse’s status, headers, and body;statusandheadersto set the HTTP-visible response metadata.
The harness regression test exercises these input forms and compares the add-on’s response-phase events with the status, headers, and bytes observed downstream.
Redirect, authentication, and error events are conditional, not part of every successful response:
- A fulfilled 3xx with
Locationcan produceonBeforeRedirect; the target request has its own lifecycle. - A 401 response with
WWW-AuthenticateproducesonAuthRequired. A 407 is treated as proxy authentication only when Firefox reports an actual proxy; the event’s challenger comes from that proxy. - An aborted request produces
onErrorOccurredinstead of a successful response completion.
Test these cases separately and correlate by requestId. Ordinary successful 2xx responses should not produce onAuthRequired, onBeforeRedirect, or onErrorOccurred.
Permissions and troubleshooting
The extension needs webRequest, the applicable host permission (for example https://app.localhost/*), and webRequestBlocking for blocking listeners. Firefox’s normal WebRequest matching and permission checks still apply; the harness does not grant extra host access.
If an event is missing, check that:
- Playwright global setup applied the required Firefox patch;
- the extension registered the listener before the request;
- the URL filter and host permission match the full HTTPS origin;
- the assertion groups events by
requestId, so a redirect or retry is not combined with another request.
localMappings serves files only; use route.fulfill() for per-request dynamic statuses, headers, and bodies. Neither requires a manually started HTTP server.
Run the regression test
From the repository root, run:
npx playwright test tests/integration/web-request-local-mode.spec.ts --workers=1This case uses localMappings for static files and route.fulfill() for response cases; it does not start an HTTP server.