cdpfleet Docs GitHub Dashboard

Cases / #48 · 2026-10-15 · Medium

Mock, patch and fail API responses with route.fulfill on remote browsers

Stub a backend that doesn't exist, change a real API response on its way in, simulate a 500, and drop images — all from your script with `page.route`, on Chromium, Firefox and WebKit running on the fleet.

Chromium Firefox WebKit

Run on production on 2026-10-15: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

Testing a front end against a real backend is slow and flaky: you want to give the page exactly the data you need, check how it handles an error, or tweak one field of a real response — without a mock server. With a remote browser the question is whether request interception still works when the browser runs somewhere else.

What we used, and why

WhatWhy
page.route(url, handler)Every matching request the page makes is handed to your script first — the browser is remote, the handler runs in your program.
route.fulfill({ json })Answer with your own data; the request never reaches the network. Here the whole site, shop.example.test, doesn't exist.
route.fetch() + route.fulfill({ response, json })Let the real request go out, change the JSON, and pass it on with the original status and headers.
route.fulfill({ status: 500 })Simulate a failing backend to test the page's error state.
route.abort()Drop requests you don't need, such as images, to save proxy traffic.

How it works

  1. On each browser, route the made-up site shop.example.test to an HTML page served from the script, and its /api/products to three stubbed products.
  2. Route httpbin.org/json through route.fetch() and rewrite the slideshow title; abort all image requests.
  3. Open the page and read what it rendered; open it again with the API answering 500.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium, firefox, webkit } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const TYPES = { chromium, firefox, webkit };
const SITE = 'https://shop.example.test'; // doesn't exist: every request to it is answered by the script

// A tiny shop front: loads products from its API, shows an error state, and a slideshow title
// from a real public API (httpbin.org/json).
const HTML = `<html><head><meta charset="utf-8"></head><body><h1>Shop</h1><ul id="list"></ul><p id="status"></p><p id="title"></p>
<img src="${SITE}/banner.png"><img src="https://httpbin.org/image/png">
<script>
  fetch('/api/products').then((r) => r.ok ? r.json() : Promise.reject(r.status))
    .then((items) => { for (const p of items) document.getElementById('list').insertAdjacentHTML('beforeend', '<li>' + p.name + ' — ' + p.price + '</li>'); document.getElementById('status').textContent = 'ok'; })
    .catch((code) => { document.getElementById('status').textContent = 'error ' + code; });
  fetch('https://httpbin.org/json').then((r) => r.json()).then((j) => { document.getElementById('title').textContent = j.slideshow.title; });
</script></body></html>`;

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(engine, body) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (res.ok) return res.json();
    if (res.status !== 503 || attempt === 5) throw new Error(`launch ${engine} ${res.status} ${await res.text()}`);
    await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
  }
}

// Open the shop with the API answering `api` ('stub' or 'error'); returns what the page shows.
async function visit(context, api) {
  const page = await context.newPage();
  let aborted = 0;
  // The page itself and its API never leave the browser (the page also declares its charset).
  await page.route(`${SITE}/`, (route) => route.fulfill({ contentType: 'text/html; charset=utf-8', body: HTML }));
  await page.route(`${SITE}/api/products`, (route) => (api === 'stub'
    ? route.fulfill({ json: [{ name: 'Red mug', price: '$9' }, { name: 'Blue mug', price: '$11' }, { name: 'Teapot', price: '$24' }] })
    : route.fulfill({ status: 500, body: 'boom' })));
  // A real response, changed on its way in.
  await page.route('https://httpbin.org/json', async (route) => {
    const response = await route.fetch();
    const json = await response.json();
    json.slideshow.title = `Patched: ${json.slideshow.title}`;
    await route.fulfill({ response, json });
  });
  // Images are dropped before they're requested.
  await page.route(/\.png$|\/image\//, (route) => { aborted++; return route.abort(); });
  await page.goto(`${SITE}/`);
  await page.waitForFunction(() => document.getElementById('status').textContent && document.getElementById('title').textContent, null, { timeout: 30000 });
  const out = await page.evaluate(() => ({
    items: [...document.querySelectorAll('#list li')].map((li) => li.textContent),
    status: document.getElementById('status').textContent,
    title: document.getElementById('title').textContent,
  }));
  await page.close();
  return { ...out, images_aborted: aborted };
}

// Does the charset in the fulfilled Content-Type alone decode non-ASCII text? (No <meta>.)
async function charsetHeaderOnly(context) {
  const page = await context.newPage();
  await page.route(`${SITE}/plain`, (route) => route.fulfill({ contentType: 'text/html; charset=utf-8', body: '<p>Red mug — $9</p>' }));
  await page.goto(`${SITE}/plain`);
  const text = await page.textContent('p');
  await page.close();
  return text;
}

async function one(engine) {
  const { wsUrl } = await launch(engine, { proxy: process.env.PROXY_URL, headless: true, record: false });
  const browser = await TYPES[engine].connect(wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const context = await browser.newContext();
    const stub = await visit(context, 'stub');
    const error = await visit(context, 'error');
    const headerOnly = await charsetHeaderOnly(context);
    return { engine, items: stub.items, status: stub.status, title: stub.title, images_aborted: stub.images_aborted, error_status: error.status, header_only_text: headerOnly };
  } finally {
    await browser.close();
  }
}

const results = [];
for (const engine of ['chromium', 'firefox', 'webkit']) results.push(await one(engine));
console.log(JSON.stringify({ results }, null, 2));

What we got

BrowserStubbed products shownPatched real responseStub answering 500Images abortedHeader charset only
chromiumRed mug — $9 · Blue mug — $11 · Teapot — $24Patched: Sample Slide Showerror 5002Red mug — $9
firefoxRed mug — $9 · Blue mug — $11 · Teapot — $24Patched: Sample Slide Showerror 5002Red mug — $9
webkitRed mug — $9 · Blue mug — $11 · Teapot — $24Patched: Sample Slide Showerror 5002Red mug — $9

From the Node.js run on 2026-10-15. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)
{
  "results": [
    {
      "engine": "chromium",
      "items": [
        "Red mug — $9",
        "Blue mug — $11",
        "Teapot — $24"
      ],
      "status": "ok",
      "title": "Patched: Sample Slide Show",
      "images_aborted": 2,
      "error_status": "error 500",
      "header_only_text": "Red mug — $9"
    },
    {
      "engine": "firefox",
      "items": [
        "Red mug — $9",
        "Blue mug — $11",
        "Teapot — $24"
      ],
      "status": "ok",
      "title": "Patched: Sample Slide Show",
      "images_aborted": 2,
      "error_status": "error 500",
      "header_only_text": "Red mug — $9"
    },
    {
      "engine": "webkit",
      "items": [
        "Red mug — $9",
        "Blue mug — $11",
        "Teapot — $24"
      ],
      "status": "ok",
      "title": "Patched: Sample Slide Show",
      "images_aborted": 2,
      "error_status": "error 500",
      "header_only_text": "Red mug — $9"
    }
  ]
}

Takeaways