Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs
T
kdletters bdf7ed288a 放宽直连游戏验收并增加运行时素材证据
- 将固定四切片与固定 drawImage 降为推荐路径

- 在导航前注入 Canvas/WebGL 平台素材运行时观测

- 对缺少核心素材渲染证据的 desktop/mobile 视口回灌同一 Codex thread 整改

- 补充直连验收、浏览器脚本与真实 Chrome fixture 测试

- 同步 AGC 直连 Runtime 实施计划
2026-08-18 13:14:45 +08:00

356 lines
13 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, 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()
}