9371cc537a
拆分 tool-plan 持久交接的模型、校验、发现和跨平台存储模块。 拆分旧生成链的提示、画布、产物、生命周期和追踪模块。 拆分 Swarm CLI 的输入、观察、终态判定、报告与测试模块。 拆分浏览器发现、CDP、网络策略、试玩、证据与测试模块。 补充兼容可见性、集成验证和第三轮拆分文档。
278 lines
10 KiB
Rust
278 lines
10 KiB
Rust
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,
|
||
})
|
||
}
|