Esc

Start typing to search the docs

Local mappings and WebRequest

Serve static fixtures with Firefox Local Mode and test fulfilled responses through WebExtension WebRequest.

4 min read · 609 words

View as Markdown

Use both response paths

localMappings and Playwright routing solve different test needs:

  • localMappings serves static files from a directory on an HTTPS *.localhost origin through Firefox DevTools Local Mode.
  • page.route() plus route.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:

  1. onBeforeRequest
  2. onBeforeSendHeaders
  3. onSendHeaders
  4. onHeadersReceived
  5. onResponseStarted
  6. onCompleted

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:

  • body for explicit bytes or text;
  • json for a serialized JSON body;
  • contentType to set the content type for a body;
  • path to load a body from a local file;
  • response to reuse an APIResponse’s status, headers, and body;
  • status and headers to 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 Location can produce onBeforeRedirect; the target request has its own lifecycle.
  • A 401 response with WWW-Authenticate produces onAuthRequired. 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 onErrorOccurred instead 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=1

This case uses localMappings for static files and route.fulfill() for response cases; it does not start an HTTP server.