Cases / #46 · 2026-10-14 · Easy
Browser extensions aren't supported — what to use instead
An honest "doesn't work" case: a launch with extension flags or an extension list is refused (`400 unsupported_option`). Content scripts, request blocking and header rewriting — what most automation extensions are for — work with Playwright instead, on Chrome and Firefox alike.
Run on production on 2026-10-14: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Locally you might load an ad blocker, a header-rewriting extension or your own content script with --load-extension. cdpfleet launches browsers for you, so there is no way to install an extension, and launch fields that would change the browser process are refused. What do you lose, and how do you get the same effect?
What we used, and why
| What | Why |
|---|---|
args: ["--load-extension=…"], extensions: […] in the launch | What people try first. Both are browser-process fields, which the fleet refuses with 400 unsupported_option, as it does executablePath, ignoreDefaultArgs, env, userDataDir, chromiumSandbox, devtools, downloadsPath and tracesDir. |
chrome.runtime / browser.runtime in the page | Present only when an extension exposes itself to the page. |
context.addInitScript() | The content-script equivalent: runs in every page and frame before the page's own scripts, after every navigation. |
context.route() + route.abort() | The request-blocking equivalent (ad and image blockers). |
context.setExtraHTTPHeaders() | The header-rewriting equivalent, for every request the browser makes. |
| httpbin.org/headers | Echoes the request headers, to prove the header went out. |
How it works
- Ask for Chrome and Firefox with an extension flag, a user-agent flag and an
extensionslist, and record the answer. - Launch both normally and check the page for an extension API.
- In a new context, add a content script that marks the page, block images, and add a request header.
- Open two pages of books.toscrape.com: count the pages the script marked and the images blocked; then read the headers httpbin received.
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 } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const SETUPS = [
{ label: 'Chrome', engine: 'chrome', type: chromium, api: 'typeof chrome !== "undefined" && !!chrome.runtime' },
{ label: 'Firefox', engine: 'firefox', type: firefox, api: 'typeof browser !== "undefined" && !!browser.runtime' },
];
const SITE = 'https://books.toscrape.com/';
// 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 { status: res.status, ...(await 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));
}
}
// What an extension's content script does: runs in every page before the page's own scripts.
const CONTENT_SCRIPT = `
window.__myExtension = true;
document.addEventListener('DOMContentLoaded', () => {
const b = document.createElement('div');
b.id = 'my-extension-banner';
b.textContent = 'Injected on ' + location.pathname;
document.body.prepend(b);
});`;
// Try to load an extension the way a local launch would: Chromium flags and an "extensions" list.
// The fleet refuses browser-process fields with 400 unsupported_option, so nothing is started.
async function tryExtensions(engine) {
const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({
proxy: process.env.PROXY_URL,
args: ['--load-extension=/tmp/my-extension', '--user-agent=ARGS-WERE-APPLIED'],
extensions: ['/tmp/my-extension'],
}),
});
const body = await res.json().catch(() => ({}));
return { status: res.status, error: body.error ?? null };
}
async function one(s) {
const refused = await tryExtensions(s.engine);
const out = { label: s.label, launch_status: refused.status, launch_error: refused.error };
// A normal launch, for the Playwright equivalents.
const session = await launch(s.engine, { proxy: process.env.PROXY_URL });
const browser = await s.type.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
try {
const page = await browser.newPage();
// chrome.runtime / browser.runtime exist in a page only when an extension talks to it.
out.extension_api = await page.evaluate(s.api);
await page.close();
// The Playwright equivalents, in one context.
const context = await browser.newContext();
await context.addInitScript(CONTENT_SCRIPT); // content script
await context.setExtraHTTPHeaders({ 'x-my-extension': 'on' }); // header rewriting
let blocked = 0; // request blocking, like an ad or image blocker
await context.route(/\.(png|jpe?g|gif|webp)(\?|$)/, (route) => { blocked++; return route.abort(); });
const p = await context.newPage();
let injected = 0;
for (const url of [SITE, `${SITE}catalogue/page-2.html`]) {
await p.goto(url, { timeout: 60000 });
if (await p.locator('#my-extension-banner').count()) injected++;
}
out.content_script_pages = injected;
out.blocked_images = blocked;
// httpbin echoes the request headers; read the response body, not the rendered page.
const { headers } = await (await p.goto('https://httpbin.org/headers', { timeout: 60000 })).json();
out.header_added = headers['X-My-Extension'] === 'on';
} finally {
await browser.close();
}
return out;
}
const results = [];
for (const s of SETUPS) results.push(await one(s));
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"]
SETUPS = [
{"label": "Chrome", "engine": "chrome", "family": "chromium", "api": "typeof chrome !== 'undefined' && !!chrome.runtime"},
{"label": "Firefox", "engine": "firefox", "family": "firefox", "api": "typeof browser !== 'undefined' && !!browser.runtime"},
]
SITE = "https://books.toscrape.com/"
# What an extension's content script does: runs in every page before the page's own scripts.
CONTENT_SCRIPT = """
window.__myExtension = true;
document.addEventListener('DOMContentLoaded', () => {
const b = document.createElement('div');
b.id = 'my-extension-banner';
b.textContent = 'Injected on ' + location.pathname;
document.body.prepend(b);
});"""
def launch(engine, body):
# 503 means the fleet is momentarily busy: wait Retry-After and try again.
for attempt in range(1, 6):
res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json=body, timeout=60)
if res.ok:
return res.status_code, res.json()
if res.status_code != 503 or attempt == 5:
raise RuntimeError(f"launch {engine} {res.status_code} {res.text}")
time.sleep(int(res.headers.get("retry-after", 5)))
def try_extensions(engine):
# Try to load an extension the way a local launch would: Chromium flags and an "extensions" list.
# The fleet refuses browser-process fields with 400 unsupported_option, so nothing is started.
res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json={
"proxy": os.environ["PROXY_URL"],
"args": ["--load-extension=/tmp/my-extension", "--user-agent=ARGS-WERE-APPLIED"],
"extensions": ["/tmp/my-extension"],
}, timeout=60)
try:
error = res.json().get("error")
except ValueError:
error = None
return res.status_code, error
def one(p, s):
status, error = try_extensions(s["engine"])
out = {"label": s["label"], "launch_status": status, "launch_error": error}
# A normal launch, for the Playwright equivalents.
_, session = launch(s["engine"], {"proxy": os.environ["PROXY_URL"]})
browser = getattr(p, s["family"]).connect(session["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_page()
# chrome.runtime / browser.runtime exist in a page only when an extension talks to it.
out["extension_api"] = page.evaluate(s["api"])
page.close()
# The Playwright equivalents, in one context.
context = browser.new_context()
context.add_init_script(CONTENT_SCRIPT) # content script
context.set_extra_http_headers({"x-my-extension": "on"}) # header rewriting
blocked = [0] # request blocking, like an ad or image blocker
def block(route):
blocked[0] += 1
route.abort()
context.route(re.compile(r"\.(png|jpe?g|gif|webp)(\?|$)"), block)
pg = context.new_page()
injected = 0
for url in [SITE, f"{SITE}catalogue/page-2.html"]:
pg.goto(url, timeout=60000)
if pg.locator("#my-extension-banner").count():
injected += 1
out["content_script_pages"] = injected
out["blocked_images"] = blocked[0]
# httpbin echoes the request headers; read the response body, not the rendered page.
headers = pg.goto("https://httpbin.org/headers", timeout=60000).json()["headers"]
out["header_added"] = headers.get("X-My-Extension") == "on"
finally:
browser.close()
return out
with sync_playwright() as p:
results = [one(p, s) for s in SETUPS]
print(json.dumps({"results": results}, indent=2))
// 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.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://books.toscrape.com/";
record Setup(String label, String engine, String family, String api) {}
static final Setup[] SETUPS = {
new Setup("Chrome", "chrome", "chromium", "typeof chrome !== 'undefined' && !!chrome.runtime"),
new Setup("Firefox", "firefox", "firefox", "typeof browser !== 'undefined' && !!browser.runtime"),
};
// What an extension's content script does: runs in every page before the page's own scripts.
static final String CONTENT_SCRIPT = """
window.__myExtension = true;
document.addEventListener('DOMContentLoaded', () => {
const b = document.createElement('div');
b.id = 'my-extension-banner';
b.textContent = 'Injected on ' + location.pathname;
document.body.prepend(b);
});""";
record Launched(int status, JsonObject session) {}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
static Launched 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 new Launched(res.statusCode(), 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);
}
}
// Try to load an extension the way a local launch would: Chromium flags and an "extensions" list.
// The fleet refuses browser-process fields with 400 unsupported_option, so nothing is started.
static JsonObject tryExtensions(String engine) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("proxy", System.getenv("PROXY_URL"));
JsonArray args = new JsonArray();
args.add("--load-extension=/tmp/my-extension");
args.add("--user-agent=ARGS-WERE-APPLIED");
body.add("args", args);
JsonArray exts = new JsonArray();
exts.add("/tmp/my-extension");
body.add("extensions", exts);
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());
JsonObject out = new JsonObject();
out.addProperty("status", res.statusCode());
try {
JsonElement e = JsonParser.parseString(res.body()).getAsJsonObject().get("error");
out.add("error", e == null ? JsonNull.INSTANCE : e);
} catch (Exception e) {
out.add("error", JsonNull.INSTANCE);
}
return out;
}
static JsonObject one(Playwright pw, Setup s) throws Exception {
JsonObject refused = tryExtensions(s.engine());
JsonObject out = new JsonObject();
out.addProperty("label", s.label());
out.add("launch_status", refused.get("status"));
out.add("launch_error", refused.get("error"));
// A normal launch, for the Playwright equivalents.
JsonObject body = new JsonObject();
body.addProperty("proxy", System.getenv("PROXY_URL"));
Launched l = launch(s.engine(), body);
BrowserType type = s.family().equals("firefox") ? pw.firefox() : pw.chromium();
Browser browser = type.connect(l.session().get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Page page = browser.newPage();
// chrome.runtime / browser.runtime exist in a page only when an extension talks to it.
out.addProperty("extension_api", (Boolean) page.evaluate(s.api()));
page.close();
// The Playwright equivalents, in one context.
BrowserContext context = browser.newContext();
context.addInitScript(CONTENT_SCRIPT); // content script
context.setExtraHTTPHeaders(Map.of("x-my-extension", "on")); // header rewriting
int[] blocked = {0}; // request blocking, like an ad or image blocker
context.route(Pattern.compile("\\.(png|jpe?g|gif|webp)(\\?|$)"), route -> { blocked[0]++; route.abort(); });
Page p = context.newPage();
int injected = 0;
for (String url : List.of(SITE, SITE + "catalogue/page-2.html")) {
p.navigate(url, new Page.NavigateOptions().setTimeout(60000));
if (p.locator("#my-extension-banner").count() > 0) injected++;
}
out.addProperty("content_script_pages", injected);
out.addProperty("blocked_images", blocked[0]);
// httpbin echoes the request headers; read the response body, not the rendered page.
Response resp = p.navigate("https://httpbin.org/headers", new Page.NavigateOptions().setTimeout(60000));
JsonObject headers = JsonParser.parseString(resp.text()).getAsJsonObject().getAsJsonObject("headers");
out.addProperty("header_added", headers.has("X-My-Extension") && headers.get("X-My-Extension").getAsString().equals("on"));
} finally {
browser.close();
}
return out;
}
public static void main(String[] args) throws Exception {
JsonArray results = new JsonArray();
try (Playwright pw = Playwright.create()) {
for (Setup s : SETUPS) results.add(one(pw, s));
}
JsonObject result = new JsonObject();
result.add("results", results);
System.out.println(GSON.toJson(result));
}
}
// 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://books.toscrape.com/";
var setups = new (string Label, string Engine, string Family, string Api)[]
{
("Chrome", "chrome", "chromium", "typeof chrome !== 'undefined' && !!chrome.runtime"),
("Firefox", "firefox", "firefox", "typeof browser !== 'undefined' && !!browser.runtime"),
};
// What an extension's content script does: runs in every page before the page's own scripts.
const string ContentScript = """
window.__myExtension = true;
document.addEventListener('DOMContentLoaded', () => {
const b = document.createElement('div');
b.id = 'my-extension-banner';
b.textContent = 'Injected on ' + location.pathname;
document.body.prepend(b);
});
""";
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<(int Status, JsonElement Session)> 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 ((int)res.StatusCode, 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));
}
}
// Try to load an extension the way a local launch would: Chromium flags and an "extensions" list.
// The fleet refuses browser-process fields with 400 unsupported_option, so nothing is started.
async Task<(int Status, string? Error)> TryExtensions(string engine)
{
var body = new JsonObject
{
["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"),
["args"] = new JsonArray("--load-extension=/tmp/my-extension", "--user-agent=ARGS-WERE-APPLIED"),
["extensions"] = new JsonArray("/tmp/my-extension"),
};
var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session",
new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
string? error = null;
try
{
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.String) error = e.GetString();
}
catch (JsonException) { }
return ((int)res.StatusCode, error);
}
async Task<JsonObject> One(string label, string engine, string family, string api)
{
var (refusedStatus, refusedError) = await TryExtensions(engine);
var outp = new JsonObject { ["label"] = label, ["launch_status"] = refusedStatus, ["launch_error"] = refusedError };
// A normal launch, for the Playwright equivalents.
var (_, session) = await Launch(engine, new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL") });
var type = family == "firefox" ? playwright.Firefox : playwright.Chromium;
var browser = await type.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var page = await browser.NewPageAsync();
// chrome.runtime / browser.runtime exist in a page only when an extension talks to it.
outp["extension_api"] = await page.EvaluateAsync<bool>(api);
await page.CloseAsync();
// The Playwright equivalents, in one context.
var context = await browser.NewContextAsync();
await context.AddInitScriptAsync(ContentScript); // content script
await context.SetExtraHTTPHeadersAsync(new Dictionary<string, string> { ["x-my-extension"] = "on" }); // header rewriting
var blocked = 0; // request blocking, like an ad or image blocker
await context.RouteAsync(new Regex(@"\.(png|jpe?g|gif|webp)(\?|$)"), route => { blocked++; return route.AbortAsync(); });
var p = await context.NewPageAsync();
var injected = 0;
foreach (var url in new[] { Site, $"{Site}catalogue/page-2.html" })
{
await p.GotoAsync(url, new() { Timeout = 60000 });
if (await p.Locator("#my-extension-banner").CountAsync() > 0) injected++;
}
outp["content_script_pages"] = injected;
outp["blocked_images"] = blocked;
// httpbin echoes the request headers; read the response body, not the rendered page.
var resp = await p.GotoAsync("https://httpbin.org/headers", new() { Timeout = 60000 });
var headers = (await resp!.JsonAsync())!.Value.GetProperty("headers");
outp["header_added"] = headers.TryGetProperty("X-My-Extension", out var v) && v.GetString() == "on";
}
finally
{
await browser.CloseAsync();
}
return outp;
}
var results = new JsonArray();
foreach (var s in setups) results.Add(await One(s.Label, s.Engine, s.Family, s.Api));
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://books.toscrape.com/"
type setup struct{ label, engine, family, api string }
var setups = []setup{
{"Chrome", "chrome", "chromium", "typeof chrome !== 'undefined' && !!chrome.runtime"},
{"Firefox", "firefox", "firefox", "typeof browser !== 'undefined' && !!browser.runtime"},
}
// What an extension's content script does: runs in every page before the page's own scripts.
const contentScript = `
window.__myExtension = true;
document.addEventListener('DOMContentLoaded', () => {
const b = document.createElement('div');
b.id = 'my-extension-banner';
b.textContent = 'Injected on ' + location.pathname;
document.body.prepend(b);
});`
// Field order matches the other languages' output.
type result struct {
Label string `json:"label"`
LaunchStatus int `json:"launch_status"`
LaunchError *string `json:"launch_error"`
ExtensionAPI bool `json:"extension_api"`
ContentScriptPages int `json:"content_script_pages"`
BlockedImages int `json:"blocked_images"`
HeaderAdded bool `json:"header_added"`
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(engine string, opts map[string]any) (int, 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 0, nil, err
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == 200 {
var session map[string]any
return res.StatusCode, session, json.Unmarshal(b, &session)
}
if res.StatusCode != 503 || attempt == 5 {
return 0, 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)
}
}
// Try to load an extension the way a local launch would: Chromium flags and an "extensions" list.
// The fleet refuses browser-process fields with 400 unsupported_option, so nothing is started.
func tryExtensions(engine string) (int, *string, error) {
body, _ := json.Marshal(map[string]any{
"proxy": os.Getenv("PROXY_URL"),
"args": []string{"--load-extension=/tmp/my-extension", "--user-agent=ARGS-WERE-APPLIED"},
"extensions": []string{"/tmp/my-extension"},
})
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 0, nil, err
}
defer res.Body.Close()
var reply struct {
Error *string `json:"error"`
}
b, _ := io.ReadAll(res.Body)
_ = json.Unmarshal(b, &reply)
return res.StatusCode, reply.Error, nil
}
func one(pw *playwright.Playwright, s setup) (result, error) {
out := result{Label: s.label}
status, refusal, err := tryExtensions(s.engine)
if err != nil {
return out, err
}
out.LaunchStatus, out.LaunchError = status, refusal
// A normal launch, for the Playwright equivalents.
_, session, err := launch(s.engine, map[string]any{"proxy": os.Getenv("PROXY_URL")})
if err != nil {
return out, err
}
browserType := pw.Chromium
if s.family == "firefox" {
browserType = pw.Firefox
}
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()
page, err := browser.NewPage()
if err != nil {
return out, err
}
// chrome.runtime / browser.runtime exist in a page only when an extension talks to it.
api, err := page.Evaluate(s.api)
if err != nil {
return out, err
}
out.ExtensionAPI = api == true
page.Close()
// The Playwright equivalents, in one context.
context, err := browser.NewContext()
if err != nil {
return out, err
}
if err := context.AddInitScript(playwright.Script{Content: playwright.String(contentScript)}); err != nil { // content script
return out, err
}
if err := context.SetExtraHTTPHeaders(map[string]string{"x-my-extension": "on"}); err != nil { // header rewriting
return out, err
}
blocked := 0 // request blocking, like an ad or image blocker
if err := context.Route(regexp.MustCompile(`\.(png|jpe?g|gif|webp)(\?|$)`), func(route playwright.Route) {
blocked++
route.Abort()
}); err != nil {
return out, err
}
p, err := context.NewPage()
if err != nil {
return out, err
}
for _, url := range []string{site, site + "catalogue/page-2.html"} {
if _, err := p.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
return out, err
}
if n, err := p.Locator("#my-extension-banner").Count(); err == nil && n > 0 {
out.ContentScriptPages++
}
}
out.BlockedImages = blocked
// httpbin echoes the request headers; read the response body, not the rendered page.
resp, err := p.Goto("https://httpbin.org/headers", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err != nil {
return out, err
}
var echoed struct {
Headers map[string]string `json:"headers"`
}
if err := resp.JSON(&echoed); err != nil {
return out, err
}
out.HeaderAdded = echoed.Headers["X-My-Extension"] == "on"
return out, nil
}
// run returns errors instead of exiting, so deferred Close and Stop calls still run.
func run() error {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
return err
}
defer pw.Stop()
results := []result{}
for _, s := range setups {
r, err := one(pw, s)
if err != nil {
return fmt.Errorf("%s: %w", s.label, 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 | Launch with args/extensions | Extension API in page | Content script (pages) | Images blocked | Header added |
|---|---|---|---|---|---|
| Chrome | 400 unsupported_option | no | 2 / 2 | 44 | yes |
| Firefox | 400 unsupported_option | no | 2 / 2 | 40 | yes |
From the Node.js run on 2026-10-14. 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": [
{
"label": "Chrome",
"launch_status": 400,
"launch_error": "unsupported_option",
"extension_api": false,
"content_script_pages": 2,
"blocked_images": 44,
"header_added": true
},
{
"label": "Firefox",
"launch_status": 400,
"launch_error": "unsupported_option",
"extension_api": false,
"content_script_pages": 2,
"blocked_images": 40,
"header_added": true
}
]
}Takeaways
- Extensions can't be loaded, and you're told so: the launch with
argsandextensionswas refused with400 {"error":"unsupported_option"}on both browsers, so nothing starts and nothing is billed; a normal launch has no extension API in the page. The same goes for the other browser-process fields above — use the launch options instead. - Content scripts →
addInitScript: the script ran on both pages, on Chrome and Firefox, and re-ran after each navigation — what a content script withrun_at: document_startgives you. - Blockers →
route(): aborting image requests blocked every image on the two pages (40 or more) on both browsers; the same pattern blocks ad and tracker hosts (bandwidth diet). - Header rewriting →
setExtraHTTPHeaders: httpbin received the added header from both browsers. - What has no equivalent: an extension's own UI, popup and background service worker, and extensions that call browser-internal APIs (password managers, wallets). If a site requires one of those, it won't run here.