Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs
T
AIGameCreator App 9371cc537a 并行拆分客户端生成与验收大型模块
拆分 tool-plan 持久交接的模型、校验、发现和跨平台存储模块。

拆分旧生成链的提示、画布、产物、生命周期和追踪模块。

拆分 Swarm CLI 的输入、观察、终态判定、报告与测试模块。

拆分浏览器发现、CDP、网络策略、试玩、证据与测试模块。

补充兼容可见性、集成验证和第三轮拆分文档。
2026-07-22 18:28:19 +08:00

278 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::time::Duration;
use chromiumoxide::browser::Browser;
use chromiumoxide::cdp::browser_protocol::browser::{
SetDownloadBehaviorBehavior, SetDownloadBehaviorParams,
};
use chromiumoxide::cdp::browser_protocol::emulation::{
SetDeviceMetricsOverrideParams, SetTouchEmulationEnabledParams,
};
use chromiumoxide::cdp::browser_protocol::network::SetBypassServiceWorkerParams;
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use chromiumoxide::page::ScreenshotParams;
use url::Url;
use super::capture::{
build_snapshot_script, canvas_validation_diagnostic, sanitize_url, start_capture_tasks,
truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RESTRICTION_SCRIPT,
};
use super::evidence::write_atomic;
use super::model::{
BrowserExpectedTextMatch, BrowserIdentity, BrowserPlaytestResult,
BrowserValidationEvidencePaths, BrowserValidationInput, BrowserValidationResult,
BrowserValidationViewport, BrowserViewportValidationResult, DiscoveredBrowser, BROWSER_TIMEOUT,
REQUIRED_VIEWPORTS, RESULT_SCHEMA_VERSION,
};
use super::network_policy::same_preview_origin;
use super::playtest::run_desktop_playtest;
struct BrowserViewportValidationOutcome {
result: BrowserViewportValidationResult,
playtest: Option<BrowserPlaytestResult>,
}
pub(super) async fn run_browser_validation(
browser: &Browser,
discovered: &DiscoveredBrowser,
preview_url: &Url,
input: &BrowserValidationInput,
) -> Result<BrowserValidationResult, String> {
browser
.execute(SetDownloadBehaviorParams::new(
SetDownloadBehaviorBehavior::Deny,
))
.await
.map_err(|error| format!("禁用浏览器下载失败:{error}"))?;
let version = browser
.version()
.await
.map_err(|error| format!("读取浏览器版本失败:{error}"))?;
let mut viewport_results = Vec::with_capacity(REQUIRED_VIEWPORTS.len());
let mut playtest = None;
for viewport in REQUIRED_VIEWPORTS {
let outcome = validate_viewport(browser, preview_url, input, viewport).await?;
if outcome.playtest.is_some() {
playtest = outcome.playtest;
}
viewport_results.push(outcome.result);
}
let mut diagnostics = viewport_results
.iter()
.flat_map(|result| {
result
.diagnostics
.iter()
.map(move |message| format!("{}: {message}", result.viewport.file_stem()))
})
.collect::<Vec<_>>();
let playtest_passed = match (&input.playtest_scenario, &playtest) {
(None, None) => true,
(Some(_), Some(result)) => {
if !result.passed {
diagnostics.extend(
result
.diagnostics
.iter()
.map(|message| format!("playtest: {message}")),
);
}
result.passed
}
(Some(_), None) => {
diagnostics.push("playtest: desktop 试玩结果缺失".to_string());
false
}
(None, Some(_)) => {
diagnostics.push("playtest: 未请求试玩却产生了试玩结果".to_string());
false
}
};
let passed = viewport_results.iter().all(|result| result.passed) && playtest_passed;
let report_path = input.evidence_root.join("validation.json");
Ok(BrowserValidationResult {
schema_version: RESULT_SCHEMA_VERSION.to_string(),
url: preview_url.as_str().to_string(),
browser: BrowserIdentity {
kind: discovered.kind,
product: version.product,
protocol_version: version.protocol_version,
},
passed,
viewport_results,
playtest,
diagnostics,
evidence: BrowserValidationEvidencePaths {
root: input.evidence_root.clone(),
report_path,
},
completed_at_unix_ms: 0,
})
}
async fn validate_viewport(
browser: &Browser,
preview_url: &Url,
input: &BrowserValidationInput,
viewport: BrowserValidationViewport,
) -> Result<BrowserViewportValidationOutcome, String> {
let (width, height, mobile) = viewport.dimensions();
let page = browser
.new_page("about:blank")
.await
.map_err(|error| format!("创建 {} 页面失败:{error}", viewport.file_stem()))?;
page.execute(SetDeviceMetricsOverrideParams::new(
i64::from(width),
i64::from(height),
1.0,
mobile,
))
.await
.map_err(|error| format!("设置 {} 视口失败:{error}", viewport.file_stem()))?;
page.execute(SetTouchEmulationEnabledParams::new(mobile))
.await
.map_err(|error| format!("设置触摸模拟失败:{error}"))?;
page.execute(SetBypassServiceWorkerParams::new(true))
.await
.map_err(|error| format!("绕过 Service Worker 失败:{error}"))?;
page.evaluate_on_new_document(RESTRICTION_SCRIPT)
.await
.map_err(|error| format!("安装浏览器限制脚本失败:{error}"))?;
let tasks = start_capture_tasks(&page, preview_url).await?;
let navigation = tokio::time::timeout(BROWSER_TIMEOUT, page.goto(preview_url.as_str()))
.await
.map_err(|_| format!("{} 页面导航超时", viewport.file_stem()))?;
if let Err(error) = navigation {
let _ = tasks.stop().await;
let _ = page.close().await;
return Err(format!("{} 页面导航失败:{error}", viewport.file_stem()));
}
tokio::time::sleep(Duration::from_millis(input.settle_ms)).await;
let playtest = if viewport == BrowserValidationViewport::Desktop {
match input.playtest_scenario {
Some(scenario) => Some(run_desktop_playtest(&page, scenario).await),
None => None,
}
} else {
None
};
let snapshot_script = build_snapshot_script(&input.expected_text)?;
let snapshot: PageSnapshot = page
.evaluate(snapshot_script)
.await
.map_err(|error| format!("采集 {} 页面状态失败:{error}", viewport.file_stem()))?
.into_value()
.map_err(|error| format!("解析 {} 页面状态失败:{error}", viewport.file_stem()))?;
let screenshot = page
.screenshot(
ScreenshotParams::builder()
.format(CaptureScreenshotFormat::Png)
.full_page(false)
.capture_beyond_viewport(false)
.build(),
)
.await
.map_err(|error| format!("采集 {} PNG 失败:{error}", viewport.file_stem()))?;
if !screenshot.starts_with(b"\x89PNG\r\n\x1a\n") {
return Err(format!("{} 截图不是有效 PNG", viewport.file_stem()));
}
let screenshot_path = input
.evidence_root
.join(format!("{}.png", viewport.file_stem()));
write_atomic(&screenshot_path, &screenshot)?;
tokio::task::yield_now().await;
let capture = tasks.stop().await?;
let _ = page.close().await;
if !capture.infrastructure_errors.is_empty() {
return Err(format!(
"浏览器安全拦截失败:{}",
capture.infrastructure_errors.join(";")
));
}
let expected_text = input
.expected_text
.iter()
.enumerate()
.map(|(index, text)| BrowserExpectedTextMatch {
text: text.clone(),
found: snapshot
.expected_text_matches
.get(index)
.copied()
.unwrap_or(false),
})
.collect::<Vec<_>>();
let mut diagnostics = Vec::new();
if snapshot.ready_state != "complete" {
diagnostics.push(format!("document.readyState={}", snapshot.ready_state));
}
let missing_text = expected_text
.iter()
.filter(|item| !item.found)
.map(|item| item.text.as_str())
.collect::<Vec<_>>();
if !missing_text.is_empty() {
diagnostics.push(format!("缺少可见文本:{}", missing_text.join("、")));
}
if input.fail_on_console_error && !capture.console_errors.is_empty() {
diagnostics.push(format!("console error {} 条", capture.console_errors.len()));
}
if !capture.exceptions.is_empty() {
diagnostics.push(format!("未捕获异常 {} 条", capture.exceptions.len()));
}
let fatal_request_count = capture
.failed_requests
.iter()
.filter(|request| request.fatal)
.count();
if fatal_request_count > 0 {
diagnostics.push(format!("失败请求 {} 条", fatal_request_count));
}
if !same_preview_origin(&snapshot.final_url, preview_url) {
diagnostics.push("页面最终 URL 已离开当前预览 origin".to_string());
}
let canvases = snapshot
.canvases
.into_iter()
.map(BrowserCanvasSnapshot::into_evidence)
.collect::<Vec<_>>();
if let Some(diagnostic) = canvas_validation_diagnostic(&canvases) {
diagnostics.push(diagnostic.to_string());
}
Ok(BrowserViewportValidationOutcome {
result: BrowserViewportValidationResult {
viewport,
width,
height,
final_url: sanitize_url(&snapshot.final_url),
title: truncate_chars(&snapshot.title, 512),
ready_state: snapshot.ready_state,
visible_text_summary: snapshot.visible_text_summary,
visible_text_character_count: snapshot.visible_text_character_count,
dom_character_count: snapshot.dom_character_count,
expected_text,
console_errors: capture.console_errors,
console_warnings: capture.console_warnings,
exceptions: capture.exceptions,
failed_requests: capture.failed_requests,
canvases,
blocked_popup_count: snapshot.blocked_popup_count,
blocked_dialog_count: capture.blocked_dialog_count,
blocked_download_count: snapshot.blocked_download_count,
blocked_permission_count: snapshot.blocked_permission_count,
blocked_service_worker_count: snapshot.blocked_service_worker_count,
screenshot_path,
passed: diagnostics.is_empty(),
diagnostics,
},
playtest,
})
}