fabro/test/twin/openai/tests/debug_ui.rs
Bryan Helmkamp 4585f9874c
fix(test): wait for chrome process exit instead of polling for screenshot
The headless Chrome screenshot test polled for the screenshot file every
100 ms for 20 seconds, then panicked. Slow CI runners (Ubuntu 24.04 GHA)
sometimes took longer than the polling deadline, surfacing as a flake.

Chrome with `--screenshot` exits when the file is written, so waiting on
the process is the deterministic completion signal — no polling, no
arbitrary deadline that might be too short.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:49:04 -04:00

403 lines
13 KiB
Rust

#![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("<!DOCTYPE html>"),
"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>alert('xss')</script>"
},
"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("&lt;script&gt;"),
"HTML should contain escaped '<script>' as '&lt;script&gt;'"
);
// Should NOT contain the raw injection
assert!(
!body.contains("<script>alert"),
"HTML should NOT contain raw unescaped '<script>alert'"
);
}