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.
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
- On each browser, route the made-up site
shop.example.testto an HTML page served from the script, and its/api/productsto three stubbed products. - Route
httpbin.org/jsonthroughroute.fetch()and rewrite the slideshow title; abort all image requests. - 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:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// 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));
# 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))
// 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));
}
}
// 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 get github.com/playwright-community/[email protected]
// 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 500when 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-8in 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.routedoes the same for every page in the context.