#![expect(
clippy::disallowed_methods,
reason = "These browser-debug integration tests synchronously probe for Chrome binaries before launching external tooling."
)]
mod common;
use std::process::Stdio;
use serde_json::json;
use tokio::net::TcpListener;
use tokio::process::Command as TokioCommand;
use tokio::time::{Duration, timeout};
use twin_openai::config::Config;
#[tokio::test]
async fn debug_html_page_serves_valid_html_on_empty_state() {
let server = common::spawn_server().await.expect("server should start");
let response = server
.client
.get(format!("{}/__debug", server.base_url))
.send()
.await
.expect("debug page request should complete");
assert_eq!(response.status(), 200);
let content_type = response
.headers()
.get("content-type")
.expect("content-type header should be present")
.to_str()
.expect("content-type should be valid string");
assert!(
content_type.contains("text/html"),
"content-type should contain text/html, got: {content_type}"
);
let body = response.text().await.expect("body should read");
assert!(
body.contains(""),
"response should contain DOCTYPE"
);
assert!(
body.contains("twin-openai"),
"response should contain project name"
);
assert!(body.contains("debug"), "response should contain 'debug'");
assert!(
body.contains("no active namespaces"),
"empty state should show 'no active namespaces'"
);
}
#[tokio::test]
async fn debug_json_endpoint_returns_correct_state_snapshot() {
let server = common::spawn_server().await.expect("server should start");
// Load two scenarios: one success, one error
server
.enqueue_scenarios(json!({
"scenarios": [
{
"matcher": { "endpoint": "responses", "model": "gpt-test" },
"script": { "kind": "success" }
},
{
"matcher": { "endpoint": "responses", "model": "gpt-error" },
"script": {
"kind": "error",
"status": 500,
"message": "test error",
"error_type": "server_error",
"code": "server_error"
}
}
]
}))
.await;
// Make one request that consumes the first (success) scenario
let response = server
.post_responses(json!({
"model": "gpt-test",
"input": "hello debug",
"stream": false
}))
.await;
assert_eq!(response.status(), 200);
// GET the debug JSON endpoint (unauthenticated)
let response = server
.client
.get(format!("{}/__debug/state.json", server.base_url))
.send()
.await
.expect("debug json request should complete");
assert_eq!(response.status(), 200);
let content_type = response
.headers()
.get("content-type")
.expect("content-type header should be present")
.to_str()
.expect("content-type should be valid string");
assert!(
content_type.contains("application/json"),
"content-type should contain application/json, got: {content_type}"
);
let body: serde_json::Value = response.json().await.expect("json should parse");
// Should have a top-level namespaces array
let namespaces = body["namespaces"]
.as_array()
.expect("namespaces should be an array");
assert_eq!(namespaces.len(), 1, "should have exactly one namespace");
let ns = &namespaces[0];
assert!(
ns["key"].as_str().unwrap().starts_with("Bearer:"),
"namespace key should start with 'Bearer:', got: {}",
ns["key"]
);
// Should have 1 remaining scenario (the error one; the success was consumed)
let scenarios = ns["scenarios"]
.as_array()
.expect("scenarios should be an array");
assert_eq!(scenarios.len(), 1, "should have 1 remaining scenario");
assert_eq!(scenarios[0]["endpoint"], "responses");
assert_eq!(scenarios[0]["model"], "gpt-error");
assert_eq!(scenarios[0]["script_kind"], "error");
// Should have 1 request log
let request_logs = ns["request_logs"]
.as_array()
.expect("request_logs should be an array");
assert_eq!(request_logs.len(), 1, "should have 1 request log");
assert_eq!(request_logs[0]["endpoint"], "responses");
assert_eq!(request_logs[0]["model"], "gpt-test");
assert!(
request_logs[0]["input_text"]
.as_str()
.unwrap()
.contains("hello debug"),
"request log should contain input text 'hello debug'"
);
}
#[tokio::test]
async fn debug_html_page_reflects_loaded_scenarios_and_request_logs() {
let server = common::spawn_server().await.expect("server should start");
// Load one success scenario
server
.enqueue_scenarios(json!({
"scenarios": [
{
"matcher": { "endpoint": "responses", "model": "gpt-html-test" },
"script": { "kind": "success" }
}
]
}))
.await;
// Make one request with a different model (won't match, but still gets logged
// via the default behavior)
let response = server
.post_responses(json!({
"model": "gpt-other",
"input": "check the page",
"stream": false
}))
.await;
// The request gets a deterministic response (no matching scenario consumed
// since model doesn't match). Status should be 200 (default behavior).
assert_eq!(response.status(), 200);
// GET the debug HTML page
let response = server
.client
.get(format!("{}/__debug", server.base_url))
.send()
.await
.expect("debug page request should complete");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("body should read");
// Should contain the scenario's model name
assert!(
body.contains("gpt-html-test"),
"HTML should contain scenario model 'gpt-html-test'"
);
// Should contain the script kind
assert!(
body.contains("success"),
"HTML should contain script kind 'success'"
);
// Should contain the request log model
assert!(
body.contains("gpt-other"),
"HTML should contain request log model 'gpt-other'"
);
// Should contain the request log input text
assert!(
body.contains("check the page"),
"HTML should contain request log input text 'check the page'"
);
// Verify the server-rendered content section does not show empty state.
// The JS source always includes the "no active namespaces" string as a
// template, so we check that the server-rendered content div contains
// namespace sections rather than the empty-state paragraph.
assert!(
body.contains("namespace-header"),
"HTML should contain a namespace-header element (proving non-empty rendering)"
);
}
#[tokio::test]
async fn debug_routes_not_accessible_when_admin_disabled() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind should succeed");
let addr = listener.local_addr().expect("should have addr");
let app = twin_openai::build_app_with_config(Config {
bind_addr: "127.0.0.1:0".parse().expect("valid addr"),
require_auth: false,
enable_admin: false,
});
tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
let base_url = format!("http://{addr}");
let client = common::test_http_client().expect("test client");
let html_response = client
.get(format!("{base_url}/__debug"))
.send()
.await
.expect("request should complete");
assert_eq!(
html_response.status(),
404,
"debug HTML should be 404 when admin disabled"
);
let json_response = client
.get(format!("{base_url}/__debug/state.json"))
.send()
.await
.expect("request should complete");
assert_eq!(
json_response.status(),
404,
"debug JSON should be 404 when admin disabled"
);
}
#[tokio::test]
async fn debug_page_renders_in_headless_chrome() {
// Find Chrome binary
let chrome_binary = ["chromium", "google-chrome", "chromium-browser"]
.iter()
.find(|name| {
std::process::Command::new("which")
.arg(name)
.output()
.is_ok_and(|o| o.status.success())
});
let Some(chrome_binary) = chrome_binary.copied() else {
return;
};
let server = common::spawn_server().await.expect("server should start");
// Load a scenario and make a request so the page has content
server
.enqueue_scenarios(json!({
"scenarios": [
{
"matcher": { "endpoint": "responses", "model": "gpt-screenshot" },
"script": { "kind": "success" }
}
]
}))
.await;
let response = server
.post_responses(json!({
"model": "gpt-screenshot",
"input": "screenshot test",
"stream": false
}))
.await;
assert_eq!(response.status(), 200);
let screenshot_path = format!(
"/tmp/twin-openai-debug-screenshot-{}.png",
std::process::id()
);
let mut command = TokioCommand::new(chrome_binary);
command.args([
"--headless",
"--disable-gpu",
&format!("--screenshot={screenshot_path}"),
"--window-size=1280,900",
]);
if cfg!(target_os = "linux") {
// Ubuntu 24.04 GitHub runners block Chrome's default sandbox unless it
// is launched with a compatible user namespace or disabled explicitly.
command.arg("--no-sandbox");
}
let mut child = command
.stdout(Stdio::null())
.stderr(Stdio::null())
// Static mode keeps the page visually identical for the screenshot
// while avoiding a live refresh loop that can stall headless Chrome
// on Linux CI.
.arg(format!("{}/__debug?refresh=0", server.base_url))
.spawn()
.expect("Chrome should start");
// Chrome with --screenshot exits once the file is written, so waiting on
// the process is the deterministic completion signal.
let wait_result = timeout(Duration::from_mins(2), child.wait()).await;
let status = match wait_result {
Ok(Ok(status)) => status,
Ok(Err(err)) => panic!("Chrome wait failed: {err}"),
Err(_) => {
let _ = child.start_kill();
let _ = child.wait().await;
panic!("Chrome did not exit within 120s while taking screenshot");
}
};
assert!(
status.success(),
"Chrome exited with non-success status: {status}"
);
let screenshot_data = std::fs::read(&screenshot_path)
.expect("Chrome exited successfully but screenshot file is missing");
assert!(
screenshot_data.len() >= 10_000,
"screenshot should be at least 10KB, got {} bytes",
screenshot_data.len()
);
// Check PNG magic bytes
assert_eq!(
&screenshot_data[..8],
b"\x89PNG\r\n\x1a\n",
"screenshot should be a valid PNG"
);
// Clean up
let _ = std::fs::remove_file(&screenshot_path);
}
#[tokio::test]
async fn debug_html_escapes_user_controlled_values() {
let server = common::spawn_server().await.expect("server should start");
// Load a scenario with an XSS attempt in the model name
server
.enqueue_scenarios(json!({
"scenarios": [
{
"matcher": {
"endpoint": "responses",
"model": ""
},
"script": { "kind": "success" }
}
]
}))
.await;
let response = server
.client
.get(format!("{}/__debug", server.base_url))
.send()
.await
.expect("debug page request should complete");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("body should read");
// Should contain the escaped form
assert!(
body.contains("<script>"),
"HTML should contain escaped '