[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #48 · 2026-10-15 · Example · Medium · for [testing](https://cdpfleet.com/cross-browser-testing)

# 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](https://cdpfleet.com/docs/browsers/chromium) [Firefox](https://cdpfleet.com/docs/browsers/firefox) [WebKit](https://cdpfleet.com/docs/browsers/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

| What | Why |
| --- | --- |
| `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](https://github.com/cdpfleet/cdpfleet/tree/main/cases/48-mock-api-responses), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)
-   `PROXY_URL` — your proxy, e.g. `http://user:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// 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));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import re
import time

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
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).
HTML = f"""<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.
def launch(engine, body):
    for attempt in range(1, 6):
        r = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json=body, timeout=120)
        if r.ok:
            return r.json()
        if r.status_code != 503 or attempt == 5:
            raise RuntimeError(f"launch {engine} {r.status_code} {r.text}")
        time.sleep(int(r.headers.get("retry-after") or 5))

# Open the shop with the API answering `api` ('stub' or 'error'); returns what the page shows.
def visit(context, api):
    page = context.new_page()
    aborted = 0

    def patch(route):
        response = route.fetch()
        data = response.json()
        data["slideshow"]["title"] = "Patched: " + data["slideshow"]["title"]
        route.fulfill(response=response, json=data)

    def drop(route):
        nonlocal aborted
        aborted += 1
        route.abort()

    # The page itself and its API never leave the browser (the page also declares its charset).
    page.route(f"{SITE}/", lambda route: route.fulfill(content_type="text/html; charset=utf-8", body=HTML))
    if api == "stub":
        page.route(f"{SITE}/api/products", lambda route: route.fulfill(json=[{"name": "Red mug", "price": "$9"}, {"name": "Blue mug", "price": "$11"}, {"name": "Teapot", "price": "$24"}]))
    else:
        page.route(f"{SITE}/api/products", lambda route: route.fulfill(status=500, body="boom"))
    # A real response, changed on its way in.
    page.route("https://httpbin.org/json", patch)
    # Images are dropped before they're requested.
    page.route(re.compile(r"\.png$|/image/"), drop)
    page.goto(f"{SITE}/")
    page.wait_for_function("() => document.getElementById('status').textContent && document.getElementById('title').textContent", timeout=30000)
    out = page.evaluate("""() => ({
        items: [...document.querySelectorAll('#list li')].map((li) => li.textContent),
        status: document.getElementById('status').textContent,
        title: document.getElementById('title').textContent,
    })""")
    page.close()
    return {**out, "images_aborted": aborted}

# Does the charset in the fulfilled Content-Type alone decode non-ASCII text? (No <meta>.)
def charset_header_only(context):
    page = context.new_page()
    page.route(f"{SITE}/plain", lambda route: route.fulfill(content_type="text/html; charset=utf-8", body="<p>Red mug — $9</p>"))
    page.goto(f"{SITE}/plain")
    text = page.text_content("p")
    page.close()
    return text

def one(pw, engine):
    session = launch(engine, {"proxy": os.environ["PROXY_URL"], "headless": True, "record": False})
    browser = getattr(pw, engine).connect(session["wsUrl"], headers={"x-api-key": KEY})
    try:
        context = browser.new_context()
        stub = visit(context, "stub")
        error = visit(context, "error")
        header_only = charset_header_only(context)
        return {"engine": engine, "items": stub["items"], "status": stub["status"], "title": stub["title"],
                "images_aborted": stub["images_aborted"], "error_status": error["status"], "header_only_text": header_only}
    finally:
        browser.close()

with sync_playwright() as pw:
    results = [one(pw, engine) for engine in ["chromium", "firefox", "webkit"]]
print(json.dumps({"results": results}, indent=2, ensure_ascii=False))
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
  static final String 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).
  static final String 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.
  static JsonObject launch(String engine, JsonObject body) throws Exception {
    for (int attempt = 1; ; attempt++) {
      HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
          .header("x-api-key", KEY).header("content-type", "application/json")
          .POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
      if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
      if (res.statusCode() != 503 || attempt == 5) throw new RuntimeException("launch " + engine + " " + res.statusCode() + " " + res.body());
      Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
    }
  }

  // Open the shop with the API answering `api` ("stub" or "error"); returns what the page shows.
  static JsonObject visit(BrowserContext context, String api) {
    Page page = context.newPage();
    AtomicInteger aborted = new AtomicInteger();
    // The page itself and its API never leave the browser (the page also declares its charset).
    page.route(SITE + "/", route -> route.fulfill(new Route.FulfillOptions().setContentType("text/html; charset=utf-8").setBody(HTML)));
    page.route(SITE + "/api/products", route -> {
      if (api.equals("stub")) {
        route.fulfill(new Route.FulfillOptions().setContentType("application/json")
            .setBody("[{\"name\":\"Red mug\",\"price\":\"$9\"},{\"name\":\"Blue mug\",\"price\":\"$11\"},{\"name\":\"Teapot\",\"price\":\"$24\"}]"));
      } else {
        route.fulfill(new Route.FulfillOptions().setStatus(500).setBody("boom"));
      }
    });
    // A real response, changed on its way in.
    page.route("https://httpbin.org/json", route -> {
      APIResponse response = route.fetch();
      JsonObject json = JsonParser.parseString(response.text()).getAsJsonObject();
      JsonObject show = json.getAsJsonObject("slideshow");
      show.addProperty("title", "Patched: " + show.get("title").getAsString());
      route.fulfill(new Route.FulfillOptions().setResponse(response).setBody(json.toString()));
    });
    // Images are dropped before they're requested.
    page.route(Pattern.compile("\\.png$|/image/"), route -> { aborted.incrementAndGet(); route.abort(); });
    page.navigate(SITE + "/");
    page.waitForFunction("() => document.getElementById('status').textContent && document.getElementById('title').textContent", null, new Page.WaitForFunctionOptions().setTimeout(30000));
    JsonObject out = GSON.toJsonTree(page.evaluate("() => ({ items: [...document.querySelectorAll('#list li')].map((li) => li.textContent), status: document.getElementById('status').textContent, title: document.getElementById('title').textContent })")).getAsJsonObject();
    page.close();
    out.addProperty("images_aborted", aborted.get());
    return out;
  }

  // Does the charset in the fulfilled Content-Type alone decode non-ASCII text? (No <meta>.)
  static String charsetHeaderOnly(BrowserContext context) {
    Page page = context.newPage();
    page.route(SITE + "/plain", route -> route.fulfill(new Route.FulfillOptions().setContentType("text/html; charset=utf-8").setBody("<p>Red mug — $9</p>")));
    page.navigate(SITE + "/plain");
    String text = page.textContent("p");
    page.close();
    return text;
  }

  static JsonObject one(Playwright pw, String engine) throws Exception {
    JsonObject body = new JsonObject();
    body.addProperty("proxy", System.getenv("PROXY_URL"));
    body.addProperty("headless", true);
    body.addProperty("record", false);
    JsonObject session = launch(engine, body);
    BrowserType type = switch (engine) { case "firefox" -> pw.firefox(); case "webkit" -> pw.webkit(); default -> pw.chromium(); };
    Browser browser = type.connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
    try {
      BrowserContext context = browser.newContext();
      JsonObject stub = visit(context, "stub");
      JsonObject error = visit(context, "error");
      String headerOnly = charsetHeaderOnly(context);
      JsonObject out = new JsonObject();
      out.addProperty("engine", engine);
      out.add("items", stub.get("items"));
      out.add("status", stub.get("status"));
      out.add("title", stub.get("title"));
      out.add("images_aborted", stub.get("images_aborted"));
      out.add("error_status", error.get("status"));
      out.addProperty("header_only_text", headerOnly);
      return out;
    } finally {
      browser.close();
    }
  }

  public static void main(String[] args) throws Exception {
    JsonArray results = new JsonArray();
    try (Playwright pw = Playwright.create()) {
      for (String engine : new String[] {"chromium", "firefox", "webkit"}) results.add(one(pw, engine));
    }
    JsonObject out = new JsonObject();
    out.add("results", results);
    System.out.println(GSON.toJson(out));
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
const string 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).
var 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>";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<JsonElement> Launch(string engine, JsonObject body)
{
    for (var attempt = 1; ; attempt++)
    {
        var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session",
            new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
        if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
        if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {engine} {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
        await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
    }
}

// Open the shop with the API answering `api` ("stub" or "error"); returns what the page shows.
async Task<JsonObject> Visit(IBrowserContext context, string api)
{
    var page = await context.NewPageAsync();
    var aborted = 0;
    // The page itself and its API never leave the browser (the page also declares its charset).
    await page.RouteAsync($"{Site}/", route => route.FulfillAsync(new() { ContentType = "text/html; charset=utf-8", Body = html }));
    await page.RouteAsync($"{Site}/api/products", route => api == "stub"
        ? route.FulfillAsync(new() { Json = new[] { new { name = "Red mug", price = "$9" }, new { name = "Blue mug", price = "$11" }, new { name = "Teapot", price = "$24" } } })
        : route.FulfillAsync(new() { Status = 500, Body = "boom" }));
    // A real response, changed on its way in.
    await page.RouteAsync("https://httpbin.org/json", async route =>
    {
        var response = await route.FetchAsync();
        var json = JsonNode.Parse(await response.TextAsync())!;
        json["slideshow"]!["title"] = "Patched: " + json["slideshow"]!["title"]!.GetValue<string>();
        await route.FulfillAsync(new() { Response = response, Body = json.ToJsonString() });
    });
    // Images are dropped before they're requested.
    await page.RouteAsync(new Regex(@"\.png$|/image/"), route => { aborted++; return route.AbortAsync(); });
    await page.GotoAsync($"{Site}/");
    await page.WaitForFunctionAsync("() => document.getElementById('status').textContent && document.getElementById('title').textContent", null, new() { Timeout = 30000 });
    var shown = await page.EvaluateAsync<JsonElement>("() => ({ items: [...document.querySelectorAll('#list li')].map((li) => li.textContent), status: document.getElementById('status').textContent, title: document.getElementById('title').textContent })");
    await page.CloseAsync();
    var outp = JsonNode.Parse(shown.GetRawText())!.AsObject();
    outp["images_aborted"] = aborted;
    return outp;
}

// Does the charset in the fulfilled Content-Type alone decode non-ASCII text? (No <meta>.)
async Task<string> CharsetHeaderOnly(IBrowserContext context)
{
    var page = await context.NewPageAsync();
    await page.RouteAsync($"{Site}/plain", route => route.FulfillAsync(new() { ContentType = "text/html; charset=utf-8", Body = "<p>Red mug — $9</p>" }));
    await page.GotoAsync($"{Site}/plain");
    var text = await page.TextContentAsync("p") ?? "";
    await page.CloseAsync();
    return text;
}

async Task<JsonObject> One(string engine)
{
    var session = await Launch(engine, new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"), ["headless"] = true, ["record"] = false });
    var type = engine switch { "firefox" => playwright.Firefox, "webkit" => playwright.Webkit, _ => playwright.Chromium };
    var browser = await type.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
    try
    {
        var context = await browser.NewContextAsync();
        var stub = await Visit(context, "stub");
        var error = await Visit(context, "error");
        var headerOnly = await CharsetHeaderOnly(context);
        return new JsonObject
        {
            ["engine"] = engine, ["items"] = stub["items"]!.DeepClone(), ["status"] = stub["status"]!.DeepClone(), ["title"] = stub["title"]!.DeepClone(),
            ["images_aborted"] = stub["images_aborted"]!.DeepClone(), ["error_status"] = error["status"]!.DeepClone(), ["header_only_text"] = headerOnly,
        };
    }
    finally
    {
        await browser.CloseAsync();
    }
}

var results = new JsonArray();
foreach (var engine in new[] { "chromium", "firefox", "webkit" }) results.Add(await One(engine));
Console.WriteLine(new JsonObject { ["results"] = results }.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
```

```go
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/playwright-community/playwright-go"
)

var key = os.Getenv("CDPFLEET_API_KEY")

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).
var 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.
func launch(engine string, opts map[string]any) (map[string]any, error) {
	body, _ := json.Marshal(opts)
	for attempt := 1; ; attempt++ {
		req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(body))
		req.Header.Set("x-api-key", key)
		req.Header.Set("content-type", "application/json")
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			return nil, err
		}
		b, _ := io.ReadAll(res.Body)
		res.Body.Close()
		if res.StatusCode == 200 {
			var session map[string]any
			return session, json.Unmarshal(b, &session)
		}
		if res.StatusCode != 503 || attempt == 5 {
			return nil, fmt.Errorf("launch %s %d %s", engine, res.StatusCode, strings.TrimSpace(string(b)))
		}
		wait, err := strconv.Atoi(res.Header.Get("retry-after"))
		if err != nil {
			wait = 5
		}
		time.Sleep(time.Duration(wait) * time.Second)
	}
}

type shown struct {
	Items  []string `json:"items"`
	Status string   `json:"status"`
	Title  string   `json:"title"`
}

// Open the shop with the API answering api ("stub" or "error"); returns what the page shows.
func visit(context playwright.BrowserContext, api string) (shown, int, error) {
	var out shown
	page, err := context.NewPage()
	if err != nil {
		return out, 0, err
	}
	defer page.Close()
	aborted := 0
	// The page itself and its API never leave the browser (the page also declares its charset).
	page.Route(site+"/", func(route playwright.Route) {
		route.Fulfill(playwright.RouteFulfillOptions{ContentType: playwright.String("text/html; charset=utf-8"), Body: html})
	})
	page.Route(site+"/api/products", func(route playwright.Route) {
		if api == "stub" {
			// playwright-go has no JSON option: marshal the body and set the content type.
			products, _ := json.Marshal([]map[string]string{{"name": "Red mug", "price": "$9"}, {"name": "Blue mug", "price": "$11"}, {"name": "Teapot", "price": "$24"}})
			route.Fulfill(playwright.RouteFulfillOptions{ContentType: playwright.String("application/json"), Body: products})
		} else {
			route.Fulfill(playwright.RouteFulfillOptions{Status: playwright.Int(500), Body: "boom"})
		}
	})
	// A real response, changed on its way in.
	page.Route("https://httpbin.org/json", func(route playwright.Route) {
		response, err := route.Fetch()
		if err != nil {
			route.Abort()
			return
		}
		var data map[string]any
		body, _ := response.Body()
		json.Unmarshal(body, &data)
		show := data["slideshow"].(map[string]any)
		show["title"] = "Patched: " + show["title"].(string)
		patched, _ := json.Marshal(data)
		route.Fulfill(playwright.RouteFulfillOptions{Response: response, Body: patched})
	})
	// Images are dropped before they're requested.
	page.Route(regexp.MustCompile(`\.png$|/image/`), func(route playwright.Route) {
		aborted++
		route.Abort()
	})
	if _, err = page.Goto(site + "/"); err != nil {
		return out, 0, err
	}
	if _, err = page.WaitForFunction("() => document.getElementById('status').textContent && document.getElementById('title').textContent", nil, playwright.PageWaitForFunctionOptions{Timeout: playwright.Float(30000)}); err != nil {
		return out, 0, err
	}
	raw, err := page.Evaluate("() => ({ items: [...document.querySelectorAll('#list li')].map((li) => li.textContent), status: document.getElementById('status').textContent, title: document.getElementById('title').textContent })")
	if err != nil {
		return out, 0, err
	}
	b, _ := json.Marshal(raw)
	return out, aborted, json.Unmarshal(b, &out)
}

// Does the charset in the fulfilled Content-Type alone decode non-ASCII text? (No <meta>.)
func charsetHeaderOnly(context playwright.BrowserContext) (string, error) {
	page, err := context.NewPage()
	if err != nil {
		return "", err
	}
	defer page.Close()
	page.Route(site+"/plain", func(route playwright.Route) {
		route.Fulfill(playwright.RouteFulfillOptions{ContentType: playwright.String("text/html; charset=utf-8"), Body: "<p>Red mug — $9</p>"})
	})
	if _, err = page.Goto(site + "/plain"); err != nil {
		return "", err
	}
	return page.TextContent("p")
}

// Field order matches the other languages' output.
type result struct {
	Engine         string   `json:"engine"`
	Items          []string `json:"items"`
	Status         string   `json:"status"`
	Title          string   `json:"title"`
	ImagesAborted  int      `json:"images_aborted"`
	ErrorStatus    string   `json:"error_status"`
	HeaderOnlyText string   `json:"header_only_text"`
}

func one(pw *playwright.Playwright, engine string) (result, error) {
	out := result{Engine: engine}
	session, err := launch(engine, map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true, "record": false})
	if err != nil {
		return out, err
	}
	browserType := pw.Chromium
	switch engine {
	case "firefox":
		browserType = pw.Firefox
	case "webkit":
		browserType = pw.WebKit
	}
	browser, err := browserType.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		return out, err
	}
	defer browser.Close()
	context, err := browser.NewContext()
	if err != nil {
		return out, err
	}
	stub, aborted, err := visit(context, "stub")
	if err != nil {
		return out, err
	}
	failed, _, err := visit(context, "error")
	if err != nil {
		return out, err
	}
	headerOnly, err := charsetHeaderOnly(context)
	if err != nil {
		return out, err
	}
	out.Items, out.Status, out.Title, out.ImagesAborted, out.ErrorStatus, out.HeaderOnlyText = stub.Items, stub.Status, stub.Title, aborted, failed.Status, headerOnly
	return out, nil
}

func run() error {
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		return err
	}
	defer pw.Stop()
	results := []result{}
	for _, engine := range []string{"chromium", "firefox", "webkit"} {
		r, err := one(pw, engine)
		if err != nil {
			return fmt.Errorf("%s: %w", engine, err)
		}
		results = append(results, r)
	}
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	enc.SetIndent("", "  ")
	if err := enc.Encode(map[string]any{"results": results}); err != nil {
		return err
	}
	fmt.Print(buf.String())
	return nil
}

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}
```

## What we got

| Browser | Stubbed products shown | Patched real response | Stub answering 500 | Images aborted | Header charset only |
| --- | --- | --- | --- | --- | --- |
| chromium | Red mug — $9 · Blue mug — $11 · Teapot — $24 | Patched: Sample Slide Show | error 500 | 2 | Red mug — $9 |
| firefox | Red mug — $9 · Blue mug — $11 · Teapot — $24 | Patched: Sample Slide Show | error 500 | 2 | Red mug — $9 |
| webkit | Red mug — $9 · Blue mug — $11 · Teapot — $24 | Patched: Sample Slide Show | error 500 | 2 | Red 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

-   **Interception works the same on a remote browser:** on Chromium, Firefox and WebKit the page rendered the three stubbed products from a site that doesn't exist, and showed `error 500` when the stub failed. Your handlers run in your program; only the decisions travel over the connection.
-   **Patching real responses:** `route.fetch()` went out through the session's proxy, and the page got the real httpbin JSON with the title changed — status and headers unchanged.
-   **Declare the charset inside fulfilled HTML:** with only `text/html; charset=utf-8` in the fulfilled headers, Chromium and Firefox showed the em dash correctly but WebKit rendered `â€”` — it read the body as Latin-1. A `<meta charset="utf-8">` in the page fixed it everywhere.
-   **Aborting saves traffic:** both images (one from the fake site, one real) were dropped before they were requested.
-   **Routes are per page here:** `context.route` does the same for every page in the context.

---
Source: https://cdpfleet.com/docs/cases/mock-api-responses · Markdown: https://cdpfleet.com/docs/cases/mock-api-responses.md · All docs for AI tools: https://cdpfleet.com/llms.txt
