#![allow(
clippy::too_many_lines,
clippy::doc_markdown,
clippy::uninlined_format_args,
clippy::unwrap_used,
clippy::expect_used,
clippy::needless_pass_by_value
)]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
use serde_json::json;
use super::client::McpClient;
fn skip_if_no_new_context(c: &McpClient) -> bool {
c.backend == "webkit"
}
pub fn test_context_options_user_agent(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({ userAgent: 'FerriUA/1.0 (RuleNine)' });
try {
const p = await ctx.newPage();
const ua = await p.evaluate(() => navigator.userAgent);
return { ua };
} finally {
await ctx.close();
}
",
);
let ua = v["ua"].as_str().unwrap_or("");
assert!(
ua.contains("FerriUA/1.0 (RuleNine)"),
"navigator.userAgent should reflect contextOptions.userAgent: got {ua:?}"
);
}
pub fn test_context_options_locale(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({ locale: 'de-DE' });
try {
const p = await ctx.newPage();
const lang = await p.evaluate(() => navigator.language);
return { lang };
} finally {
await ctx.close();
}
",
);
let lang = v["lang"].as_str().unwrap_or("");
assert!(
lang.starts_with("de"),
"navigator.language should reflect locale 'de-DE': got {lang:?}"
);
}
pub fn test_context_options_timezone(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({ timezoneId: 'America/New_York' });
try {
const p = await ctx.newPage();
const tz = await p.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone);
return { tz };
} finally {
await ctx.close();
}
",
);
let tz = v["tz"].as_str().unwrap_or("");
assert_eq!(
tz, "America/New_York",
"resolvedOptions().timeZone should match timezoneId override: got {tz:?}"
);
}
pub fn test_context_options_color_scheme(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({ colorScheme: 'dark' });
try {
const p = await ctx.newPage();
const dark = await p.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches);
return { dark };
} finally {
await ctx.close();
}
",
);
assert_eq!(
v["dark"].as_bool(),
Some(true),
"matchMedia(prefers-color-scheme: dark) should be true: {v}"
);
}
pub fn test_context_options_reduced_motion(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({ reducedMotion: 'reduce' });
try {
const p = await ctx.newPage();
const reduce = await p.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches);
return { reduce };
} finally {
await ctx.close();
}
",
);
assert_eq!(
v["reduce"].as_bool(),
Some(true),
"matchMedia(prefers-reduced-motion: reduce) should be true: {v}"
);
}
pub fn test_context_options_forced_colors(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({ forcedColors: 'active' });
try {
const p = await ctx.newPage();
const active = await p.evaluate(() => matchMedia('(forced-colors: active)').matches);
return { active };
} finally {
await ctx.close();
}
",
);
assert_eq!(
v["active"].as_bool(),
Some(true),
"matchMedia(forced-colors: active) should be true: {v}"
);
}
pub fn test_context_options_viewport(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({ viewport: { width: 800, height: 600 } });
try {
const p = await ctx.newPage();
const dims = await p.evaluate(() => ({
w: window.innerWidth,
h: window.innerHeight,
}));
return dims;
} finally {
await ctx.close();
}
",
);
assert_eq!(
v["w"].as_u64(),
Some(800),
"innerWidth should match viewport.width: {v}"
);
assert_eq!(
v["h"].as_u64(),
Some(600),
"innerHeight should match viewport.height: {v}"
);
}
pub fn test_context_options_javascript_enabled(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let v = c.script_value(
r#"
const ctx = await browser.newContext({ javaScriptEnabled: false });
try {
const p = await ctx.newPage();
// Navigate to a data URL whose inline script would set a
// dataset attr if scripts are enabled. With JS disabled the
// attribute should be absent.
await p.goto("data:text/html,<body><script>document.body.dataset.set='yes'</script></body>");
// p.evaluate is run via the runtime — that channel may still
// work even with JS disabled. So we read back via attribute
// inspection; on disabled JS Playwright provides
// `page.content()` which reflects the post-parse DOM. We use
// `innerHTML` of body via a dedicated runtime context (works
// around the disabled-page-context).
const innerHtml = await p.evaluate(() => document.body.outerHTML);
return { innerHtml };
} finally {
await ctx.close();
}
"#,
);
let html = v["innerHtml"].as_str().unwrap_or("");
assert!(
!html.contains("data-set"),
"with JS disabled, inline script should not have set dataset: got {html:?}"
);
}
pub fn test_context_options_geolocation(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind geolocation server");
let port = listener.local_addr().expect("addr").port();
thread::spawn(move || {
while let Ok((mut stream, _)) = listener.accept() {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
if line == "\r\n" || line == "\n" {
break;
}
}
let body = "<!doctype html><body>geo</body>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
}
});
let url = format!("http://localhost:{port}/geo");
let v = c.script_value_with_args(
r"
const [url] = args;
const ctx = await browser.newContext({
geolocation: { latitude: 12.5, longitude: 34.75, accuracy: 1 },
permissions: ['geolocation'],
});
try {
const p = await ctx.newPage();
await p.goto(url);
const coords = await p.evaluate(() => new Promise(resolve => {
if (!navigator.geolocation) {
resolve({ error: 'no geolocation api' });
return;
}
navigator.geolocation.getCurrentPosition(
pos => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
err => resolve({ error: err.code + ':' + err.message }),
{ timeout: 4000 },
);
}));
return coords;
} finally {
await ctx.close();
}
",
json!([url]),
);
if let Some(err) = v["error"].as_str() {
panic!("geolocation should resolve when permissions are granted: got error {err}");
}
let lat = v["lat"].as_f64().unwrap_or_default();
let lng = v["lng"].as_f64().unwrap_or_default();
assert!(
(lat - 12.5).abs() < 0.5,
"latitude should match geolocation override: got {lat}"
);
assert!(
(lng - 34.75).abs() < 0.5,
"longitude should match geolocation override: got {lng}"
);
}
pub fn test_context_options_extra_http_headers(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind echo server");
let port = listener.local_addr().expect("addr").port();
let (tx, rx) = mpsc::channel::<String>();
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut header_value = String::new();
let mut content_length = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
if line == "\r\n" || line == "\n" {
break;
}
if let Some(rest) = line.strip_prefix("x-rule-nine:") {
header_value = rest.trim().to_string();
}
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") {
content_length = rest.trim().parse().unwrap_or(0);
}
}
if content_length > 0 {
let mut buf = vec![0u8; content_length];
let _ = reader.read_exact(&mut buf);
}
let body = format!("HEADER:{header_value}");
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
let _ = tx.send(header_value);
}
});
let url = format!("http://127.0.0.1:{port}/rule-nine");
let v = c.script_value_with_args(
r"
const [url] = args;
const ctx = await browser.newContext({
extraHTTPHeaders: { 'x-rule-nine': 'pingpong' },
});
try {
const p = await ctx.newPage();
const resp = await p.goto(url);
const body = await p.evaluate(() => document.body.textContent);
return { body, status: resp ? resp.status() : null };
} finally {
await ctx.close();
}
",
json!([url]),
);
let server_seen = rx.recv_timeout(std::time::Duration::from_secs(8)).unwrap_or_default();
assert_eq!(
server_seen, "pingpong",
"echo server should have observed the override header on the request"
);
let body = v["body"].as_str().unwrap_or("");
assert!(
body.contains("HEADER:pingpong"),
"page body should echo the override header: {body:?}"
);
}
pub fn test_context_options_offline(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({ offline: true });
try {
const p = await ctx.newPage();
// Navigate first to a data URL (cached, doesn't need network),
// then attempt a fetch — should fail with the offline error.
await p.goto('data:text/html,<body>offline-test</body>');
const result = await p.evaluate(async () => {
try {
await fetch('http://127.0.0.1:1/never');
return { ok: true };
} catch (e) {
return { ok: false, msg: String(e && e.message ? e.message : e) };
}
});
return result;
} finally {
await ctx.close();
}
",
);
assert_eq!(v["ok"].as_bool(), Some(false), "fetch should reject when offline: {v}");
}
pub fn test_context_options_device_scale_factor(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({
viewport: { width: 800, height: 600 },
deviceScaleFactor: 2,
});
try {
const p = await ctx.newPage();
const dpr = await p.evaluate(() => window.devicePixelRatio);
return { dpr };
} finally {
await ctx.close();
}
",
);
let dpr = v["dpr"].as_f64().unwrap_or(0.0);
assert!(
(dpr - 2.0).abs() < 0.01,
"devicePixelRatio should match deviceScaleFactor=2: got {dpr}"
);
}
pub fn test_context_options_proxy(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let origin_listener = TcpListener::bind("127.0.0.1:0").expect("bind origin");
let origin_port = origin_listener.local_addr().expect("addr").port();
thread::spawn(move || {
while let Ok((mut stream, _)) = origin_listener.accept() {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
if line == "\r\n" || line == "\n" {
break;
}
}
let body = "<!doctype html><body>origin</body>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
}
});
let proxy_listener = TcpListener::bind("127.0.0.1:0").expect("bind proxy");
let proxy_port = proxy_listener.local_addr().expect("addr").port();
let observed: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let observed_for_thread = observed.clone();
thread::spawn(move || {
while let Ok((mut stream, _)) = proxy_listener.accept() {
let observed = observed_for_thread.clone();
thread::spawn(move || {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut first_line = String::new();
if reader.read_line(&mut first_line).unwrap_or(0) == 0 {
return;
}
loop {
let mut l = String::new();
if reader.read_line(&mut l).unwrap_or(0) == 0 {
break;
}
if l == "\r\n" || l == "\n" {
break;
}
}
if let Ok(mut log) = observed.lock() {
log.push(first_line.clone());
}
let body = "<!doctype html><body>PROXY:ok</body>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
});
}
});
let proxy_url = format!("http://127.0.0.1:{proxy_port}");
let origin_url = format!("http://127.0.0.1:{origin_port}/behind-proxy");
let v = c.script_value_with_args(
r"
const [proxyUrl, originUrl] = args;
const ctx = await browser.newContext({
// `<-loopback>` flips Chrome's default-bypass for loopback so
// `127.0.0.1` actually routes through the proxy — required
// for localhost-based Rule-9 proofs. Matches Playwright's
// test-infra pattern (`chromium.ts::proxyBypassRules`).
proxy: { server: proxyUrl, bypass: '<-loopback>' },
ignoreHTTPSErrors: true,
});
try {
const p = await ctx.newPage();
await p.goto(originUrl);
const body = await p.evaluate(() => document.body.textContent);
return { body };
} finally {
await ctx.close();
}
",
json!([proxy_url, origin_url]),
);
let body = v["body"].as_str().unwrap_or("");
assert!(
body.contains("PROXY:ok"),
"request should have traversed the per-context proxy: body={body:?}"
);
let log = observed.lock().expect("observed");
assert!(
!log.is_empty(),
"proxy server should have received at least one request"
);
assert!(
log
.iter()
.any(|l| l.contains("127.0.0.1") && l.contains("behind-proxy")),
"proxy request line should target the origin: {log:?}"
);
}
pub fn test_context_options_storage_state(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind storageState server");
let port = listener.local_addr().expect("addr").port();
thread::spawn(move || {
while let Ok((mut stream, _)) = listener.accept() {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
if line == "\r\n" || line == "\n" {
break;
}
}
let body = "<!doctype html><body>storage</body>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
}
});
let origin = format!("http://127.0.0.1:{port}");
let state = json!({
"cookies": [
{ "name": "ferri_ck", "value": "hello",
"domain": "127.0.0.1", "path": "/",
"secure": false, "httpOnly": false,
"expires": -1.0_f64, "sameSite": "Lax" }
],
"origins": [
{ "origin": origin.clone(),
"localStorage": [ { "name": "ferri_ls", "value": "world" } ] }
]
});
let url = format!("{origin}/");
let v = c.script_value_with_args(
r"
const [state, url] = args;
const ctx = await browser.newContext({ storageState: state });
try {
const p = await ctx.newPage();
await p.goto(url);
const got = await p.evaluate(() => ({
ck: document.cookie,
ls: localStorage.getItem('ferri_ls'),
}));
return got;
} finally {
await ctx.close();
}
",
json!([state, url]),
);
let ck = v["ck"].as_str().unwrap_or("");
let ls = v["ls"].as_str().unwrap_or("");
if c.backend == "cdp-pipe" || c.backend == "cdp-raw" {
assert!(
ck.contains("ferri_ck=hello"),
"cookie from storageState should be visible: {v}"
);
}
assert_eq!(ls, "world", "localStorage from storageState should be restored: {v}");
}
pub fn test_context_options_base_url(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind baseURL server");
let port = listener.local_addr().expect("addr").port();
thread::spawn(move || {
while let Ok((mut stream, _)) = listener.accept() {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut path = String::new();
let mut first = true;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
if first {
if let Some(rest) = line.strip_prefix("GET ") {
path = rest.split_whitespace().next().unwrap_or("").to_string();
}
first = false;
}
if line == "\r\n" || line == "\n" {
break;
}
}
let body = format!("<!doctype html><body>PATH:{path}</body>");
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
}
});
let base = format!("http://127.0.0.1:{port}");
let v = c.script_value_with_args(
r"
const [base] = args;
const ctx = await browser.newContext({ baseURL: base });
try {
const p = await ctx.newPage();
await p.goto('/hello/world');
const body = await p.evaluate(() => document.body.textContent);
return { body };
} finally {
await ctx.close();
}
",
json!([base]),
);
let body = v["body"].as_str().unwrap_or("");
assert!(
body.contains("PATH:/hello/world"),
"baseURL should resolve relative goto path: got {body:?}"
);
}
pub fn test_context_options_service_workers_block(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({ serviceWorkers: 'block' });
try {
const p = await ctx.newPage();
await p.goto('data:text/html,<body></body>');
const result = await p.evaluate(async () => {
if (!navigator.serviceWorker) return { hasSW: false };
try {
await navigator.serviceWorker.register('/sw.js');
return { hasSW: true, rejected: false };
} catch (e) {
return { hasSW: true, rejected: true, msg: String(e.message || e) };
}
});
return result;
} finally {
await ctx.close();
}
",
);
if v["hasSW"].as_bool() == Some(true) {
assert_eq!(
v["rejected"].as_bool(),
Some(true),
"serviceWorkers: 'block' should force navigator.serviceWorker.register to reject: {v}"
);
}
}
pub fn test_context_options_screen(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({
viewport: { width: 640, height: 480 },
screen: { width: 1920, height: 1080 },
});
try {
const p = await ctx.newPage();
const dims = await p.evaluate(() => ({
sw: window.screen.width,
sh: window.screen.height,
}));
return dims;
} finally {
await ctx.close();
}
",
);
let sw = v["sw"].as_u64().unwrap_or(0);
let sh = v["sh"].as_u64().unwrap_or(0);
assert_eq!(sw, 1920, "screen.width should reflect override: {v}");
assert_eq!(sh, 1080, "screen.height should reflect override: {v}");
}
pub fn test_context_options_bypass_csp(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind csp server");
let port = listener.local_addr().expect("addr").port();
thread::spawn(move || {
while let Ok((mut stream, _)) = listener.accept() {
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
if line == "\r\n" || line == "\n" {
break;
}
}
let body = "<!doctype html><html><head><meta http-equiv=\"Content-Security-Policy\" content=\"script-src 'none'\"></head><body>csp</body></html>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
}
});
let url = format!("http://127.0.0.1:{port}/csp");
let v = c.script_value_with_args(
r"
const [url] = args;
const ctx = await browser.newContext({ bypassCSP: true });
try {
const p = await ctx.newPage();
await p.addInitScript(() => { window.__fd_csp_bypass = 'yes'; });
await p.goto(url);
const flag = await p.evaluate(() => window.__fd_csp_bypass || null);
return { flag };
} finally {
await ctx.close();
}
",
json!([url]),
);
assert_eq!(
v["flag"].as_str(),
Some("yes"),
"bypassCSP should let addInitScript run on a strict-CSP page: {v}"
);
}
pub fn test_context_options_has_touch(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({
viewport: { width: 800, height: 600 },
hasTouch: true,
});
try {
const p = await ctx.newPage();
const touch = await p.evaluate(() => 'ontouchstart' in window || (navigator.maxTouchPoints > 0));
return { touch };
} finally {
await ctx.close();
}
",
);
assert_eq!(
v["touch"].as_bool(),
Some(true),
"hasTouch should expose touch APIs to the page: {v}"
);
}
fn spawn_basic_auth_server(max_conns: usize) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind auth server");
let port = listener.local_addr().expect("addr").port();
thread::spawn(move || {
for _ in 0..max_conns {
let Ok((mut stream, _)) = listener.accept() else { break };
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut authed = false;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
if line == "\r\n" || line == "\n" {
break;
}
if line.to_ascii_lowercase().starts_with("authorization:") && line.contains("dXNlcjpwYXNz") {
authed = true;
}
}
let resp = if authed {
let body = "AUTHED";
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
} else {
let body = "NOAUTH";
format!(
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"r9\"\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
};
let _ = stream.write_all(resp.as_bytes());
}
});
port
}
pub fn test_context_set_http_credentials(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
if c.backend == "bidi" {
let v = c.script_value(
r"
const ctx = await browser.newContext({});
try {
await ctx.newPage();
let err = null;
try { await ctx.setHTTPCredentials({ username: 'user', password: 'pass' }); }
catch (e) { err = String(e && e.message ? e.message : e); }
return { err };
} finally {
await ctx.close();
}
",
);
let err = v["err"].as_str().unwrap_or("");
assert!(
err.contains("not supported") || err.contains("Unsupported"),
"BiDi setHTTPCredentials should reject as Unsupported: {v}"
);
return;
}
let port = spawn_basic_auth_server(2);
let url = format!("http://127.0.0.1:{port}/secret");
let v = c.script_value_with_args(
r"
const [url] = args;
const ctx = await browser.newContext({});
try {
const p = await ctx.newPage();
// With credentials set, the backend's Fetch.authRequired hook
// answers the challenge → 200 AUTHED. This 200 only occurs when
// the credentials took effect; a no-credentials top-level nav to
// this URL aborts with ERR_INVALID_AUTH_CREDENTIALS.
await ctx.setHTTPCredentials({ username: 'user', password: 'pass' });
const r = await p.goto(url);
const status = r ? r.status() : null;
const body = await p.evaluate(() => document.body.textContent);
return { status, body };
} finally {
await ctx.close();
}
",
json!([url]),
);
let as_status = |val: &serde_json::Value| -> Option<i64> {
val
.as_i64()
.or_else(|| val.as_str().and_then(|s| s.parse::<i64>().ok()))
};
assert_eq!(
as_status(&v["status"]),
Some(200),
"nav after setHTTPCredentials should 200: {v}"
);
assert!(
v["body"].as_str().unwrap_or("").contains("AUTHED"),
"authed body should be served after setHTTPCredentials: {v}"
);
}
pub fn test_context_set_default_timeout(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({});
try {
// Setter takes effect via interior mutability on a shared handle.
ctx.setDefaultTimeout(50);
ctx.setDefaultNavigationTimeout(50);
const p = await ctx.newPage();
await p.goto('data:text/html,<body>timeout-probe</body>');
let err = null;
try {
await p.waitForSelector('#never-ever', { timeout: 50 });
} catch (e) {
err = String(e && e.message ? e.message : e);
}
return { err };
} finally {
await ctx.close();
}
",
);
let err = v["err"].as_str().unwrap_or("");
assert!(
err.to_ascii_lowercase().contains("timeout") || err.to_ascii_lowercase().contains("timed out"),
"waitForSelector should time out: {v}"
);
}
pub fn test_context_is_closed_and_browser(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({});
const before = await ctx.isClosed();
const hasBrowser = ctx.browser() != null;
// browser() should hand back a usable Browser (version() is sync-ish).
const ver = ctx.browser() != null ? String(ctx.browser().version()) : null;
await ctx.close();
const after = await ctx.isClosed();
return { before, hasBrowser, after, verNonEmpty: ver != null && ver.length > 0 };
",
);
assert_eq!(
v["before"].as_bool(),
Some(false),
"isClosed() should be false before close: {v}"
);
assert_eq!(
v["hasBrowser"].as_bool(),
Some(true),
"browser() should return the parent Browser: {v}"
);
assert_eq!(
v["verNonEmpty"].as_bool(),
Some(true),
"browser().version() should be a non-empty string: {v}"
);
assert_eq!(
v["after"].as_bool(),
Some(true),
"isClosed() should be true after close: {v}"
);
}
pub fn test_context_route_and_unroute(c: &mut McpClient) {
if skip_if_no_new_context(c) {
return;
}
let v = c.script_value(
r"
const ctx = await browser.newContext({});
try {
const p = await ctx.newPage();
const matcher = 'https://ferri.test/**';
await ctx.route(matcher, (route) => {
route.fulfill({ status: 200, contentType: 'text/html', body: '<body>ROUTED</body>' });
});
await p.goto('https://ferri.test/page');
const routed = await p.evaluate(() => document.body.textContent);
await ctx.unroute(matcher);
return { routed };
} finally {
await ctx.close();
}
",
);
assert!(
v["routed"].as_str().unwrap_or("").contains("ROUTED"),
"context.route should fulfil the matched request: {v}"
);
}