bdf7ed288a
- 将固定四切片与固定 drawImage 降为推荐路径 - 在导航前注入 Canvas/WebGL 平台素材运行时观测 - 对缺少核心素材渲染证据的 desktop/mobile 视口回灌同一 Codex thread 整改 - 补充直连验收、浏览器脚本与真实 Chrome fixture 测试 - 同步 AGC 直连 Runtime 实施计划
356 lines
13 KiB
Rust
356 lines
13 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, RENDERED_IMAGE_CAPTURE_SCRIPT,
|
||
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,
|
||
advisory_interaction: bool,
|
||
) -> 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, advisory_interaction).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,
|
||
advisory_interaction: bool,
|
||
) -> 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}"))?;
|
||
page.evaluate_on_new_document(RENDERED_IMAGE_CAPTURE_SCRIPT)
|
||
.await
|
||
.map_err(|error| {
|
||
format!(
|
||
"安装 {} 平台素材渲染观察失败:{error}",
|
||
viewport.file_stem()
|
||
)
|
||
})?;
|
||
|
||
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 advisory_interaction_diagnostic = if advisory_interaction {
|
||
Some(run_advisory_interaction_probe(&page).await)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
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());
|
||
}
|
||
if !snapshot.canvas_or_webgl_image_paths.is_empty() {
|
||
diagnostics.push(format!(
|
||
"[advisory] rendered-local-images:{}",
|
||
snapshot.canvas_or_webgl_image_paths.join("|")
|
||
));
|
||
}
|
||
if let Some(diagnostic) = advisory_interaction_diagnostic {
|
||
diagnostics.push(format!("[advisory] {diagnostic}"));
|
||
}
|
||
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
|
||
.iter()
|
||
.all(|diagnostic| diagnostic.starts_with("[advisory]")),
|
||
diagnostics,
|
||
},
|
||
playtest,
|
||
})
|
||
}
|
||
|
||
async fn run_advisory_interaction_probe(page: &chromiumoxide::Page) -> String {
|
||
const PROBE: &str = r#"function() {
|
||
const candidates = Array.from(document.querySelectorAll(
|
||
'button:not([disabled]), a[href], input[type="button"]:not([disabled]), input[type="submit"]:not([disabled])'
|
||
));
|
||
const item = candidates.find((element) => {
|
||
const style = getComputedStyle(element);
|
||
const rect = element.getBoundingClientRect();
|
||
return style.display !== 'none' && style.visibility !== 'hidden' &&
|
||
rect.width > 0 && rect.height > 0 &&
|
||
element.getAttribute('aria-disabled') !== 'true';
|
||
});
|
||
if (!item) return JSON.stringify({ ok: false, reason: 'no-visible-enabled-control' });
|
||
item.scrollIntoView({ block: 'center', inline: 'center' });
|
||
return JSON.stringify({ ok: true, tag: item.tagName.toLowerCase() });
|
||
}"#;
|
||
let mut elements = match page.find_elements(
|
||
"button:not([disabled]), a[href], input[type=button]:not([disabled]), input[type=submit]:not([disabled])",
|
||
)
|
||
.await
|
||
{
|
||
Ok(elements) => elements,
|
||
Err(_) => return "真实交互探针无法查询可见控件".to_string(),
|
||
};
|
||
for element in elements.drain(..).take(8) {
|
||
let evaluated = match element.call_js_fn(PROBE, false).await {
|
||
Ok(value) => value,
|
||
Err(_) => continue,
|
||
};
|
||
let Some(value) = evaluated.result.value else {
|
||
continue;
|
||
};
|
||
let Some(text) = value.as_str() else {
|
||
continue;
|
||
};
|
||
let Ok(probe) = serde_json::from_str::<serde_json::Value>(text) else {
|
||
continue;
|
||
};
|
||
if probe.get("ok").and_then(serde_json::Value::as_bool) != Some(true) {
|
||
continue;
|
||
}
|
||
return match element.click().await {
|
||
Ok(_) => "真实交互探针已点击一个可见启用控件".to_string(),
|
||
Err(_) => "真实交互探针发现控件但点击未完成".to_string(),
|
||
};
|
||
}
|
||
"真实交互探针未发现可见启用控件".to_string()
|
||
}
|