feb2e100c4
- browser/sweep.rs:新增清扫模块,launch 时向 ga-browser-* 目录写入 owner.json 身份锚点(浏览器 root PID 与启动身份、可信 exe 路径、App 属主 PID 与身份) - browser/sweep.rs:清扫按属主存活跳过、进程已退出或 PID 复用只删目录、root 存活且身份一致才杀整树分级处置,杀树前要求 exe canonical 匹配系统标准浏览器候选路径,防止伪造 owner.json 借刀杀进程 - browser/sweep.rs:Windows 用 ToolHelp 快照走树终止 root 及全部后代并逐轮复核 root 身份,Unix 只杀 root 由子进程级联退出;无 owner.json 的旧版本残留只删目录不按猜测杀进程,五分钟内的目录一律跳过防止误伤进行中的预检 - main.rs:App 启动时后台异步清扫并把处置记录写入应用日志,不阻塞窗口 - environment_check/web_creation.rs:每次 Web 预检前顺手清扫一遍,防同会话残留放大超时 - browser/process.rs:launch 时写 owner.json,暴露临时目录前缀常量 - process_session/tests.rs:Job 杀树测试的 timeout fixture 在 stdio 重定向下会立即退出导致空转通过,改用 ping fixture 并补杀前树非空断言 - browser/sweep.rs:新增年龄门禁、活实例跳过、死进程只删不杀、坏 owner 按旧残留、可信 exe 匹配与 Windows 快照走树收割的定向测试
599 lines
22 KiB
Rust
599 lines
22 KiB
Rust
use std::fs;
|
||
use std::path::PathBuf;
|
||
use std::time::Duration;
|
||
#[cfg(windows)]
|
||
use std::time::Instant;
|
||
|
||
use chromiumoxide::async_process::{Child as BrowserChild, ChildStderr as BrowserChildStderr};
|
||
use chromiumoxide::browser::{Browser, BrowserConfig};
|
||
use chromiumoxide::handler::HandlerConfig;
|
||
use futures::{AsyncBufReadExt, FutureExt, StreamExt};
|
||
use tempfile::{Builder as TempDirBuilder, TempDir};
|
||
|
||
use super::cdp::run_browser_validation;
|
||
use super::discovery::discover_chrome_or_edge;
|
||
use super::evidence::{
|
||
browser_validation_result_for_report, prepare_evidence_root, unix_time_ms, write_json_report,
|
||
};
|
||
use super::model::{
|
||
BrowserIdentity, BrowserValidationInput, BrowserValidationResult, BROWSER_TIMEOUT,
|
||
};
|
||
use super::network_policy::{preview_proxy_bypass_list, validate_input};
|
||
#[cfg(windows)]
|
||
use crate::process_session::WindowsProcessJob;
|
||
|
||
pub(super) const BROWSER_TEMP_PREFIX: &str = "ga-browser-";
|
||
|
||
pub(super) fn browser_process_temp_root() -> PathBuf {
|
||
#[cfg(unix)]
|
||
{
|
||
PathBuf::from("/tmp")
|
||
}
|
||
#[cfg(not(unix))]
|
||
{
|
||
std::env::temp_dir()
|
||
}
|
||
}
|
||
|
||
pub(super) fn create_browser_process_temp_dir() -> Result<TempDir, String> {
|
||
TempDirBuilder::new()
|
||
.prefix(BROWSER_TEMP_PREFIX)
|
||
.tempdir_in(browser_process_temp_root())
|
||
.map_err(|error| format!("创建浏览器临时目录失败:{error}"))
|
||
}
|
||
|
||
fn browser_config(
|
||
executable: &std::path::Path,
|
||
temporary: &TempDir,
|
||
bypass: &str,
|
||
) -> Result<BrowserConfig, String> {
|
||
let profile_path = temporary.path().join("profile");
|
||
fs::create_dir(&profile_path)
|
||
.map_err(|error| format!("创建浏览器临时 Profile 失败:{error}"))?;
|
||
BrowserConfig::builder()
|
||
.chrome_executable(executable)
|
||
.user_data_dir(profile_path)
|
||
.env("TMPDIR", temporary.path().to_string_lossy().into_owned())
|
||
.new_headless_mode()
|
||
.enable_request_intercept()
|
||
.disable_cache()
|
||
.disable_https_first()
|
||
.request_timeout(BROWSER_TIMEOUT)
|
||
.launch_timeout(BROWSER_TIMEOUT)
|
||
.window_size(1280, 720)
|
||
.arg(("proxy-server", "http://127.0.0.1:9"))
|
||
.arg(("proxy-bypass-list", bypass))
|
||
.arg("block-new-web-contents")
|
||
.arg("deny-permission-prompts")
|
||
.arg("disable-notifications")
|
||
.arg("disable-service-worker")
|
||
.build()
|
||
.map_err(|error| format!("构建浏览器配置失败:{error}"))
|
||
}
|
||
|
||
const BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
|
||
const MAX_LAUNCH_STDERR_BYTES: usize = 64 * 1024;
|
||
const MAX_WS_URL_LEN: usize = 512;
|
||
|
||
/// 必须与 browser_config 的 builder 调用保持一致:request_timeout /
|
||
/// enable_request_intercept / disable_cache 在这里逐项镜像;其余字段
|
||
/// (ignore_https_errors / ignore_invalid_messages / viewport)与
|
||
/// BrowserConfigBuilder 默认值相同,沿用 HandlerConfig::default()。
|
||
fn handler_config() -> HandlerConfig {
|
||
let mut config = HandlerConfig::default();
|
||
config.request_timeout = BROWSER_TIMEOUT;
|
||
config.request_intercept = true;
|
||
config.cache_enabled = false;
|
||
config
|
||
}
|
||
|
||
/// 从浏览器 stderr 行提取 DevTools ws 地址;只接受 loopback 端点,
|
||
/// 避免伪造输出把 CDP 连接引导到任意地址。
|
||
fn parse_devtools_ws_url(line: &str) -> Option<String> {
|
||
let (_, ws) = line.rsplit_once("listening on ")?;
|
||
let ws = ws.trim();
|
||
if ws.is_empty() || ws.len() > MAX_WS_URL_LEN || !ws.contains("devtools/browser") {
|
||
return None;
|
||
}
|
||
let authority = ws.strip_prefix("ws://")?.split('/').next()?;
|
||
let (host, port) = authority.rsplit_once(':')?;
|
||
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]")
|
||
|| port.is_empty()
|
||
|| !port.bytes().all(|byte| byte.is_ascii_digit())
|
||
{
|
||
return None;
|
||
}
|
||
Some(ws.to_string())
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum OwnedBrowserLaunchError {
|
||
SpawnFailed,
|
||
WsTimeout,
|
||
WsFailed,
|
||
ConnectTimeout,
|
||
ConnectFailed,
|
||
}
|
||
|
||
impl OwnedBrowserLaunchError {
|
||
fn is_timeout(self) -> bool {
|
||
matches!(self, Self::WsTimeout | Self::ConnectTimeout)
|
||
}
|
||
|
||
fn code(self) -> &'static str {
|
||
match self {
|
||
Self::SpawnFailed => "browser-spawn-failed",
|
||
Self::WsTimeout => "browser-ws-timeout",
|
||
Self::WsFailed => "browser-ws-failed",
|
||
Self::ConnectTimeout => "browser-cdp-connect-timeout",
|
||
Self::ConnectFailed => "browser-cdp-connect-failed",
|
||
}
|
||
}
|
||
}
|
||
|
||
type BrowserStderrReader = futures::io::BufReader<BrowserChildStderr>;
|
||
|
||
/// 复刻 chromiumoxide 私有 ws_url_from_output 的语义(读 stderr 直到
|
||
/// DevTools listening 行、提前退出即失败、整体有界),但 root 进程始终
|
||
/// 留在调用方手里,并把 stderr reader 交还给调用方持续排空。
|
||
async fn devtools_ws_url_from_stderr(
|
||
child: &mut BrowserChild,
|
||
timeout: Duration,
|
||
) -> Result<(String, BrowserStderrReader), OwnedBrowserLaunchError> {
|
||
let stderr = child
|
||
.stderr
|
||
.take()
|
||
.ok_or(OwnedBrowserLaunchError::SpawnFailed)?;
|
||
let mut reader = futures::io::BufReader::new(stderr);
|
||
let mut captured: Vec<u8> = Vec::new();
|
||
let mut exited = Box::pin(child.wait()).fuse();
|
||
let read = async {
|
||
loop {
|
||
let mut line = Vec::new();
|
||
futures::select! {
|
||
_status = exited => return Err(OwnedBrowserLaunchError::WsFailed),
|
||
result = reader.read_until(b'\n', &mut line).fuse() => {
|
||
let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?;
|
||
if count == 0 {
|
||
return Err(OwnedBrowserLaunchError::WsFailed);
|
||
}
|
||
captured.extend_from_slice(&line);
|
||
if captured.len() > MAX_LAUNCH_STDERR_BYTES {
|
||
return Err(OwnedBrowserLaunchError::WsFailed);
|
||
}
|
||
let Ok(text) = std::str::from_utf8(&line) else {
|
||
return Err(OwnedBrowserLaunchError::WsFailed);
|
||
};
|
||
if let Some(url) = parse_devtools_ws_url(text) {
|
||
return Ok(url);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
match tokio::time::timeout(timeout, read).await {
|
||
Ok(Ok(url)) => Ok((url, reader)),
|
||
Ok(Err(error)) => Err(error),
|
||
Err(_) => Err(OwnedBrowserLaunchError::WsTimeout),
|
||
}
|
||
}
|
||
|
||
fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> {
|
||
// 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。
|
||
tokio::spawn(async move {
|
||
let mut buffer = Vec::with_capacity(4096);
|
||
loop {
|
||
buffer.clear();
|
||
match reader.read_until(b'\n', &mut buffer).await {
|
||
Ok(0) | Err(_) => break,
|
||
Ok(_) => {}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
/// 自持浏览器进程:root 从 spawn 起归本结构所有;Windows 下整树挂进
|
||
/// KILL_ON_JOB_CLOSE 的 Job——失败路径、遗忘 shutdown、甚至宿主进程
|
||
/// 被杀,整棵树都会被回收,不再留下无父进程的无头浏览器。
|
||
struct BrowserProcessGuard {
|
||
child: BrowserChild,
|
||
#[cfg(windows)]
|
||
job: Option<WindowsProcessJob>,
|
||
}
|
||
|
||
impl BrowserProcessGuard {
|
||
/// 整树收割:Job 终止全树,再 kill+wait root 兜底(Unix 与 Job
|
||
/// 绑定前逸出的极早期子进程依赖 root 死亡的级联退出)。
|
||
async fn reap(&mut self) {
|
||
#[cfg(windows)]
|
||
if let Some(job) = &self.job {
|
||
let _ = job.terminate();
|
||
}
|
||
let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await;
|
||
let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await;
|
||
}
|
||
|
||
async fn confirm_reaped(&self) -> Result<(), String> {
|
||
#[cfg(windows)]
|
||
if let Some(job) = &self.job {
|
||
let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT;
|
||
loop {
|
||
if job
|
||
.is_empty()
|
||
.map_err(|_| "browser-tree-reap-unconfirmed".to_string())?
|
||
{
|
||
return Ok(());
|
||
}
|
||
if Instant::now() >= deadline {
|
||
return Err("browser-tree-reap-unconfirmed".into());
|
||
}
|
||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl Drop for BrowserProcessGuard {
|
||
fn drop(&mut self) {
|
||
// Windows:Job 句柄关闭触发 KILL_ON_JOB_CLOSE 收掉整树;
|
||
// Unix:tokio kill_on_drop 终止 root,子进程随 IPC 断开级联退出。
|
||
}
|
||
}
|
||
|
||
struct OwnedBrowser {
|
||
browser: Browser,
|
||
process: BrowserProcessGuard,
|
||
handler_task: tokio::task::JoinHandle<()>,
|
||
drain_task: tokio::task::JoinHandle<()>,
|
||
}
|
||
|
||
impl OwnedBrowser {
|
||
fn browser(&self) -> &Browser {
|
||
&self.browser
|
||
}
|
||
|
||
/// 优雅收束:CDP close → wait;失败则整树终止并确认收割。
|
||
async fn shutdown(mut self) -> Result<(), String> {
|
||
let close = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.browser.close()).await;
|
||
let waited = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.process.child.wait()).await;
|
||
self.handler_task.abort();
|
||
self.drain_task.abort();
|
||
if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) {
|
||
return Ok(());
|
||
}
|
||
self.process.reap().await;
|
||
self.process.confirm_reaped().await
|
||
}
|
||
}
|
||
|
||
impl Drop for OwnedBrowser {
|
||
fn drop(&mut self) {
|
||
self.handler_task.abort();
|
||
self.drain_task.abort();
|
||
// 进程侧由 BrowserProcessGuard 的 Drop 兜底。
|
||
}
|
||
}
|
||
|
||
/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出
|
||
/// 窗口:绑定前产生的子进程未入 Job,随 root 死亡级联退出;绑定之后
|
||
/// 的一切子进程由 Job 全覆盖。
|
||
async fn launch_owned_browser(
|
||
config: BrowserConfig,
|
||
executable: &std::path::Path,
|
||
temp_root: &std::path::Path,
|
||
) -> Result<OwnedBrowser, OwnedBrowserLaunchError> {
|
||
let child = config
|
||
.launch()
|
||
.map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?;
|
||
// 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。
|
||
#[cfg(windows)]
|
||
let job = match WindowsProcessJob::assign_tokio(&child.inner) {
|
||
Ok(job) => Some(job),
|
||
Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed),
|
||
};
|
||
let mut process = BrowserProcessGuard {
|
||
child,
|
||
#[cfg(windows)]
|
||
job,
|
||
};
|
||
// 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。
|
||
if let Some(pid) = process.child.inner.id() {
|
||
super::sweep::write_browser_process_owner(temp_root, pid, executable);
|
||
}
|
||
let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await
|
||
{
|
||
Ok(pair) => pair,
|
||
Err(error) => {
|
||
process.reap().await;
|
||
return Err(error);
|
||
}
|
||
};
|
||
let drain_task = spawn_stderr_drain(reader);
|
||
let connected = tokio::time::timeout(
|
||
BROWSER_TIMEOUT,
|
||
Browser::connect_with_config(url, handler_config()),
|
||
)
|
||
.await;
|
||
let (browser, mut handler) = match connected {
|
||
Ok(Ok(pair)) => pair,
|
||
Ok(Err(_)) => {
|
||
drain_task.abort();
|
||
process.reap().await;
|
||
return Err(OwnedBrowserLaunchError::ConnectFailed);
|
||
}
|
||
Err(_) => {
|
||
drain_task.abort();
|
||
process.reap().await;
|
||
return Err(OwnedBrowserLaunchError::ConnectTimeout);
|
||
}
|
||
};
|
||
let handler_task = tokio::spawn(async move {
|
||
while let Some(message) = handler.next().await {
|
||
if message.is_err() {
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
Ok(OwnedBrowser {
|
||
browser,
|
||
process,
|
||
handler_task,
|
||
drain_task,
|
||
})
|
||
}
|
||
|
||
/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。
|
||
/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。
|
||
pub(crate) async fn check_browser_health() -> Result<BrowserIdentity, String> {
|
||
let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?;
|
||
let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?;
|
||
let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>")
|
||
.map_err(|_| "browser-config-invalid")?;
|
||
let owned = launch_owned_browser(config, &discovered.executable_path, temporary.path())
|
||
.await
|
||
.map_err(|error| {
|
||
if error.is_timeout() {
|
||
"browser-start-timeout"
|
||
} else {
|
||
"browser-start-failed"
|
||
}
|
||
})?;
|
||
let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await;
|
||
if owned.shutdown().await.is_err() {
|
||
return Err("browser-cleanup-failed".into());
|
||
}
|
||
let version = version
|
||
.map_err(|_| "browser-cdp-timeout")?
|
||
.map_err(|_| "browser-cdp-failed")?;
|
||
if !safe_version_label(&version.product) || !safe_version_label(&version.protocol_version) {
|
||
return Err("browser-version-invalid".into());
|
||
}
|
||
Ok(BrowserIdentity {
|
||
kind: discovered.kind,
|
||
product: version.product,
|
||
protocol_version: version.protocol_version,
|
||
})
|
||
}
|
||
|
||
fn safe_version_label(value: &str) -> bool {
|
||
!value.is_empty()
|
||
&& value.len() <= 128
|
||
&& value
|
||
.bytes()
|
||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'-' | b'_'))
|
||
}
|
||
|
||
pub async fn validate_local_preview_in_browser(
|
||
input: BrowserValidationInput,
|
||
) -> Result<BrowserValidationResult, String> {
|
||
validate_local_preview_in_browser_with_interaction(input, false).await
|
||
}
|
||
|
||
/// Runs the same restricted Chromium validation, optionally adding one bounded,
|
||
/// advisory click on a visible enabled control. The interaction is evidence for
|
||
/// the direct Codex reviewer; it is not a fixed gameplay contract and never
|
||
/// changes the deterministic browser safety checks.
|
||
pub async fn validate_local_preview_in_browser_with_interaction(
|
||
input: BrowserValidationInput,
|
||
advisory_interaction: bool,
|
||
) -> Result<BrowserValidationResult, String> {
|
||
validate_local_preview_in_browser_with_cancellation(input, advisory_interaction, None).await
|
||
}
|
||
|
||
pub(crate) async fn validate_local_preview_in_browser_with_cancellation(
|
||
input: BrowserValidationInput,
|
||
advisory_interaction: bool,
|
||
cancellation: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||
) -> Result<BrowserValidationResult, String> {
|
||
if cancellation
|
||
.as_ref()
|
||
.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire))
|
||
{
|
||
return Err("宿主已停止本轮浏览器验证".into());
|
||
}
|
||
let preview_url = validate_input(&input)?;
|
||
prepare_evidence_root(&input.evidence_root)?;
|
||
let browser_executable = discover_chrome_or_edge()?;
|
||
let browser_temp = create_browser_process_temp_dir()?;
|
||
let proxy_bypass_list = preview_proxy_bypass_list(&preview_url);
|
||
let config = browser_config(
|
||
&browser_executable.executable_path,
|
||
&browser_temp,
|
||
&proxy_bypass_list,
|
||
)?;
|
||
|
||
let owned = launch_owned_browser(
|
||
config,
|
||
&browser_executable.executable_path,
|
||
browser_temp.path(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
if error.is_timeout() {
|
||
"启动浏览器超时".to_string()
|
||
} else {
|
||
format!("启动浏览器失败:{}", error.code())
|
||
}
|
||
})?;
|
||
|
||
let work = run_browser_validation(
|
||
owned.browser(),
|
||
&browser_executable,
|
||
&preview_url,
|
||
&input,
|
||
advisory_interaction,
|
||
);
|
||
let cancelled = async {
|
||
let Some(flag) = cancellation else {
|
||
std::future::pending::<()>().await;
|
||
return;
|
||
};
|
||
while !flag.load(std::sync::atomic::Ordering::Acquire) {
|
||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||
}
|
||
};
|
||
let validation = tokio::select! {
|
||
result=work => result,
|
||
_=cancelled => Err("宿主已停止本轮浏览器验证".to_string()),
|
||
};
|
||
|
||
if owned.shutdown().await.is_err() {
|
||
return Err(
|
||
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(),
|
||
);
|
||
}
|
||
drop(browser_temp);
|
||
let mut result = validation?;
|
||
result.completed_at_unix_ms = unix_time_ms();
|
||
let persisted_result = browser_validation_result_for_report(&result)?;
|
||
write_json_report(&result.evidence.report_path, &persisted_result)?;
|
||
Ok(result)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod health_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn health_launch_uses_isolated_profiles_and_same_restricted_configuration() {
|
||
let first = create_browser_process_temp_dir().unwrap();
|
||
let second = create_browser_process_temp_dir().unwrap();
|
||
let executable = std::env::current_exe().unwrap();
|
||
let first_config = browser_config(&executable, &first, "<-loopback>").unwrap();
|
||
let second_config = browser_config(&executable, &second, "<-loopback>").unwrap();
|
||
assert_ne!(first_config.user_data_dir, second_config.user_data_dir);
|
||
assert!(first_config.user_data_dir.unwrap().is_dir());
|
||
assert!(!safe_version_label("Chrome/123\nTOKEN=secret"));
|
||
assert!(safe_version_label("HeadlessChrome/140.0.1.2"));
|
||
}
|
||
|
||
#[test]
|
||
fn devtools_ws_url_only_accepts_loopback_browser_endpoints() {
|
||
assert_eq!(
|
||
parse_devtools_ws_url(
|
||
"DevTools listening on ws://127.0.0.1:53317/devtools/browser/abc-123\r\n"
|
||
)
|
||
.as_deref(),
|
||
Some("ws://127.0.0.1:53317/devtools/browser/abc-123")
|
||
);
|
||
assert!(parse_devtools_ws_url(
|
||
"DevTools listening on ws://localhost:9222/devtools/browser/x"
|
||
)
|
||
.is_some());
|
||
assert!(
|
||
parse_devtools_ws_url("DevTools listening on ws://[::1]:9222/devtools/browser/x")
|
||
.is_some()
|
||
);
|
||
// 非 loopback、非 ws 协议、非 browser 端点、非法端口一律拒绝。
|
||
assert!(parse_devtools_ws_url(
|
||
"DevTools listening on ws://evil.example.com:9222/devtools/browser/x"
|
||
)
|
||
.is_none());
|
||
assert!(parse_devtools_ws_url(
|
||
"DevTools listening on http://127.0.0.1:9222/devtools/browser/x"
|
||
)
|
||
.is_none());
|
||
assert!(
|
||
parse_devtools_ws_url("DevTools listening on ws://127.0.0.1:9222/devtools/page/x")
|
||
.is_none()
|
||
);
|
||
assert!(parse_devtools_ws_url(
|
||
"DevTools listening on ws://127.0.0.1:notaport/devtools/browser/x"
|
||
)
|
||
.is_none());
|
||
assert!(parse_devtools_ws_url("random browser log line").is_none());
|
||
let overlong = format!(
|
||
"DevTools listening on ws://127.0.0.1:9222/devtools/browser/{}",
|
||
"a".repeat(MAX_WS_URL_LEN)
|
||
);
|
||
assert!(parse_devtools_ws_url(&overlong).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn handler_config_mirrors_browser_config_builder() {
|
||
let config = handler_config();
|
||
assert_eq!(config.request_timeout, BROWSER_TIMEOUT);
|
||
assert!(config.request_intercept);
|
||
assert!(!config.cache_enabled);
|
||
// 其余字段必须与 BrowserConfigBuilder 默认值保持一致。
|
||
assert!(config.ignore_https_errors);
|
||
assert!(config.ignore_invalid_messages);
|
||
assert!(config.viewport.is_none());
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"]
|
||
async fn real_browser_health_checks_can_run_concurrently() {
|
||
let (first, second) = tokio::join!(check_browser_health(), check_browser_health());
|
||
assert!(!first.unwrap().product.is_empty());
|
||
assert!(!second.unwrap().protocol_version.is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires an installed browser; verifies cancellation after the real page is requested and cleanup completes"]
|
||
async fn real_browser_budget_cancellation_closes_an_already_started_validation() {
|
||
let page_requested = std::sync::Arc::new(tokio::sync::Notify::new());
|
||
let observed = std::sync::Arc::clone(&page_requested);
|
||
let app=axum::Router::new().route("/",axum::routing::get(move || {
|
||
let observed=std::sync::Arc::clone(&observed);
|
||
async move { observed.notify_one(); axum::response::Html("<!doctype html><canvas id='game' width='100' height='100'></canvas><script>game.getContext('2d').fillRect(0,0,100,100)</script>") }
|
||
}));
|
||
let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
|
||
.await
|
||
.unwrap();
|
||
let address = listener.local_addr().unwrap();
|
||
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||
let cancellation = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||
let flag = std::sync::Arc::clone(&cancellation);
|
||
let cancel = tokio::spawn(async move {
|
||
tokio::time::timeout(Duration::from_secs(20), page_requested.notified())
|
||
.await
|
||
.expect("real browser requested page");
|
||
flag.store(true, std::sync::atomic::Ordering::Release);
|
||
});
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let result = validate_local_preview_in_browser_with_cancellation(
|
||
BrowserValidationInput {
|
||
url: format!("http://{address}/"),
|
||
viewports: vec![
|
||
crate::browser::BrowserValidationViewport::Desktop,
|
||
crate::browser::BrowserValidationViewport::Mobile,
|
||
],
|
||
expected_text: Vec::new(),
|
||
settle_ms: 4000,
|
||
fail_on_console_error: true,
|
||
playtest_scenario: None,
|
||
evidence_root: temp.path().join("evidence"),
|
||
},
|
||
false,
|
||
Some(cancellation),
|
||
)
|
||
.await;
|
||
cancel.await.unwrap();
|
||
server.abort();
|
||
let _ = server.await;
|
||
assert_eq!(result.unwrap_err(), "宿主已停止本轮浏览器验证");
|
||
}
|
||
}
|