Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
//! observe 集成测试共享接线(`#[ignore]`,本机/打包 chrome)。
|
||||
//!
|
||||
//! 任务 5 的契约母本(launch→connect→run_attach_loop→enable_auto_attach→createTarget→arm→
|
||||
//! call)已被复制过一次;任务 6 起所有 observe 集成测试**复用**本模块的 helper,勿再复制母本。
|
||||
//!
|
||||
//! 与契约母本(`observe_fixtures.rs::observe_inject_contract_iframe`)的关系:那个测试手动接线
|
||||
//! 注入侧的 `incrementalAriaSnapshot` 单帧调用,验证**注入契约**(aria 形态快照);本模块走更高层
|
||||
//! ——直接建一个 [`CdpBackend`] 并 navigate 到 fixture,让被测对象就是 `engine.observe()` 全链
|
||||
//! (逐帧缝合 + 脱敏 + 代际翻新 ref 表)。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=...\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(observe_)'
|
||||
//! 首跑写 `.snap.new`;`cargo insta accept`(或手动改名 .snap)接受为基线。
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。
|
||||
|
||||
#![allow(dead_code)] // 不同集成测试文件用到的 helper 子集不同;未用项不报警。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomi_browser_engine::backend::CdpBackend;
|
||||
use nomi_browser_engine::firewall::{EgressApprover, FirewallConfig, HostResolver};
|
||||
use nomi_browser_engine::launch::{launch_chrome, LaunchConfig};
|
||||
|
||||
/// fixture 的 file:// URL。`file://` + 单个前导斜杠 + POSIX 路径:`CARGO_MANIFEST_DIR`
|
||||
/// 在 unix 是 `/abs`(已带前导斜杠)、在 windows 是 `C:/abs`(需补一个),故仅在缺失时补斜杠。
|
||||
/// 旧写法 `file:///{manifest}` 在 unix 上产生**四**斜杠(`file:////Users/...`)——chrome 容忍但
|
||||
/// 回报时归一成三斜杠,导致 navigate 的 redirect 判定(请求 url != final url)误报 redirected。
|
||||
pub fn fixture_url(name: &str) -> String {
|
||||
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
|
||||
let abs = if manifest.starts_with('/') {
|
||||
manifest
|
||||
} else {
|
||||
format!("/{manifest}")
|
||||
};
|
||||
format!("file://{abs}/tests/fixtures/{name}")
|
||||
}
|
||||
|
||||
/// 解析 chrome(env NOMIFUN_CHROME_BINARY > 打包 > 数据目录 > 下载兜底)+ 托管 launch(headless)
|
||||
/// + 自建 transport connect + flatten auto-attach + 取一个 page session,建一个 [`CdpBackend`]。
|
||||
///
|
||||
/// `profile`:本测试专属 user-data-dir 后缀,避免并发测试争用同一 profile。
|
||||
/// 返回的 backend 持有 chrome 进程句柄——Drop 即清理整棵进程树(kill_on_drop);测试结束自然回收。
|
||||
///
|
||||
/// 不挂下载沙箱(`download_dir=None`)——下载相关集成测试用
|
||||
/// [`build_backend_for_fixture_with_downloads`]。evaluate 全权 OFF(默认 default-deny)。
|
||||
/// 防火墙用 `FirewallConfig::default()`(IP 封禁开 + 跨域 POST 门控开 = 现行为)。
|
||||
pub async fn build_backend_for_fixture(profile: &str) -> CdpBackend {
|
||||
build_backend_for_fixture_inner(profile, None, false, false, FirewallConfig::default(), None, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 同 [`build_backend_for_fixture`],但 **evaluate 全权模式 ON**(`EngineConfig.evaluate_full_power
|
||||
/// = true` 的等价 test seam)——F1-sec 验「全权 LIVE 开 → act(Evaluate) 放行」。
|
||||
pub async fn build_backend_for_fixture_full_power(profile: &str) -> CdpBackend {
|
||||
build_backend_for_fixture_inner(profile, None, true, false, FirewallConfig::default(), None, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// **SD-6 persistent-login mutex 验证 test seam**:`evaluate_full_power = true` +
|
||||
/// `evaluate_persistent_login` = 调用方指定。用于验证 persistent-login LIVE 值穿透到
|
||||
/// `EvaluateGate.persistent_login`——`persistent_login=true` 时互斥生效(full_power+persistent
|
||||
/// → Blocked);`persistent_login=false` 时全权正常放行(控制组)。
|
||||
pub async fn build_backend_for_fixture_persistent_login(
|
||||
profile: &str,
|
||||
persistent_login: bool,
|
||||
) -> CdpBackend {
|
||||
build_backend_for_fixture_inner(
|
||||
profile,
|
||||
None,
|
||||
true, // evaluate_full_power = ON (to test the mutex)
|
||||
persistent_login,
|
||||
FirewallConfig::default(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// **W4d 持久登录验证 test seam**:同 [`build_backend_for_fixture`],但额外注入
|
||||
/// `storage_state`(上层从 vault `load_storage_state` 解出的登录态 JSON)——引擎在 page
|
||||
/// 建好后 `restore_cookies`(+`restore_local_storage`)**启动注入**灌登录态。配合 W4d 集成测试验
|
||||
/// 「跨引擎/会话持久登录」(会话 A capture+save vault → 会话 B 新引擎 load vault → 此 seam 注入 →
|
||||
/// 登录态恢复存活)。`storage_state=None` 走零注入(现行为)。
|
||||
///
|
||||
/// Always runs on the default browser context (no per-pet isolation).
|
||||
pub async fn build_backend_for_fixture_with_storage_state(
|
||||
profile: &str,
|
||||
storage_state: Option<serde_json::Value>,
|
||||
) -> CdpBackend {
|
||||
build_backend_for_fixture_inner(
|
||||
profile,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
FirewallConfig::default(),
|
||||
None,
|
||||
storage_state,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// **P3-G1 注入链验证 test seam**:同 [`build_backend_for_fixture`],但注入一个**自定义**
|
||||
/// [`FirewallConfig`](而非 `default()`)。配合 [`CdpBackend::firewall_config_for_test`] 断言
|
||||
/// 「自定义配置经 from_launched 真的注入到引擎、未被硬编码 default 吞掉」。
|
||||
pub async fn build_backend_for_fixture_with_firewall(
|
||||
profile: &str,
|
||||
firewall: FirewallConfig,
|
||||
) -> CdpBackend {
|
||||
build_backend_for_fixture_inner(profile, None, false, false, firewall, None, None, None).await
|
||||
}
|
||||
|
||||
/// **P3-D2 验证 test seam**:注入自定义 [`FirewallConfig`] **+ 一个出口审批通道**
|
||||
/// ([`EgressApprover`])。配合跨域 POST fixture 验「被门控请求悬挂等裁决 → 批准 continue / 拒绝
|
||||
/// fail / 超时 / 无通道 fail-closed」。`egress_approver=None` 走引擎 fail-closed 默认(验泄漏窗口闭合)。
|
||||
///
|
||||
/// **SD-1 交互**:`dns_resolver` 注入式——跨域 POST 的目标域要先过 DNS→IP SSRF 守卫才到 approver。
|
||||
/// 测试须注入一个把探针域映射到**公网 IP** 的 fake resolver,否则真实 DNS 对离线伪域返 NXDOMAIN →
|
||||
/// 守卫 fail-closed 在 approver **之前**就 Block(approver 永不被咨询)。`None` = 默认 TokioResolver。
|
||||
pub async fn build_backend_for_fixture_with_egress(
|
||||
profile: &str,
|
||||
firewall: FirewallConfig,
|
||||
egress_approver: Option<Arc<dyn EgressApprover>>,
|
||||
dns_resolver: Option<Arc<dyn HostResolver>>,
|
||||
) -> CdpBackend {
|
||||
build_backend_for_fixture_inner(profile, None, false, false, firewall, egress_approver, None, dns_resolver).await
|
||||
}
|
||||
|
||||
/// 同 [`build_backend_for_fixture`],但挂 E4 下载沙箱(`Browser.setDownloadBehavior(allowAndName)`
|
||||
/// 落 `download_dir` + 下载事件循环打 MOTW)。返回 `(backend, download_dir)`——下载测试用 download_dir
|
||||
/// 校验落盘文件 + Zone.Identifier ADS。
|
||||
pub async fn build_backend_for_fixture_with_downloads(
|
||||
profile: &str,
|
||||
) -> (CdpBackend, std::path::PathBuf) {
|
||||
let download_dir = std::env::temp_dir().join(format!("nomifun-dl-{profile}-downloads"));
|
||||
// 干净起点:清旧目录残留(上次跑的 GUID 文件),再建。
|
||||
let _ = std::fs::remove_dir_all(&download_dir);
|
||||
std::fs::create_dir_all(&download_dir).expect("create download dir");
|
||||
let backend = build_backend_for_fixture_inner(
|
||||
profile,
|
||||
Some(download_dir.to_string_lossy().into_owned()),
|
||||
false,
|
||||
false,
|
||||
FirewallConfig::default(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
(backend, download_dir)
|
||||
}
|
||||
|
||||
/// **单标签验证 test seam(headful)**:同 [`build_backend_for_fixture`] 但 **headful**
|
||||
/// (`force_headless=false`)——验 `--no-startup-window` 在 headful 下被 REMOTE_DEBUGGING
|
||||
/// keep-alive 拴住(chrome 不无窗口自退、launch 不报 "exited before DevTools port")+ 恰好
|
||||
/// 一个受控 page。会开一个**可见** chrome 窗口(需本机有显示器;本机 Windows 有)。
|
||||
pub async fn build_backend_for_fixture_headful(profile: &str) -> CdpBackend {
|
||||
let chrome = nomi_browser_engine::acquire::resolve_chrome_path(
|
||||
&std::env::temp_dir().join("nomifun-browser-data"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("resolve chrome (set NOMIFUN_CHROME_BINARY)");
|
||||
let cfg = LaunchConfig {
|
||||
chrome_path: chrome,
|
||||
user_data_dir: std::env::temp_dir().join(format!("nomifun-observe-{profile}-profile")),
|
||||
headful: true,
|
||||
};
|
||||
// force_headless=false → headful(带可见窗口)。这是 --no-startup-window keep-alive 的风险路径。
|
||||
let launched = launch_chrome(&cfg, false).await.expect("launch headful chrome");
|
||||
CdpBackend::from_launched(
|
||||
launched,
|
||||
true, // headful
|
||||
true, // display_available
|
||||
None,
|
||||
None, // workspace_dir: headful fixture 不上传
|
||||
false,
|
||||
false, // evaluate_persistent_login
|
||||
FirewallConfig::default(),
|
||||
None,
|
||||
None,
|
||||
nomi_browser_engine::KnownSecretValues::default(),
|
||||
None, // dns_resolver: 默认 TokioResolver
|
||||
)
|
||||
.await
|
||||
.expect("build CdpBackend (headful)")
|
||||
}
|
||||
|
||||
/// 同 [`build_backend_for_fixture_headful`] 但注入自定义 [`FirewallConfig`]。OOPIF 验证用:
|
||||
/// **headful = 真浏览器进程模型**,site-isolation 才会把跨站 iframe 起成**独立渲染进程(OOPIF)**;
|
||||
/// `--headless=new` 是单渲染进程,不起 OOPIF。配合 `NOMIFUN_CHROME_BINARY` + 测试侧 `NOMI_CHROME_EXTRA_ARGS`
|
||||
/// (--host-resolver-rules/--site-per-process)使用。会开一个**可见** chrome 窗口(需本机有显示器)。
|
||||
pub async fn build_backend_for_fixture_headful_with_firewall(
|
||||
profile: &str,
|
||||
firewall: FirewallConfig,
|
||||
) -> CdpBackend {
|
||||
let chrome = nomi_browser_engine::acquire::resolve_chrome_path(
|
||||
&std::env::temp_dir().join("nomifun-browser-data"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("resolve chrome (set NOMIFUN_CHROME_BINARY)");
|
||||
let cfg = LaunchConfig {
|
||||
chrome_path: chrome,
|
||||
user_data_dir: std::env::temp_dir().join(format!("nomifun-observe-{profile}-profile")),
|
||||
headful: true,
|
||||
};
|
||||
let launched = launch_chrome(&cfg, false).await.expect("launch headful chrome");
|
||||
CdpBackend::from_launched(launched, true, true, None, None, false, false, firewall, None, None, nomi_browser_engine::KnownSecretValues::default(), None)
|
||||
.await
|
||||
.expect("build CdpBackend (headful+firewall)")
|
||||
}
|
||||
|
||||
/// **Headful + 下载沙箱 test seam**(Task 6 / `TODO(verify-headful-printToPDF)`):同
|
||||
/// [`build_backend_for_fixture_headful`](headful,真可见窗口)但额外挂 E4 下载沙箱
|
||||
/// (`download_dir`)。专供 **save_as_pdf 的 headful 校验**——验 `Page.printToPDF` 在 headful
|
||||
/// Chrome 下是否仍产非空 PDF(headful 历史上对 printToPDF 有限制)。返回 `(backend, download_dir)`。
|
||||
/// 会开一个**可见** chrome 窗口(需本机有显示器)。
|
||||
pub async fn build_backend_for_fixture_headful_with_downloads(
|
||||
profile: &str,
|
||||
) -> (CdpBackend, std::path::PathBuf) {
|
||||
let download_dir =
|
||||
std::env::temp_dir().join(format!("nomifun-dl-{profile}-headful-downloads"));
|
||||
// 干净起点:清旧目录残留,再建。
|
||||
let _ = std::fs::remove_dir_all(&download_dir);
|
||||
std::fs::create_dir_all(&download_dir).expect("create download dir");
|
||||
let chrome = nomi_browser_engine::acquire::resolve_chrome_path(
|
||||
&std::env::temp_dir().join("nomifun-browser-data"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("resolve chrome (set NOMIFUN_CHROME_BINARY)");
|
||||
let cfg = LaunchConfig {
|
||||
chrome_path: chrome,
|
||||
user_data_dir: std::env::temp_dir().join(format!("nomifun-observe-{profile}-profile")),
|
||||
headful: true,
|
||||
};
|
||||
let launched = launch_chrome(&cfg, false).await.expect("launch headful chrome");
|
||||
let backend = CdpBackend::from_launched(
|
||||
launched,
|
||||
true, // headful
|
||||
true, // display_available
|
||||
Some(download_dir.to_string_lossy().into_owned()),
|
||||
None, // workspace_dir:save_as_pdf 不依赖上传沙箱
|
||||
false,
|
||||
false, // evaluate_persistent_login
|
||||
FirewallConfig::default(),
|
||||
None,
|
||||
None,
|
||||
nomi_browser_engine::KnownSecretValues::default(),
|
||||
None, // dns_resolver: 默认 TokioResolver
|
||||
)
|
||||
.await
|
||||
.expect("build CdpBackend (headful+downloads)");
|
||||
(backend, download_dir)
|
||||
}
|
||||
|
||||
// 构造参数随安全配置增长(download/full_power/persistent/firewall/egress/storage/dns_resolver);
|
||||
// 与 build_backend / from_launched 同源的已知取舍(见 cdp.rs 的 too_many_arguments allow + EngineRuntimeParams TODO)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_backend_for_fixture_inner(
|
||||
profile: &str,
|
||||
download_dir: Option<String>,
|
||||
evaluate_full_power: bool,
|
||||
evaluate_persistent_login: bool,
|
||||
firewall: FirewallConfig,
|
||||
egress_approver: Option<Arc<dyn EgressApprover>>,
|
||||
storage_state: Option<serde_json::Value>,
|
||||
dns_resolver: Option<Arc<dyn HostResolver>>,
|
||||
) -> CdpBackend {
|
||||
let chrome = nomi_browser_engine::acquire::resolve_chrome_path(
|
||||
&std::env::temp_dir().join("nomifun-browser-data"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("resolve chrome (set NOMIFUN_CHROME_BINARY)");
|
||||
let cfg = LaunchConfig {
|
||||
chrome_path: chrome,
|
||||
user_data_dir: std::env::temp_dir().join(format!("nomifun-observe-{profile}-profile")),
|
||||
headful: false,
|
||||
};
|
||||
let launched = launch_chrome(&cfg, true).await.expect("launch chrome");
|
||||
// SD-2:fixture 测试默认给 temp_dir 作为 workspace(上传沙箱需要一个根;实际产品环境是 per-pet
|
||||
// workspace,此处用 temp_dir 让 temp 下创建的测试文件都能通过沙箱校验)。
|
||||
let workspace_dir = Some(std::env::temp_dir());
|
||||
// headful=false / display=false:observe 不依赖显示,capabilities 字段对集成断言无关紧要。
|
||||
CdpBackend::from_launched(
|
||||
launched,
|
||||
false,
|
||||
false,
|
||||
download_dir,
|
||||
workspace_dir,
|
||||
evaluate_full_power,
|
||||
evaluate_persistent_login,
|
||||
firewall,
|
||||
egress_approver,
|
||||
storage_state,
|
||||
nomi_browser_engine::KnownSecretValues::default(),
|
||||
dns_resolver,
|
||||
)
|
||||
.await
|
||||
.expect("build CdpBackend")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>C1 click/type/set_value</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; font: 16px sans-serif; }
|
||||
/* fixed 定位让元素稳定在视口内(C1 走真实 getContentQuads,非硬编码点)。 */
|
||||
.row { position: relative; margin: 8px; }
|
||||
#name { display: block; width: 240px; height: 28px; }
|
||||
#notes { display: block; width: 240px; height: 60px; }
|
||||
#toggle { display: block; width: 200px; height: 36px; }
|
||||
/* F2 元素 fixed 钉在视口右上角——永远在视口内(C1 走真实 getContentQuads),
|
||||
且不挤占文档流,不会把 #submit 顶出视口。 */
|
||||
#agree-row { position: fixed; top: 8px; right: 120px; }
|
||||
#noop { position: fixed; top: 8px; right: 8px; width: 100px; height: 28px; }
|
||||
form#login { margin: 8px; }
|
||||
#login input { display: block; width: 240px; height: 28px; margin: 4px 0; }
|
||||
#login button { width: 200px; height: 36px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>C1 actions</h1>
|
||||
|
||||
<!-- type: 文本输入,type 后读回 value 验证 -->
|
||||
<div class="row"><label>Name <input id="name" type="text"></label></div>
|
||||
|
||||
<!-- set_value: textarea,直接设值后读回 -->
|
||||
<div class="row"><label>Notes <textarea id="notes"></textarea></label></div>
|
||||
|
||||
<!-- click: 按钮点击改自身文案,验证点击真分发到元素 -->
|
||||
<div class="row"><button id="toggle">Not clicked</button></div>
|
||||
|
||||
<!-- 登录表单(验收点):user/pass input + submit button;submit 时打标记(不真导航,preventDefault)。 -->
|
||||
<form id="login" action="javascript:void(0)">
|
||||
<label>Username <input id="username" name="username" type="text" autocomplete="username"></label>
|
||||
<label>Password <input id="password" name="password" type="password" autocomplete="current-password"></label>
|
||||
<button id="submit" type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
<!-- 登录提交后的可见标记(aria 可观测:role=status)。 -->
|
||||
<div id="login-status" role="status" aria-label="login status">idle</div>
|
||||
|
||||
<!-- F2 富锚点(fixed 钉视口右上角,永在视口内、不挤占文档流、不顶出 #submit):
|
||||
checkbox 点击切换 checked(URL 不变也能经元素态锚点判 changed=true);
|
||||
#noop 点击不改态不导航(changed 应如实 false)。 -->
|
||||
<div id="agree-row"><label><input id="agree" type="checkbox"> I agree</label></div>
|
||||
<button id="noop" type="button">No-op</button>
|
||||
|
||||
<script>
|
||||
document.getElementById('toggle').addEventListener('click', function () {
|
||||
this.textContent = 'Clicked';
|
||||
});
|
||||
document.getElementById('login').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var u = document.getElementById('username').value;
|
||||
// 把提交时捕获的用户名写进可见标记(证明 type 真写入 + click submit 真触发);
|
||||
// 不回显 password 值(与脱敏精神一致——这里只为证明表单态变)。
|
||||
document.getElementById('login-status').textContent = 'submitted:' + u;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<!doctype html><html><body>
|
||||
<h1>Actionability</h1>
|
||||
<button>Submit order</button>
|
||||
<button disabled>Disabled action</button>
|
||||
<label>Email <input type="text"></label>
|
||||
<label>Locked <input type="text" value="locked" readonly></label>
|
||||
<div style="display:none"><button>Hidden action</button></div>
|
||||
<p>Just some static prose, not editable.</p>
|
||||
</body></html>
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>C2 hover/select/press_key/scroll/scroll_to_text</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; font: 16px sans-serif; }
|
||||
.row { margin: 8px; }
|
||||
/* hover 目标:onmouseover 时打标记 + :hover 改色(验证 hover 真投递)。 */
|
||||
#hovertarget { display: inline-block; width: 200px; height: 40px; line-height: 40px;
|
||||
text-align: center; background: #ddd; }
|
||||
#hovertarget:hover { background: #8cf; }
|
||||
/* select 目标。 */
|
||||
#picker { display: block; width: 240px; height: 28px; }
|
||||
/* press_key 目标:表单里的输入框,Enter 触发 submit 打标记。 */
|
||||
form#search { margin: 8px; }
|
||||
#q { display: block; width: 240px; height: 28px; }
|
||||
/* 长页:spacer 撑高,底部有可滚到的文本 + 元素,验证 scroll / scroll_to_text。 */
|
||||
#spacer { height: 3000px; background: linear-gradient(#fff, #eee); }
|
||||
#bottom { padding: 24px; background: #cfc; }
|
||||
#bottomtarget { display: block; width: 200px; height: 40px; background: #fc9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>C2 actions</h1>
|
||||
|
||||
<!-- hover: onmouseover 打标记(验证 hover 真投递到元素)。用 button 让 aria 给它稳定 name。 -->
|
||||
<div class="row">
|
||||
<button id="hovertarget" data-hovered="no">Hover me</button>
|
||||
</div>
|
||||
|
||||
<!-- select_option: 三个选项,act 选 opt2 后读回 select.value。 -->
|
||||
<div class="row">
|
||||
<label>Pick <select id="picker" aria-label="Pick">
|
||||
<option value="opt1">First</option>
|
||||
<option value="opt2">Second</option>
|
||||
<option value="opt3">Third</option>
|
||||
</select></label>
|
||||
</div>
|
||||
|
||||
<!-- press_key: 输入框在 form 内,Enter 触发 submit 打标记(验证 Enter-in-form)。
|
||||
带一个 submit 按钮让隐式提交在合成键事件下也可靠(单字段无按钮时浏览器启发式不稳)。 -->
|
||||
<form id="search" action="javascript:void(0)">
|
||||
<label>Query <input id="q" name="q" type="text"></label>
|
||||
<button id="search-submit" type="submit" style="position:absolute;left:-9999px">Go</button>
|
||||
</form>
|
||||
<div id="search-status" role="status" aria-label="search status">idle</div>
|
||||
|
||||
<!-- 长页 spacer:撑高让初始 scrollY=0,scroll down 后变大;底部文本/元素验证 scroll_to_text。 -->
|
||||
<div id="spacer"></div>
|
||||
<div id="bottom">
|
||||
<p id="bottommarker">Unique footer sentinel text</p>
|
||||
<button id="bottomtarget">Bottom target button</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// hover 标记:onmouseover 置位(hit-target 验证 hover 真命中目标本身)。
|
||||
document.getElementById('hovertarget').addEventListener('mouseover', function () {
|
||||
this.setAttribute('data-hovered', 'yes');
|
||||
this.textContent = 'Hovered';
|
||||
});
|
||||
// Enter-in-form 提交标记。
|
||||
document.getElementById('search').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var q = document.getElementById('q').value;
|
||||
document.getElementById('search-status').textContent = 'searched:' + q;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>C3 read-only actions (get_page_text/search_page/find_elements/get_dropdown_options/cursor/wait/wait_for)</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; font: 16px sans-serif; }
|
||||
.row { margin: 8px; }
|
||||
/* find_elements 目标:两个 .primary button + 一个 .secondary。 */
|
||||
button.primary { display: inline-block; width: 180px; height: 36px; }
|
||||
button.secondary { display: inline-block; width: 180px; height: 36px; }
|
||||
#picker { display: block; width: 240px; height: 28px; }
|
||||
/* cursor: 让若干元素有 cursor:pointer。button 默认非 pointer,显式设。 */
|
||||
button, a, .clickable { cursor: pointer; }
|
||||
/* wait_for TextVisible 目标:初始隐藏,1s 后注入文本节点。 */
|
||||
#late { color: #060; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>C3 read-only actions</h1>
|
||||
|
||||
<!-- get_page_text / search_page:可读正文含唯一 sentinel(search 命中)+ 明文 secret(验证脱敏)。 -->
|
||||
<div class="row">
|
||||
<p id="intro">Welcome to the unique-sentinel-marker order page. Your order is ready.</p>
|
||||
<!-- 明文 secret:get_page_text/search_page 的输出必须脱敏(绝不出现明文 sk-... / Bearer ...)。 -->
|
||||
<p id="leak">API key sk-ABCDEFGHIJ0123456789xyzQRSTUV must never reach the LLM.</p>
|
||||
<p id="leak2">Authorization: Bearer abcdef0123456789ABCDEFghij is also a secret.</p>
|
||||
<!-- password 输入框:DOM 信号 type=password;其 value 经 get_page_text(innerText) 通常不进文本,但保留以对齐 observe 口径。 -->
|
||||
<label>Password <input id="pw" type="password" value="hunter2plain"></label>
|
||||
</div>
|
||||
|
||||
<!-- find_elements:CSS 选择器 button.primary 命中两个;再用其中一个 ref click 验证可反解。 -->
|
||||
<div class="row">
|
||||
<button class="primary" id="p1">Primary One</button>
|
||||
<button class="primary" id="p2">Primary Two</button>
|
||||
<button class="secondary" id="s1">Secondary</button>
|
||||
</div>
|
||||
|
||||
<!-- get_dropdown_options:含 selected + disabled option,枚举验证。 -->
|
||||
<div class="row">
|
||||
<label>Pick <select id="picker" aria-label="Pick">
|
||||
<option value="opt1">First</option>
|
||||
<option value="opt2" selected>Second</option>
|
||||
<option value="opt3" disabled>Third (disabled)</option>
|
||||
</select></label>
|
||||
</div>
|
||||
|
||||
<!-- wait_for TextVisible:测试侧用 __eval_page_world_for_test 注入延迟文本(确定性控制时序),
|
||||
fixture 只提供空容器 #late。 -->
|
||||
<div class="row"><span id="late"></span></div>
|
||||
|
||||
<!-- find_elements click 反解目标:点击 #p1 改自身文案(证明 ref 真能 act)。 -->
|
||||
<div id="click-status" role="status" aria-label="click status">idle</div>
|
||||
|
||||
<script>
|
||||
// #p1 点击改文案(find_elements 返回的 ref → act(Click) 验证可反解端到端)。
|
||||
document.getElementById('p1').addEventListener('click', function () {
|
||||
this.textContent = 'Primary One Clicked';
|
||||
document.getElementById('click-status').textContent = 'p1-clicked';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>E4 download sandbox</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; font: 16px sans-serif; }
|
||||
a { display: block; margin: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>E4 download</h1>
|
||||
|
||||
<!--
|
||||
E4 集成测试用:触发一次真实下载落进沙箱(per-pet workspace/downloads)。
|
||||
用 data: URL + download 属性——点击即让 chrome 把内容当文件下载(无需服务器)。
|
||||
内容是一段可识别的文本(>0 字节,便于断言 size>0);文件名 report.txt(非可执行,
|
||||
denylist 不命中——本测试验证「良性文件落盘 + MOTW」,红线拒绝走纯逻辑单测)。
|
||||
注:data: URL 下 download 属性的 suggestedFilename 取 download 属性值(report.txt)。
|
||||
-->
|
||||
<a id="dl"
|
||||
download="report.txt"
|
||||
href="data:text/plain;charset=utf-8,NomiFun%20E4%20download%20sandbox%20test%20payload%0Aline2%0Aline3%0A">Download report.txt</a>
|
||||
|
||||
<script>
|
||||
// 暴露一个程序化触发(集成测试可直接 click DOM,或经 evaluate 调用——但 E4 测试走真实 click)。
|
||||
window.__triggerDownload = function () {
|
||||
document.getElementById('dl').click();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>F1-sec executable download red-line</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; font: 16px sans-serif; }
|
||||
a { display: block; margin: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>F1-sec executable download red-line</h1>
|
||||
|
||||
<!--
|
||||
F1-sec 集成测试用:触发一次「可执行文件」下载(suggestedFilename=setup.exe)。
|
||||
引擎的下载循环订阅 Browser.downloadWillBegin,命中 reject_executable_download(.exe denylist)
|
||||
即 cancelDownload(fail-closed,红线——yolo/companion 也取消,因为该判定不看 session_mode)。
|
||||
用 data: URL + download="setup.exe":点击即让 chrome 把内容当 setup.exe 下载(无需服务器);
|
||||
引擎应在落盘前取消它,故沙箱目录里不会出现非空 .exe(也不会出现任何最终落盘文件)。
|
||||
-->
|
||||
<a id="dl-exe"
|
||||
download="setup.exe"
|
||||
href="data:application/octet-stream;base64,TVqQAAEABLOCKED_FAKE_PE_PAYLOAD_NomiFun_F1sec">Download setup.exe</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>E5 egress firewall</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; font: 16px sans-serif; }
|
||||
pre { margin: 12px; padding: 8px; background: #f0f0f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>E5 egress firewall</h1>
|
||||
|
||||
<!--
|
||||
E5 集成测试用(#[ignore] 真 chrome)。验证 Fetch.enable 拦截链路在真请求路径上生效:
|
||||
|
||||
1) IP 封禁(is_blocked_ip enforcement):fetch POST 到云元数据 IP 169.254.169.254。
|
||||
防火墙在 Fetch.requestPaused 命中 → failRequest{BlockedByClient} → fetch 几乎**瞬间** reject
|
||||
(而非离线超时挂起)。fixture 记录 reject + 用时,测试据「快速 reject」证明是被防火墙阻断,
|
||||
而非「无防火墙时的慢超时」。
|
||||
|
||||
2) 跨域 POST-body 门控(检测路径):fetch POST 到跨域 host(带 form body)。防火墙在
|
||||
Fetch.requestPaused 命中 GatePost → 构造预览(host/size/字段名,绝不含值)+ info 留痕 +
|
||||
(E5 范围)continueRequest 放行。离线下该 fetch 仍会因无网络而 reject,但**关键是请求经过了
|
||||
拦截 handler**(被 continue 而非永久挂起)——证明拦截链路对跨域 POST 也触发了。
|
||||
|
||||
全程 file:// fixture,无需服务器:fetch 到外部 host / 元数据 IP 即可触发 requestPaused。
|
||||
-->
|
||||
<pre id="log">ready</pre>
|
||||
|
||||
<script>
|
||||
// 把每步结果挂到 window,测试经 a11y observe/evaluate 或直接读 DOM 文本断言。
|
||||
window.__e5 = { steps: {} };
|
||||
|
||||
function logLine(s) {
|
||||
const el = document.getElementById('log');
|
||||
el.textContent = el.textContent + "\n" + s;
|
||||
}
|
||||
|
||||
// 1) IP 封禁:fetch POST 到云元数据 IP。被防火墙 failRequest → 快速 reject。
|
||||
window.__e5BlockedIpFetch = async function () {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await fetch("http://169.254.169.254/latest/meta-data/", {
|
||||
method: "POST",
|
||||
body: "probe=1",
|
||||
// no-cors 让请求真正发出(否则 CORS 预检也可能干扰);防火墙在网络层拦,与 CORS 无关。
|
||||
mode: "no-cors",
|
||||
});
|
||||
const dt = performance.now() - t0;
|
||||
window.__e5.steps.blockedIp = { ok: true, rejected: false, ms: dt };
|
||||
logLine("blockedIp: NOT rejected (ms=" + dt.toFixed(0) + ")");
|
||||
} catch (e) {
|
||||
const dt = performance.now() - t0;
|
||||
window.__e5.steps.blockedIp = { ok: true, rejected: true, ms: dt, err: String(e) };
|
||||
logLine("blockedIp: rejected (ms=" + dt.toFixed(0) + ") " + e);
|
||||
}
|
||||
return window.__e5.steps.blockedIp;
|
||||
};
|
||||
|
||||
// 2) 跨域 POST-body 门控(检测路径):fetch POST 到跨域 host,带表单 body。
|
||||
window.__e5CrossOriginPost = async function () {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await fetch("https://e5-cross-origin-probe.example.com/collect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: "username=alice&password=hunter2&csrf=abc123",
|
||||
mode: "no-cors",
|
||||
});
|
||||
const dt = performance.now() - t0;
|
||||
window.__e5.steps.crossPost = { ok: true, rejected: false, ms: dt };
|
||||
logLine("crossPost: completed (ms=" + dt.toFixed(0) + ")");
|
||||
} catch (e) {
|
||||
const dt = performance.now() - t0;
|
||||
window.__e5.steps.crossPost = { ok: true, rejected: true, ms: dt, err: String(e) };
|
||||
logLine("crossPost: rejected (ms=" + dt.toFixed(0) + ") " + e);
|
||||
}
|
||||
return window.__e5.steps.crossPost;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<!doctype html><html><body>
|
||||
<h1>Smoke</h1>
|
||||
<button>Submit order</button>
|
||||
<label>Email <input type="text"></label>
|
||||
<iframe srcdoc="<a href="#">Inner</a>"></iframe>
|
||||
</body></html>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Input synthesis (B5)</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; }
|
||||
/* 定位到确定区域,content quad 中点稳定(虽 B5 走真实 getContentQuads,非硬编码点)。 */
|
||||
#field { position: fixed; left: 40px; top: 40px; width: 240px; height: 32px; }
|
||||
#toggle { position: fixed; left: 40px; top: 120px; width: 200px; height: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Input synth</h1>
|
||||
<!-- 文本输入:dispatch_click 聚焦 → insert_text 写入 → 读回 value 验证。 -->
|
||||
<label>Name <input id="field" type="text"></label>
|
||||
<!-- 按钮:点击改自身文案,验证 dispatch_click 真分发到元素。 -->
|
||||
<button id="toggle">Not clicked</button>
|
||||
<script>
|
||||
document.getElementById('toggle').addEventListener('click', function () {
|
||||
this.textContent = 'Clicked';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hit-target / modal overlay</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; }
|
||||
|
||||
/* 页面背景里的按钮:被全屏 modal 遮罩盖住,点它会命中 overlay(误点)。 */
|
||||
#covered {
|
||||
position: fixed;
|
||||
left: 40px; top: 200px;
|
||||
width: 160px; height: 48px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 全屏半透明遮罩:盖住整个视口(含 #covered)。z-index 高于页面背景按钮。 */
|
||||
#overlay {
|
||||
position: fixed;
|
||||
left: 0; top: 0;
|
||||
width: 100vw; height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* modal 里的「可达」按钮:z-index 高于遮罩,点它真命中它自己(不被遮)。
|
||||
定位到确定坐标,便于测试硬编码命中点(不依赖 quad 计算,那是 B5)。 */
|
||||
#reachable {
|
||||
position: fixed;
|
||||
left: 40px; top: 40px;
|
||||
width: 160px; height: 48px;
|
||||
z-index: 1001;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hit target</h1>
|
||||
<button id="covered">Behind overlay</button>
|
||||
<div id="overlay"></div>
|
||||
<button id="reachable">Reachable in modal</button>
|
||||
</body>
|
||||
</html>
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>D2 networkidle never-idle (long polling)</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Never-idle fixture</h1>
|
||||
<div id="state" role="status">polling</div>
|
||||
<script>
|
||||
// 永不空闲:持续发起请求(每个请求在前一个完成后立即再发),模拟长轮询 / SSE 站。
|
||||
// inflight 计数永远 >0(或频繁回到 >0),networkidle 永远等不到 500ms 连续空闲 →
|
||||
// navigate 应在 networkidle 短 cap(~4s)到点后降级返 Load(不卡 30s)。
|
||||
//
|
||||
// 请求目标用本页自身(file://),每次带不同 query 防缓存命中即返回。即便 file:// 的
|
||||
// fetch 行为受限,requestWillBeSent 仍会发出 → inflight 计数仍被打破,验证 cap 降级。
|
||||
let n = 0;
|
||||
function poll() {
|
||||
n++;
|
||||
fetch(location.pathname + '?poll=' + n + '&t=' + Date.now())
|
||||
.catch(function () {})
|
||||
.finally(function () {
|
||||
// 立即再发下一个:让 inflight 在 500ms 静默窗口内总被打破。
|
||||
setTimeout(poll, 30);
|
||||
});
|
||||
}
|
||||
// 同时并发多条,进一步保证窗口内总有 inflight。
|
||||
poll(); poll(); poll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html><html><body>
|
||||
<h1>PAGE_A_MARKER</h1>
|
||||
<p>This is page A.</p>
|
||||
</body></html>
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html><html><body>
|
||||
<h1>PAGE_B_MARKER</h1>
|
||||
<p>This is page B.</p>
|
||||
</body></html>
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html><html><body>
|
||||
<h1>Secrets</h1>
|
||||
<label>Password <input type="password" value="hun]ter2sk"></label>
|
||||
<p>Your API key is sk-ABCDEFGHIJ0123456789xyzQRSTUV and keep it secret.</p>
|
||||
<p>Authorization: Bearer abcdef0123456789ABCDEFghij token follows.</p>
|
||||
<button>Save credentials</button>
|
||||
</body></html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<!doctype html><html><body>
|
||||
<h1>Shadow</h1>
|
||||
<div id="o"></div><div id="c"></div>
|
||||
<script>
|
||||
o.attachShadow({mode:"open"}).innerHTML = '<button>OpenShadowBtn</button>';
|
||||
c.attachShadow({mode:"closed"}).innerHTML = '<button>ClosedShadowBtn</button>';
|
||||
</script>
|
||||
</body></html>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>D2 SPA soft navigation</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SPA fixture</h1>
|
||||
<button id="go">Go to /step2</button>
|
||||
<div id="state" role="status">at start</div>
|
||||
<script>
|
||||
// 点击即 history.pushState 改 URL(same-document soft navigation,无 newDocument)。
|
||||
// CDP 据此发 Page.navigatedWithinDocument(不发 frameNavigated/loadEventFired)。
|
||||
document.getElementById('go').addEventListener('click', function () {
|
||||
history.pushState({}, '', '/step2');
|
||||
document.getElementById('state').textContent = 'at step2';
|
||||
});
|
||||
// 加载即自动软导航一次(让 navigate 到本页后立即触发 navigatedWithinDocument,
|
||||
// 便于集成测试在不依赖点击的情况下观察 SPA 降级路径)。延迟到下一帧,确保 navigate
|
||||
// 的初始文档 load 与软导航分离。
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(function () {
|
||||
history.pushState({}, '', '/auto-soft-nav');
|
||||
document.getElementById('state').textContent = 'auto soft-nav done';
|
||||
}, 50);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<!doctype html><html><body>
|
||||
<h1>MAIN_DOC_MARKER parent document</h1>
|
||||
<p>This text lives in the top/main frame only.</p>
|
||||
<button>Parent Button</button>
|
||||
<iframe title="content frame" srcdoc="<!doctype html><html><body><h2>IFRAME_INNER_MARKER child document</h2><p>This text lives inside the iframe only.</p><button>Inner Button</button></body></html>"></iframe>
|
||||
</body></html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>F-actions upload_file</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; font: 16px sans-serif; }
|
||||
input { display: block; margin: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>F-actions upload</h1>
|
||||
|
||||
<!--
|
||||
F-actions 集成测试用:一个 <input type=file>,验证 act(UploadFile) 经 DOM.setFileInputFiles
|
||||
把本地文件路径塞进 input.files(绕系统文件对话框)。给它 aria-label 让 observe 暴露稳定 accname。
|
||||
测试断言:setFileInputFiles 后 input.files.length == 1 且 files[0].name 含被上传的文件名。
|
||||
-->
|
||||
<label>Attachment <input id="file" type="file" aria-label="Attachment"></label>
|
||||
|
||||
<script>
|
||||
// 暴露一个只读探针:返回 file input 当前的 {count, first}(测试也可直接经 act 的 verify 锚点拿到)。
|
||||
window.__fileInputState = function () {
|
||||
var f = document.getElementById('file').files;
|
||||
return { count: f ? f.length : 0, first: (f && f.length > 0) ? f[0].name : null };
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
//! **P2 D4:back/forward/reload/switch_frame 端到端集成**(`#[ignore]`,本机/打包 chrome)。
|
||||
//!
|
||||
//! 验证 D4(DESIGN §12 导航 / §13 Target-Tab + 裁决⑤ settle 复用 / ⑧ POST reload→IRREVERSIBLE):
|
||||
//! - **back/forward**:navigate A → navigate B → `act(Back)` → URL/内容回 A → `act(Forward)` → 回 B;
|
||||
//! settle 复用 D2(load_state 正确);首页再 back / 末页再 forward → 良性「无更多历史」success=true。
|
||||
//! - **reload**:navigate → `act(Reload)` → 页面重载(load_state 达 Load/NetworkIdle,不报错)。
|
||||
//! - **switch_frame**:含 iframe 的 fixture → observe 看到 iframe ref → `act(SwitchFrame{ref})` 进 iframe
|
||||
//! → 页面级动作(get_page_text)作用于 **iframe 内容**(读到 IFRAME_INNER_MARKER 而非 MAIN_DOC_MARKER);
|
||||
//! 切回主帧(switch_frame "main")→ get_page_text 又读到主帧内容。
|
||||
//!
|
||||
//! 复用 `tests/common` 的 `build_backend_for_fixture`(勿再复制契约母本)。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=...\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all \
|
||||
//! -E 'test(back) | test(forward) | test(reload) | test(switch_frame) | test(history)'
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。
|
||||
//!
|
||||
//! 真实结果(本机首跑会 eprintln 出 URL 回退 / iframe 文本——填回任务汇报)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use nomi_browser_engine::actions::ActSpec;
|
||||
use nomi_browser_engine::progress::Progress;
|
||||
use nomi_browser_engine::{BrowserEngine, LoadState, ObserveOpts};
|
||||
|
||||
mod common;
|
||||
|
||||
/// 动作级 Progress(充裕 deadline,集成测试不为超时挂死;abort 仍按事件源触发)。
|
||||
fn act_progress() -> Progress {
|
||||
Progress::new(Duration::from_secs(60))
|
||||
}
|
||||
|
||||
/// **back/forward 端到端**:A→B→back(回 A)→forward(回 B),settle 复用 D2,load_state 正确;
|
||||
/// 末页再 forward / 首页再 back → 良性「无更多历史」success=true 不报错。一个测试覆盖全 history 链
|
||||
/// (建一次 chrome 最省资源)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn history_back_forward_roundtrip_and_benign_edges() {
|
||||
let backend = common::build_backend_for_fixture("d4-history").await;
|
||||
let url_a = common::fixture_url("page-a.html");
|
||||
let url_b = common::fixture_url("page-b.html");
|
||||
let p = act_progress();
|
||||
|
||||
// navigate A → B(建一段历史 [A, B],当前在 B)。
|
||||
let nav_a = backend.navigate(&url_a, false).await.expect("navigate A");
|
||||
assert!(nav_a.final_url.contains("page-a"), "should be on A: {}", nav_a.final_url);
|
||||
let nav_b = backend.navigate(&url_b, false).await.expect("navigate B");
|
||||
assert!(nav_b.final_url.contains("page-b"), "should be on B: {}", nav_b.final_url);
|
||||
|
||||
// ── 末页 forward → 良性「无更多历史」(在 B 是末页)──
|
||||
let fwd_edge = backend.act(&ActSpec::Forward, &p).await.expect("forward at end");
|
||||
eprintln!("=== forward-at-end === success={} changed={} msg={}", fwd_edge.success, fwd_edge.effect.changed, fwd_edge.message);
|
||||
assert!(fwd_edge.success, "forward at last page must be benign success (no more history)");
|
||||
assert!(!fwd_edge.effect.changed, "forward at end must not change page");
|
||||
|
||||
// ── back → 回 A(settle 复用 D2,load_state 达可读稳态)──
|
||||
let back = backend.act(&ActSpec::Back, &p).await.expect("back to A");
|
||||
eprintln!("=== back === success={} changed={} msg={}", back.success, back.effect.changed, back.message);
|
||||
assert!(back.success && back.effect.changed, "back from B must change to A");
|
||||
// current url 应回到 A(用 get_page_text 验内容也行;这里查 active tab url 经 observe 的 url 字段)。
|
||||
let obs_a = backend.observe(&ObserveOpts::default()).await.expect("observe after back");
|
||||
eprintln!("after-back url = {:?}", obs_a.url);
|
||||
assert!(
|
||||
obs_a.url.as_deref().unwrap_or("").contains("page-a"),
|
||||
"after back, page must be A, got url={:?}",
|
||||
obs_a.url
|
||||
);
|
||||
// 内容也应是 A(PAGE_A_MARKER)。
|
||||
let text_a = backend.act(&ActSpec::GetPageText, &p).await.expect("get_page_text A");
|
||||
assert!(text_a.message.contains("PAGE_A_MARKER"), "back must show A content: {}", text_a.message);
|
||||
|
||||
// ── forward → 回 B ──
|
||||
let fwd = backend.act(&ActSpec::Forward, &p).await.expect("forward to B");
|
||||
eprintln!("=== forward === success={} changed={} msg={}", fwd.success, fwd.effect.changed, fwd.message);
|
||||
assert!(fwd.success && fwd.effect.changed, "forward from A must change back to B");
|
||||
let obs_b = backend.observe(&ObserveOpts::default()).await.expect("observe after forward");
|
||||
eprintln!("after-forward url = {:?}", obs_b.url);
|
||||
assert!(
|
||||
obs_b.url.as_deref().unwrap_or("").contains("page-b"),
|
||||
"after forward, page must be B, got url={:?}",
|
||||
obs_b.url
|
||||
);
|
||||
|
||||
// ── back 到首页边界 → 良性「无更多历史」success=true changed=false ──
|
||||
// 注:浏览器启动时已有一个 `about:blank` 初始历史 entry(launch + createTarget),故真实历史是
|
||||
// [about:blank, page-a, page-b]——page-a 并非 idx 0。逐步 back 直到撞到首页边界(changed=false),
|
||||
// 全程必须 success=true(每一格 back 都良性、绝不报错、绝不 panic),且边界态 changed=false。
|
||||
let mut hit_edge = false;
|
||||
for i in 0..6 {
|
||||
let b = backend.act(&ActSpec::Back, &p).await.expect("back step");
|
||||
eprintln!("=== back-step {i} === success={} changed={} msg={}", b.success, b.effect.changed, b.message);
|
||||
assert!(b.success, "every back must be benign success (step {i}): {}", b.message);
|
||||
if !b.effect.changed {
|
||||
hit_edge = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
hit_edge,
|
||||
"backing to the first history entry must eventually reach the benign 'no more history' edge (success=true, changed=false)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **reload 端到端**:navigate → reload → load_state 达可读稳态(Load/NetworkIdle),success=true。
|
||||
/// reload 后页面仍是同一 URL(GET 页,非 POST,故 effect.after_anchor.irreversible == false)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn reload_get_page_succeeds_and_not_irreversible() {
|
||||
let backend = common::build_backend_for_fixture("d4-reload").await;
|
||||
let url = common::fixture_url("page-a.html");
|
||||
let p = act_progress();
|
||||
|
||||
backend.navigate(&url, false).await.expect("navigate");
|
||||
let reload = backend.act(&ActSpec::Reload, &p).await.expect("reload");
|
||||
eprintln!("=== reload === success={} changed={} msg={}", reload.success, reload.effect.changed, reload.message);
|
||||
assert!(reload.success, "reload of a GET page must succeed");
|
||||
// GET 页 reload → 非 IRREVERSIBLE(after_anchor.irreversible == false)。
|
||||
let irreversible = reload
|
||||
.effect
|
||||
.after_anchor
|
||||
.as_ref()
|
||||
.and_then(|a| a.get("irreversible"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
assert!(!irreversible, "GET page reload must NOT be flagged irreversible: {:?}", reload.effect.after_anchor);
|
||||
// load_state 达可读稳态(静态页)。
|
||||
let ls = reload
|
||||
.effect
|
||||
.after_anchor
|
||||
.as_ref()
|
||||
.and_then(|a| a.get("load_state"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
eprintln!("reload load_state = {ls}");
|
||||
assert!(
|
||||
ls == "load" || ls == "networkidle" || ls == "domcontentloaded",
|
||||
"reload should reach a readable load state, got {ls}"
|
||||
);
|
||||
// 内容仍是 A。
|
||||
let text = backend.act(&ActSpec::GetPageText, &p).await.expect("get_page_text");
|
||||
assert!(text.message.contains("PAGE_A_MARKER"), "reload keeps page A: {}", text.message);
|
||||
let _ = LoadState::Load; // 用到 LoadState 导入(保持与 nav 测同风格)。
|
||||
}
|
||||
|
||||
/// **switch_frame 端到端**:含 iframe 的 fixture → observe 看到 iframe ref → switch_frame 进 iframe →
|
||||
/// get_page_text 读到 **iframe 内容**(IFRAME_INNER_MARKER)而非主帧(MAIN_DOC_MARKER);switch_frame
|
||||
/// "main" 切回主帧 → get_page_text 又读到主帧内容。验证 active_frame 指针影响页面级动作。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn switch_frame_scopes_page_text_to_iframe() {
|
||||
let backend = common::build_backend_for_fixture("d4-switchframe").await;
|
||||
let p = act_progress();
|
||||
backend
|
||||
.navigate(&common::fixture_url("switch-frame.html"), false)
|
||||
.await
|
||||
.expect("navigate switch-frame.html");
|
||||
|
||||
// observe 填 ref 表 + 武装注入侧缓存(switch_frame 反查的前置)。
|
||||
let obs = backend.observe(&ObserveOpts::default()).await.expect("observe");
|
||||
eprintln!("=== switch_frame entries ===");
|
||||
for e in &obs.entries {
|
||||
eprintln!(" ref={} role={} name={:?} frame_seq={}", e.r#ref, e.role, e.name, e.frame_seq);
|
||||
}
|
||||
|
||||
// 主帧 get_page_text:应读到 MAIN_DOC_MARKER(默认作用主帧)。
|
||||
let main_text = backend.act(&ActSpec::GetPageText, &p).await.expect("get_page_text main");
|
||||
eprintln!("=== main-frame text (truncated) ===\n{}", &main_text.message.chars().take(300).collect::<String>());
|
||||
assert!(
|
||||
main_text.message.contains("MAIN_DOC_MARKER"),
|
||||
"before switch_frame, page text must be the MAIN doc: {}",
|
||||
main_text.message
|
||||
);
|
||||
|
||||
// 取 iframe 元素 ref(observe 把 iframe 元素以 role=iframe 暴露)。
|
||||
let iframe_entry = obs
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.role == "iframe")
|
||||
.expect("fixture should expose an iframe element ref");
|
||||
eprintln!("iframe ref = {}", iframe_entry.r#ref);
|
||||
|
||||
// switch_frame 进 iframe。
|
||||
let sw = backend
|
||||
.act(&ActSpec::SwitchFrame { r#ref: iframe_entry.r#ref.clone() }, &p)
|
||||
.await
|
||||
.expect("switch_frame into iframe");
|
||||
eprintln!("=== switch_frame === success={} msg={}", sw.success, sw.message);
|
||||
assert!(sw.success, "switch_frame into a real iframe must succeed");
|
||||
|
||||
// 现在 get_page_text 应作用于 iframe 内容(IFRAME_INNER_MARKER),不再是主帧。
|
||||
let iframe_text = backend.act(&ActSpec::GetPageText, &p).await.expect("get_page_text iframe");
|
||||
eprintln!("=== iframe text (truncated) ===\n{}", &iframe_text.message.chars().take(300).collect::<String>());
|
||||
assert!(
|
||||
iframe_text.message.contains("IFRAME_INNER_MARKER"),
|
||||
"after switch_frame, page text must be the IFRAME content: {}",
|
||||
iframe_text.message
|
||||
);
|
||||
assert!(
|
||||
!iframe_text.message.contains("MAIN_DOC_MARKER"),
|
||||
"after switch_frame, page text must NOT include the main doc marker: {}",
|
||||
iframe_text.message
|
||||
);
|
||||
|
||||
// switch_frame "main" 切回主帧 → get_page_text 又读到主帧内容。
|
||||
let back_main = backend
|
||||
.act(&ActSpec::SwitchFrame { r#ref: "main".into() }, &p)
|
||||
.await
|
||||
.expect("switch_frame back to main");
|
||||
assert!(back_main.success, "switch_frame back to main must succeed");
|
||||
let main_again = backend.act(&ActSpec::GetPageText, &p).await.expect("get_page_text main again");
|
||||
assert!(
|
||||
main_again.message.contains("MAIN_DOC_MARKER"),
|
||||
"after switching back to main, page text must be the MAIN doc again: {}",
|
||||
main_again.message
|
||||
);
|
||||
}
|
||||
|
||||
/// **switch_frame 到非 iframe 元素 → 良性失败**(success=false,不报错):取一个非 iframe 元素 ref
|
||||
/// (主帧 button)switch_frame → success=false(引导换 ref),不 Err、不 panic。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn switch_frame_on_non_iframe_is_benign_failure() {
|
||||
let backend = common::build_backend_for_fixture("d4-switchframe-noniframe").await;
|
||||
let p = act_progress();
|
||||
backend
|
||||
.navigate(&common::fixture_url("switch-frame.html"), false)
|
||||
.await
|
||||
.expect("navigate");
|
||||
let obs = backend.observe(&ObserveOpts::default()).await.expect("observe");
|
||||
let button = obs
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.role == "button")
|
||||
.expect("fixture should expose a button");
|
||||
let res = backend
|
||||
.act(&ActSpec::SwitchFrame { r#ref: button.r#ref.clone() }, &p)
|
||||
.await
|
||||
.expect("switch_frame on non-iframe must return Ok (benign), not Err");
|
||||
eprintln!("=== switch_frame-non-iframe === success={} msg={}", res.success, res.message);
|
||||
assert!(!res.success, "switch_frame on a non-iframe element must be a benign failure (success=false)");
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! 真 Chrome 集成测试:验证 per-tab 调试事件捕获(console/errors/network)。
|
||||
//! 需 `NOMIFUN_CHROME_BINARY` 环境变量指向 Chrome 可执行文件。
|
||||
//! 运行:`cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(captures_console_error_and_network)'`
|
||||
|
||||
use nomi_browser_engine::{create_engine, EngineConfig};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
|
||||
|
||||
/// fixture HTML: console.error + throw + fetch(base64 编码避免 data: URL 截断)。
|
||||
fn fixture_html() -> String {
|
||||
let html = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Debug Capture Test</title></head>
|
||||
<body>
|
||||
<script>
|
||||
// 1) console.error
|
||||
console.error("test-debug-error-message", 42);
|
||||
// 2) uncaught exception
|
||||
setTimeout(function throwIt() {
|
||||
throw new Error("test-uncaught-exception");
|
||||
}, 50);
|
||||
// 3) fetch (triggers network activity)
|
||||
fetch("https://httpbin.org/get?foo=bar").catch(function(){});
|
||||
// 4) a second fetch to a non-existent domain (will fail)
|
||||
fetch("https://this-domain-does-not-exist-12345.invalid/api").catch(function(){});
|
||||
</script>
|
||||
</body>
|
||||
</html>"#;
|
||||
let encoded = B64.encode(html.as_bytes());
|
||||
format!("data:text/html;base64,{encoded}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "需 NOMIFUN_CHROME_BINARY(真 Chrome):调试事件捕获冒烟"]
|
||||
async fn captures_console_error_and_network() {
|
||||
let engine = create_engine(EngineConfig::default())
|
||||
.await
|
||||
.expect("engine builds with NOMIFUN_CHROME_BINARY set");
|
||||
|
||||
let url = fixture_html();
|
||||
engine.navigate(&url, false).await.expect("navigate");
|
||||
|
||||
// 给 Chrome 一点时间让事件到来(setTimeout + fetch 需要异步完成)。
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let snap = engine.debug_snapshot().await.expect("debug_snapshot");
|
||||
|
||||
// ── Console: 至少有一条 error 级别的 console 消息 ──
|
||||
assert!(
|
||||
!snap.console.is_empty(),
|
||||
"expected at least one console entry, got none"
|
||||
);
|
||||
let has_error_msg = snap
|
||||
.console
|
||||
.iter()
|
||||
.any(|e| e.text.contains("test-debug-error-message"));
|
||||
assert!(
|
||||
has_error_msg,
|
||||
"expected console.error('test-debug-error-message'), got: {:?}",
|
||||
snap.console.iter().map(|e| &e.text).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// ── Errors: 至少有一条未捕获异常 ──
|
||||
// 注意:setTimeout throw 可能需要更长时间才会被捕获
|
||||
// 如果还没来,等一下再取
|
||||
let snap2 = if snap.errors.is_empty() {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
engine.debug_snapshot().await.expect("debug_snapshot 2")
|
||||
} else {
|
||||
snap
|
||||
};
|
||||
|
||||
assert!(
|
||||
!snap2.errors.is_empty(),
|
||||
"expected at least one page error (uncaught exception), got none"
|
||||
);
|
||||
let has_exception = snap2
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message.contains("test-uncaught-exception"));
|
||||
assert!(
|
||||
has_exception,
|
||||
"expected 'test-uncaught-exception' in errors, got: {:?}",
|
||||
snap2.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// ── Network: 至少有一条网络请求 ──
|
||||
assert!(
|
||||
!snap2.network.is_empty(),
|
||||
"expected at least one network entry (the fetch), got none"
|
||||
);
|
||||
// httpbin.org 或 the-invalid-domain 应出现
|
||||
let has_httpbin = snap2.network.iter().any(|e| e.url.contains("httpbin.org"));
|
||||
let has_invalid = snap2
|
||||
.network
|
||||
.iter()
|
||||
.any(|e| e.url.contains("this-domain-does-not-exist"));
|
||||
assert!(
|
||||
has_httpbin || has_invalid,
|
||||
"expected network entries for our fetch URLs, got: {:?}",
|
||||
snap2.network.iter().map(|e| &e.url).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! **P2 E4:下载沙箱 + MOTW 端到端集成**(`#[ignore]`,本机/打包 chrome)。
|
||||
//!
|
||||
//! 验证:
|
||||
//! 1. **下载落隔离 workspace**:navigate fixture(含 `download` 属性链接)→ observe → 取链接 ref
|
||||
//! → `act(Click)` → 文件落进 per-pet workspace 的 `downloads/` 子目录(**非用户 Downloads**)+
|
||||
//! size>0。`allowAndName` 下文件名是下载 GUID。
|
||||
//! 2. **Win MOTW**:下载完成后该文件有 `Zone.Identifier` ADS 且含 `ZoneId=3`(`std::fs::read` 那个
|
||||
//! ADS 路径校验)。mac/linux 跳过 MOTW 断言(write_motw 空实现,登记在 PLATFORM-VERIFICATION.md)。
|
||||
//!
|
||||
//! 可执行 denylist 红线**拒绝判定**走纯逻辑单测(`download::tests`,不需真浏览器)——这里只验「良性
|
||||
//! 文件真落盘 + MOTW 标记」的端到端链路。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=C:\Program Files\Google\Chrome\Application\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(download_) | test(motw)'
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use nomi_browser_engine::progress::Progress;
|
||||
use nomi_browser_engine::{ActSpec, BrowserEngine, ObserveOpts};
|
||||
|
||||
mod common;
|
||||
|
||||
/// 端到端:触发一次真实下载 → 文件落隔离 workspace/downloads + size>0;Windows 上额外验
|
||||
/// `Zone.Identifier` ADS 含 ZoneId=3。一个测试覆盖「沙箱落点 + MOTW」全链(建一次 chrome)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn download_lands_in_sandbox_and_gets_motw() {
|
||||
let (backend, download_dir) =
|
||||
common::build_backend_for_fixture_with_downloads("e4").await;
|
||||
eprintln!("download_dir = {}", download_dir.display());
|
||||
|
||||
backend
|
||||
.navigate(&common::fixture_url("download.html"), false)
|
||||
.await
|
||||
.expect("navigate download.html");
|
||||
|
||||
// observe 填 ref 表(act Click 反查的前置)。
|
||||
let obs = backend
|
||||
.observe(&ObserveOpts::default())
|
||||
.await
|
||||
.expect("observe");
|
||||
eprintln!("=== download fixture entries ===");
|
||||
for e in &obs.entries {
|
||||
eprintln!(" ref={} role={} name={:?}", e.r#ref, e.role, e.name);
|
||||
}
|
||||
|
||||
// 取下载链接的 ref(fixture 固定有一个 link "Download report.txt")。
|
||||
let link = obs
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.role == "link" && e.name.contains("report.txt"))
|
||||
.expect("fixture should expose a download link");
|
||||
eprintln!("download link ref = {}", link.r#ref);
|
||||
|
||||
// act(Click):点击 download 链接触发下载(data: URL + download 属性 → chrome 落盘到沙箱目录)。
|
||||
let p = Progress::new(Duration::from_secs(30));
|
||||
let res = backend
|
||||
.act(&ActSpec::Click { r#ref: link.r#ref.clone() }, &p)
|
||||
.await;
|
||||
eprintln!("click result = {res:?}");
|
||||
// 点击本身可能因「下载导致导航被打断」返回各种良性态——不强断言 success;下载是否落盘才是验收点。
|
||||
|
||||
// 轮询下载目录直到出现一个非空文件(下载异步;最长等 ~10s)。allowAndName → 文件名是 GUID。
|
||||
let mut found: Option<std::path::PathBuf> = None;
|
||||
for _ in 0..100 {
|
||||
if let Ok(rd) = std::fs::read_dir(&download_dir) {
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
// 跳过 chrome 下载中途的 .crdownload 临时文件,只认最终落盘文件。
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("crdownload") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(meta) = std::fs::metadata(&path)
|
||||
&& meta.is_file()
|
||||
&& meta.len() > 0
|
||||
{
|
||||
found = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if found.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
let file = found.expect("a non-empty downloaded file should land in the sandbox download dir");
|
||||
let size = std::fs::metadata(&file).unwrap().len();
|
||||
eprintln!("downloaded file = {} ({} bytes)", file.display(), size);
|
||||
assert!(size > 0, "downloaded file must be non-empty");
|
||||
|
||||
// ── 红线:落点必须在隔离 download_dir 下(绝不在用户真实 Downloads)──
|
||||
assert!(
|
||||
file.starts_with(&download_dir),
|
||||
"downloaded file must be inside the sandbox dir {}, got {}",
|
||||
download_dir.display(),
|
||||
file.display()
|
||||
);
|
||||
|
||||
// ── Win MOTW:等下载循环打上 Zone.Identifier ADS(异步,downloadProgress completed 后才写)──
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let ads = format!("{}:Zone.Identifier", file.display());
|
||||
let mut motw: Option<String> = None;
|
||||
for _ in 0..50 {
|
||||
if let Ok(s) = std::fs::read_to_string(&ads) {
|
||||
motw = Some(s);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let content = motw.expect("Zone.Identifier ADS should be written by the download loop (MOTW)");
|
||||
eprintln!("MOTW Zone.Identifier content = {content:?}");
|
||||
assert!(content.contains("[ZoneTransfer]"), "MOTW must have [ZoneTransfer] header: {content:?}");
|
||||
assert!(content.contains("ZoneId=3"), "MOTW must mark Internet zone (ZoneId=3): {content:?}");
|
||||
}
|
||||
// ── macOS MOTW 等价:下载文件必带 com.apple.quarantine(Gatekeeper 打开可执行/归档时校验)。
|
||||
// 两个写者:① chrome 原生 LSFileQuarantine(agent=Chrome,形态更全含源URL+事件UUID);
|
||||
// ② 我方 write_motw 兜底(XATTR_CREATE,仅 chrome 未落时填,agent=NomiFun)。二者异步竞争,
|
||||
// 最终 agent 不定——但**安全不变量=quarantine 存在且标 web-download(0081;)**,与 agent 无关。
|
||||
// 故断言「存在 + 0081; 标志」,agent 接受 Chrome(原生)或 NomiFun(兜底)两者。轮询等其落盘。──
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut q: Option<String> = None;
|
||||
for _ in 0..100 {
|
||||
let out = std::process::Command::new("/usr/bin/xattr")
|
||||
.args(["-p", "com.apple.quarantine"])
|
||||
.arg(&file)
|
||||
.output();
|
||||
if let Ok(o) = out
|
||||
&& o.status.success()
|
||||
{
|
||||
q = Some(String::from_utf8_lossy(&o.stdout).trim().to_string());
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let value = q.expect("com.apple.quarantine should be present (chrome native or write_motw fallback) on macOS");
|
||||
eprintln!("macOS quarantine = {value:?}");
|
||||
assert!(
|
||||
value.starts_with("0081;"),
|
||||
"quarantine flags must mark web download (0081;...): {value:?}"
|
||||
);
|
||||
// agent 是 Chrome(chrome 原生先写)或 NomiFun(chrome 未写时我方兜底)——两者都满足
|
||||
// 「文件已被 quarantine」这一安全不变量,不耦合具体 agent(消除两写者竞争致的伪 flake)。
|
||||
assert!(
|
||||
value.contains("Chrome") || value.contains("NomiFun"),
|
||||
"quarantine agent should be Chrome (native) or NomiFun (fallback): {value:?}"
|
||||
);
|
||||
}
|
||||
#[cfg(all(not(windows), not(target_os = "macos")))]
|
||||
{
|
||||
// linux:write_motw 空实现,无内核级 quarantine 等价——不断言。见 PLATFORM-VERIFICATION.md
|
||||
// TODO(verify-linux)。
|
||||
eprintln!("linux: MOTW/quarantine is a no-op (no kernel equivalent); skip assertion");
|
||||
}
|
||||
|
||||
// 清理:删下载文件(连带其 ADS)。download_dir 由 build helper 下次跑前清。
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
|
||||
/// **F1-sec:可执行下载红线 enforcement 端到端**。触发一次 `setup.exe` 下载 → 引擎的下载循环在
|
||||
/// `Browser.downloadWillBegin` 命中 `reject_executable_download`(.exe denylist)→ `cancelDownload`
|
||||
/// 取消(**fail-closed,红线,不看 session_mode**)→ 沙箱目录里**不出现**任何非空最终落盘文件。
|
||||
/// 证「可执行下载在红线会话也拒」(这道判定不吃 session_mode,故即便 yolo/companion 也取消)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn executable_download_is_cancelled_red_line() {
|
||||
let (backend, download_dir) =
|
||||
common::build_backend_for_fixture_with_downloads("f1sec-exe").await;
|
||||
eprintln!("download_dir = {}", download_dir.display());
|
||||
|
||||
backend
|
||||
.navigate(&common::fixture_url("download_exe.html"), false)
|
||||
.await
|
||||
.expect("navigate download_exe.html");
|
||||
|
||||
let obs = backend
|
||||
.observe(&ObserveOpts::default())
|
||||
.await
|
||||
.expect("observe");
|
||||
let link = obs
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.role == "link" && e.name.contains("setup.exe"))
|
||||
.expect("fixture should expose an executable download link");
|
||||
eprintln!("exe download link ref = {}", link.r#ref);
|
||||
|
||||
// 点击触发 .exe 下载。引擎应在 downloadWillBegin 时取消(denylist)。
|
||||
let p = Progress::new(Duration::from_secs(30));
|
||||
let res = backend
|
||||
.act(&ActSpec::Click { r#ref: link.r#ref.clone() }, &p)
|
||||
.await;
|
||||
eprintln!("click result = {res:?}");
|
||||
|
||||
// 等待窗口:给下载循环时间收到 downloadWillBegin + 发 cancelDownload;其间反复确认沙箱目录里
|
||||
// **没有**非空 .exe 最终文件落盘(被取消 → 不应有 completed 落盘)。
|
||||
let mut leaked: Option<std::path::PathBuf> = None;
|
||||
for _ in 0..50 {
|
||||
if let Ok(rd) = std::fs::read_dir(&download_dir) {
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
// .crdownload 是中途临时文件(取消后会被清理)——只认最终落盘的非空文件。
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("crdownload") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(meta) = std::fs::metadata(&path)
|
||||
&& meta.is_file()
|
||||
&& meta.len() > 0
|
||||
{
|
||||
leaked = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if leaked.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
if let Some(p) = &leaked {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
assert!(
|
||||
leaked.is_none(),
|
||||
"executable download (.exe) must be cancelled by the red-line (no final file should land), \
|
||||
but a file landed: {leaked:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! **P2 E5:出口防火墙端到端集成**(`#[ignore]`,本机/打包 chrome)。
|
||||
//!
|
||||
//! 验证 `Fetch.enable` 拦截链路在**真请求路径**上生效(`spawn_fetch_firewall_loop`
|
||||
//! 配合 `is_blocked_ip` 与跨域 POST 门控)。全程 `file://` fixture,无需服务器——fetch 到外部
|
||||
//! host / 元数据 IP 即触发 `Fetch.requestPaused`。
|
||||
//!
|
||||
//! 1. **IP 封禁 enforcement**(`firewall_blocks_metadata_ip`):fetch POST 到云元数据 IP
|
||||
//! `169.254.169.254`。防火墙命中 `is_blocked_ip` → `Fetch.failRequest{BlockedByClient}` → fetch
|
||||
//! **快速 reject**(而非离线超时挂起)。测试据「快速 reject」(远短于网络超时)证明是被防火墙阻断。
|
||||
//! 2. **跨域 POST 拦截 + 预览不含值**(纯逻辑已在 `firewall::tests` 穷尽覆盖:`build_post_preview` /
|
||||
//! `decide` 的 `GatePost` 断言 host/size/字段名且**绝不含字段值**);此处的 `#[ignore]` 集成只额外
|
||||
//! 确认「跨域 POST fetch 经过拦截 handler 被处理(continue 放行而非永久挂起)」——即防火墙循环对
|
||||
//! 跨域 POST 也触发了(离线无法连通,但请求不被永久卡住即证拦截链路活着)。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=C:\Program Files\Google\Chrome\Application\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(firewall) | test(egress) | test(blocked_ip)'
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use nomi_browser_engine::backend::CdpBackend;
|
||||
use nomi_browser_engine::BrowserEngine;
|
||||
use serde_json::Value;
|
||||
|
||||
mod common;
|
||||
|
||||
/// 轮询 `window.__e5.steps.<step>` 直到被 JS 填充(fetch promise settle),返回那一步的结果对象。
|
||||
/// 超时返 `None`。用 `__eval_page_world_for_test`(by-value 读,await_promise=false)反复读 window 状态。
|
||||
async fn poll_step(backend: &CdpBackend, step: &str, max_ms: u64) -> Option<Value> {
|
||||
let expr = format!("window.__e5 && window.__e5.steps && window.__e5.steps.{step} || null");
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_millis(max_ms);
|
||||
loop {
|
||||
// by-value RemoteObject:{type, value}。取 value;非 null 即 JS 已填充。
|
||||
if let Ok(v) = backend.__eval_page_world_for_test(&expr).await
|
||||
&& let Some(val) = v.get("value")
|
||||
&& !val.is_null()
|
||||
{
|
||||
return Some(val.clone());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// IP 封禁 enforcement:fetch POST 到云元数据 IP 169.254.169.254 被防火墙 failRequest →
|
||||
/// fetch 快速 reject。证明 `Fetch.enable` 拦截 + `is_blocked_ip` 在真请求路径生效。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn firewall_blocks_metadata_ip_on_real_fetch() {
|
||||
let backend = common::build_backend_for_fixture("e5-blockip").await;
|
||||
|
||||
backend
|
||||
.navigate(&common::fixture_url("firewall.html"), false)
|
||||
.await
|
||||
.expect("navigate firewall.html");
|
||||
|
||||
// 触发 fetch(fire-and-forget;JS 把结果填进 window.__e5.steps.blockedIp)。
|
||||
backend
|
||||
.__eval_page_world_for_test("window.__e5BlockedIpFetch(); true")
|
||||
.await
|
||||
.expect("kick off blockedIp fetch");
|
||||
|
||||
let result = poll_step(&backend, "blockedIp", 15_000)
|
||||
.await
|
||||
.expect("blockedIp fetch should settle within 15s (firewall should reject it fast)");
|
||||
eprintln!("blockedIp step = {result}");
|
||||
|
||||
// 被防火墙 failRequest{BlockedByClient} → fetch reject。
|
||||
let rejected = result.get("rejected").and_then(Value::as_bool).unwrap_or(false);
|
||||
let ms = result.get("ms").and_then(Value::as_f64).unwrap_or(f64::MAX);
|
||||
eprintln!("blockedIp: rejected={rejected} ms={ms:.0}");
|
||||
|
||||
assert!(
|
||||
rejected,
|
||||
"fetch to metadata IP 169.254.169.254 must be REJECTED by the egress firewall \
|
||||
(Fetch.failRequest), got {result}"
|
||||
);
|
||||
// 快速 reject(防火墙阻断)vs 慢超时(无防火墙时元数据 IP 离线会挂很久)。失败请求阶段拦截近乎
|
||||
// 瞬时——给 5s 上限(远短于 TCP connect 超时的数十秒),证明是防火墙而非超时。
|
||||
assert!(
|
||||
ms < 5_000.0,
|
||||
"blocked fetch should reject FAST (firewall failRequest), not slow-timeout; got {ms:.0}ms"
|
||||
);
|
||||
}
|
||||
|
||||
/// 跨域 POST 拦截链路活着:fetch POST(带表单 body)到跨域 host 经过拦截 handler 被处理,请求不被
|
||||
/// 永久挂起。**P3-D2 后**:默认(无 `EgressApprover` 注入)下被门控请求**fail-closed**(failRequest,
|
||||
/// 闭合 P2 泄漏窗口)而非 E5 旧的 continue——但 fetch 仍在合理时间内 settle(被 fail 即 reject)。关键
|
||||
/// 不变量:requestPaused 对跨域 POST 触发了且请求未永久卡住(无论 continue 还是 fail,都在有界时间内
|
||||
/// 应答)。批准/拒绝/超时的裁决分支由 `egress_gated_post_*` D2 测试 + 纯逻辑单测覆盖。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn firewall_intercepts_cross_origin_post_without_hanging() {
|
||||
let backend = common::build_backend_for_fixture("e5-crosspost").await;
|
||||
|
||||
backend
|
||||
.navigate(&common::fixture_url("firewall.html"), false)
|
||||
.await
|
||||
.expect("navigate firewall.html");
|
||||
|
||||
backend
|
||||
.__eval_page_world_for_test("window.__e5CrossOriginPost(); true")
|
||||
.await
|
||||
.expect("kick off crossPost fetch");
|
||||
|
||||
// 拦截 handler 对它做出裁决(D2 默认 fail-closed → failRequest → reject)后 settle——关键是它在
|
||||
// 有界时间内 settle(不永久挂起)。
|
||||
let result = poll_step(&backend, "crossPost", 15_000)
|
||||
.await
|
||||
.expect(
|
||||
"cross-origin POST should be processed by the firewall (D2: fail-closed by default) and \
|
||||
settle, not hang forever",
|
||||
);
|
||||
eprintln!("crossPost step = {result}");
|
||||
// ok==true 即该步 JS 跑完(不管 reject 与否)——证明请求经过了拦截链路且未被永久卡。
|
||||
let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
||||
assert!(
|
||||
ok,
|
||||
"cross-origin POST fetch should be processed by the interception loop and settle, got {result}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **P3-G1 注入链端到端**:注入一个**与 default 不同**的 `FirewallConfig`(关掉跨域 POST 门控)→
|
||||
/// build engine → 读回引擎持有的配置,断言**注入值真的到达了引擎**(而非被硬编码 `default()` 吞掉)。
|
||||
///
|
||||
/// 这是 G1「链路打通」的最小充分证据:default 与自定义在**外部可观测行为**上难以快速区分(跨域 POST
|
||||
/// 离线下都会 settle),故直接读回引擎构造期注入的配置快照([`CdpBackend::firewall_config_for_test`],
|
||||
/// 与 `spawn_fetch_firewall_loop` 消费的同一份值)断言注入生效。链路:
|
||||
/// `build_backend_for_fixture_with_firewall` → `from_launched(.., firewall)` →
|
||||
/// `spawn_fetch_firewall_loop(conn, firewall)`(不再硬编码 default)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn injected_firewall_config_reaches_backend() {
|
||||
use nomi_browser_engine::firewall::FirewallConfig;
|
||||
|
||||
// 自定义:IP 封禁仍开(SSRF 防护恒应开),但**关掉**跨域 POST 门控——与 default 明显不同。
|
||||
let custom = FirewallConfig {
|
||||
block_private_ips: true,
|
||||
gate_cross_origin_post: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert_ne!(
|
||||
custom,
|
||||
FirewallConfig::default(),
|
||||
"test sentinel must differ from default to prove injection"
|
||||
);
|
||||
|
||||
// P3-D1:FirewallConfig 不再 Copy(含 Vec 域名策略字段)→ clone 进 build 调用,后续仍可断言。
|
||||
let backend = common::build_backend_for_fixture_with_firewall("g1-inject", custom.clone()).await;
|
||||
|
||||
// 读回引擎构造期注入的配置——必须等于我们注入的自定义值(证明 G1 链路把注入值透传到了引擎,
|
||||
// 而非沿用硬编码 default)。
|
||||
let seen = backend.firewall_config_for_test();
|
||||
assert_eq!(
|
||||
seen, custom,
|
||||
"injected FirewallConfig must reach the engine (G1 injection chain), got {seen:?}"
|
||||
);
|
||||
// 显式反证:读回值**不等于** default(若链路仍硬编码 default,此处会失败)。
|
||||
assert_ne!(
|
||||
seen,
|
||||
FirewallConfig::default(),
|
||||
"engine must NOT be using hardcoded FirewallConfig::default() — G1 injection chain broken"
|
||||
);
|
||||
}
|
||||
|
||||
// ── P3-D2:GatePost 悬挂等审批(批准 continue / 拒绝 fail / 预览不含值)───────────────────
|
||||
//
|
||||
// 这些 #[ignore] 真 chrome 测试验「被门控的跨域 POST 在引擎层**悬挂**(不立即 settle),经注入的
|
||||
// EgressApprover 取裁决后 continue/fail」。悬挂机制 + always_allow + fail-closed 的**纯逻辑**已在
|
||||
// firewall::tests / handle_paused_request 的 spawn 逻辑覆盖;此处的集成在真请求路径上额外确认:
|
||||
// (a) approver 真被调用且收到正确预览(host + 字段名,绝不含值);(b) 据裁决 continue/fail。
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nomi_browser_engine::firewall::{
|
||||
EgressApprover, EgressVerdict, FirewallConfig, HostResolver, PostPreview,
|
||||
};
|
||||
|
||||
/// 记录型审批者:捕获收到的预览(验 approver 真被调用 + 预览形态),按构造时给定的裁决应答。
|
||||
struct RecordingApprover {
|
||||
verdict: EgressVerdict,
|
||||
seen: Arc<Mutex<Vec<PostPreview>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl EgressApprover for RecordingApprover {
|
||||
async fn approve_egress(&self, preview: &PostPreview) -> EgressVerdict {
|
||||
self.seen.lock().unwrap().push(preview.clone());
|
||||
self.verdict
|
||||
}
|
||||
}
|
||||
|
||||
/// **Fake DNS resolver**(SD-1 测试隔离):把任意 host 解析到一个固定 IP 列表——完全不碰真实网络。
|
||||
/// 跨域 POST 的目标域要先过 DNS→IP SSRF 守卫才到 approver;用 fake 把探针域映射到**公网 IP**(守卫
|
||||
/// 放行→抵 approver)或**私网 IP**(守卫 fail-closed→approver 之前就 Block)以精确验这一关键交互。
|
||||
/// (此前 resolver 在 cdp.rs 硬编码 TokioResolver,离线伪域 NXDOMAIN 被守卫提前 fail-closed,approver
|
||||
/// 永不被咨询——SD-1 落在 P3-D2 之后静默打破了这两个 approver 测试。)
|
||||
struct FakeResolver {
|
||||
ips: Vec<std::net::IpAddr>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl HostResolver for FakeResolver {
|
||||
async fn resolve(&self, _host: &str) -> std::io::Result<Vec<std::net::IpAddr>> {
|
||||
Ok(self.ips.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// 把探针域映射到一个**公网 IP**(93.184.216.34 = example.com 真实 IP,非私网/回环/链路本地)的 fake
|
||||
/// resolver → SSRF 守卫放行 → 跨域 POST 抵达 approver(生产里真实 exfil 目标正是解析到公网 IP)。
|
||||
fn public_ip_resolver() -> Arc<dyn HostResolver> {
|
||||
Arc::new(FakeResolver {
|
||||
ips: vec!["93.184.216.34".parse().unwrap()],
|
||||
})
|
||||
}
|
||||
|
||||
/// 批准(Continue)→ 被门控的跨域 POST 被 continueRequest 放行(离线下仍会因无网络 reject,但关键是
|
||||
/// 经审批后被放行、approver 收到了正确预览)。预览 host 命中目标、含字段名、**绝不含字段值**(安全红线)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn egress_gated_post_approved_continues_and_approver_sees_redacted_preview() {
|
||||
let seen = Arc::new(Mutex::new(Vec::<PostPreview>::new()));
|
||||
let approver: Arc<dyn EgressApprover> = Arc::new(RecordingApprover {
|
||||
verdict: EgressVerdict::Continue,
|
||||
seen: seen.clone(),
|
||||
});
|
||||
let backend = common::build_backend_for_fixture_with_egress(
|
||||
"d2-approve",
|
||||
FirewallConfig::default(),
|
||||
Some(approver),
|
||||
Some(public_ip_resolver()),
|
||||
)
|
||||
.await;
|
||||
|
||||
backend
|
||||
.navigate(&common::fixture_url("firewall.html"), false)
|
||||
.await
|
||||
.expect("navigate firewall.html");
|
||||
backend
|
||||
.__eval_page_world_for_test("window.__e5CrossOriginPost(); true")
|
||||
.await
|
||||
.expect("kick off crossPost fetch");
|
||||
|
||||
// 悬挂 → 审批(Continue)→ continueRequest → fetch settle(离线 reject)。批准后请求被放行,
|
||||
// 故在有界时间内 settle。
|
||||
let result = poll_step(&backend, "crossPost", 15_000)
|
||||
.await
|
||||
.expect("approved cross-origin POST should be released by the approver and settle");
|
||||
assert!(result.get("ok").and_then(Value::as_bool).unwrap_or(false), "step ran: {result}");
|
||||
|
||||
// approver 真被调用且收到正确预览(host = 跨域目标),且预览**绝不含字段值**(hunter2/alice)。
|
||||
let previews = seen.lock().unwrap().clone();
|
||||
assert!(!previews.is_empty(), "approver must have been consulted for the gated cross-origin POST");
|
||||
let p = &previews[0];
|
||||
assert_eq!(p.host, "e5-cross-origin-probe.example.com", "preview host = cross-origin target");
|
||||
assert!(p.field_names.iter().any(|n| n == "username"), "preview should carry field NAMES: {p:?}");
|
||||
let serialized = serde_json::to_string(p).unwrap();
|
||||
assert!(!serialized.contains("hunter2"), "preview MUST NOT contain field VALUE: {serialized}");
|
||||
assert!(!serialized.contains("alice"), "preview MUST NOT contain field VALUE: {serialized}");
|
||||
}
|
||||
|
||||
/// 拒绝(Fail)→ 被门控的跨域 POST 被 failRequest(fail-closed,泄漏窗口闭合)→ fetch reject。关键
|
||||
/// 不变量:approver 返回 Fail 后请求被**阻断**(不放行),且请求在有界时间内 settle(不永久挂起)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn egress_gated_post_denied_fails_closed() {
|
||||
let seen = Arc::new(Mutex::new(Vec::<PostPreview>::new()));
|
||||
let approver: Arc<dyn EgressApprover> = Arc::new(RecordingApprover {
|
||||
verdict: EgressVerdict::Fail,
|
||||
seen: seen.clone(),
|
||||
});
|
||||
let backend = common::build_backend_for_fixture_with_egress(
|
||||
"d2-deny",
|
||||
FirewallConfig::default(),
|
||||
Some(approver),
|
||||
Some(public_ip_resolver()),
|
||||
)
|
||||
.await;
|
||||
|
||||
backend
|
||||
.navigate(&common::fixture_url("firewall.html"), false)
|
||||
.await
|
||||
.expect("navigate firewall.html");
|
||||
backend
|
||||
.__eval_page_world_for_test("window.__e5CrossOriginPost(); true")
|
||||
.await
|
||||
.expect("kick off crossPost fetch");
|
||||
|
||||
let result = poll_step(&backend, "crossPost", 15_000)
|
||||
.await
|
||||
.expect("denied cross-origin POST should be failed-closed and settle (reject), not hang");
|
||||
eprintln!("denied crossPost = {result}");
|
||||
// 被 failRequest → reject。fixture 在 reject 分支记 rejected:true(且 ok:true)。
|
||||
assert!(result.get("ok").and_then(Value::as_bool).unwrap_or(false), "step ran: {result}");
|
||||
assert!(
|
||||
result.get("rejected").and_then(Value::as_bool).unwrap_or(false),
|
||||
"a denied (fail-closed) cross-origin POST must be REJECTED (failRequest), not allowed: {result}"
|
||||
);
|
||||
assert!(!seen.lock().unwrap().is_empty(), "approver must have been consulted before failing closed");
|
||||
}
|
||||
|
||||
/// **SSRF 守卫优先于 approver**(SD-1 × P3-D2 交互不变量,回归锁):跨域 POST 的目标域解析到**私网 IP**
|
||||
/// → DNS→IP SSRF 守卫在 approver **之前**就硬 Block(failRequest)→ approver **永不**被咨询。这是有意
|
||||
/// 的安全次序——绝不让人去「批准」一个发往内网/元数据 IP 的出口(即便审批者会点同意也轮不到它)。
|
||||
///
|
||||
/// 此测试同时是上面两个 approver 测试静默回归的**根因锁**:SD-1 落在 P3-D2 之后,离线伪域走真实
|
||||
/// DNS 返 NXDOMAIN → 守卫 fail-closed 提前 Block → approver 没被咨询。当时 resolver 在引擎里硬编码、
|
||||
/// 测试无从注入,故这条「守卫先于 approver」的交互从未被覆盖,回归才会静默。现 resolver 可注入后补此锁。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn egress_gated_post_to_private_ip_is_ssrf_blocked_before_approver() {
|
||||
let seen = Arc::new(Mutex::new(Vec::<PostPreview>::new()));
|
||||
// 即便审批者会 Continue,私网目标也应在它之前被 SSRF 守卫拦下 → 它根本不该被咨询。
|
||||
let approver: Arc<dyn EgressApprover> = Arc::new(RecordingApprover {
|
||||
verdict: EgressVerdict::Continue,
|
||||
seen: seen.clone(),
|
||||
});
|
||||
// fake resolver:把探针域解析到**私网 IP**(10.0.0.5 ∈ RFC1918)→ check_dns_ssrf fail-closed Block。
|
||||
let resolver: Arc<dyn HostResolver> = Arc::new(FakeResolver {
|
||||
ips: vec!["10.0.0.5".parse().unwrap()],
|
||||
});
|
||||
let backend = common::build_backend_for_fixture_with_egress(
|
||||
"d2-ssrf-precedence",
|
||||
FirewallConfig::default(),
|
||||
Some(approver),
|
||||
Some(resolver),
|
||||
)
|
||||
.await;
|
||||
|
||||
backend
|
||||
.navigate(&common::fixture_url("firewall.html"), false)
|
||||
.await
|
||||
.expect("navigate firewall.html");
|
||||
backend
|
||||
.__eval_page_world_for_test("window.__e5CrossOriginPost(); true")
|
||||
.await
|
||||
.expect("kick off crossPost fetch");
|
||||
|
||||
let result = poll_step(&backend, "crossPost", 15_000)
|
||||
.await
|
||||
.expect("SSRF-blocked cross-origin POST should fail-closed and settle (reject), not hang");
|
||||
eprintln!("ssrf-precedence crossPost = {result}");
|
||||
assert!(result.get("ok").and_then(Value::as_bool).unwrap_or(false), "step ran: {result}");
|
||||
// 目标解析到私网 IP → SSRF 守卫 failRequest → fetch reject。
|
||||
assert!(
|
||||
result.get("rejected").and_then(Value::as_bool).unwrap_or(false),
|
||||
"a cross-origin POST whose target resolves to a PRIVATE IP must be SSRF-blocked (rejected): {result}"
|
||||
);
|
||||
// 核心不变量:守卫先于 approver——私网目标在 approver 之前被 Block,approver 永不被咨询。
|
||||
assert!(
|
||||
seen.lock().unwrap().is_empty(),
|
||||
"SSRF guard MUST block BEFORE consulting the approver (never ask a human to approve egress to a private IP)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! **P2 F-actions:upload_file / download / save_as_pdf / extract 端到端集成**(`#[ignore]`,本机/打包 chrome)。
|
||||
//!
|
||||
//! 验证补全的 4 个动作(P2 DoD「完整动作空间 + extract」)真在 Chrome 上跑通:
|
||||
//!
|
||||
//! 1. **upload_file**:navigate upload.html(含 `<input type=file>`)→ observe → 取 file input ref →
|
||||
//! `act(UploadFile{ref, [临时文件]})`(经 `DOM.setFileInputFiles` 绕系统文件框)→ 读回
|
||||
//! `input.files.length == 1` + files[0].name 含被上传文件名(verify 锚点 `count`/`first`)。
|
||||
//! 2. **download**:navigate download.html → `act(Download{url: data:URL})` → 文件落隔离 downloads
|
||||
//! 目录 + size>0(复用 E4 沙箱)。可执行 url 红线拒走纯逻辑单测(`download::tests`)+ E4 集成已覆盖
|
||||
//! downloadWillBegin 取消链路,这里只验良性下载落盘。
|
||||
//! 3. **save_as_pdf**:navigate 任意 fixture → `act(SaveAsPdf)` → PDF 文件落隔离 downloads 目录 +
|
||||
//! size>0(headless 下 printToPDF 可靠;本机默认 headless)。
|
||||
//! 4. **extract**:navigate c3.html(含 `password value="hunter2plain"`)→ `act(Extract{schema})` →
|
||||
//! 返结构化页面表示(含 aria snapshot + 可见文本)+ 回显 schema;**脱敏验证**:输出不含明文
|
||||
//! `hunter2plain`(redact + wrap_untrusted 守住,LLM 永不见 secret)。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=C:\Program Files\Google\Chrome\Application\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(upload) | test(download_action) | test(pdf) | test(extract)'
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use nomi_browser_engine::progress::Progress;
|
||||
use nomi_browser_engine::{ActSpec, BrowserEngine, ObserveOpts};
|
||||
|
||||
mod common;
|
||||
|
||||
/// **upload_file**:DOM.setFileInputFiles 把本地文件路径塞进 `<input type=file>`(绕系统文件框)。
|
||||
/// 验收:act 成功 + verify 锚点 `count==1` + `first` 含上传文件名。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn upload_file_sets_file_input_via_set_file_input_files() {
|
||||
// 准备一个真实临时文件供上传(内容任意,setFileInputFiles 要文件真存在)。
|
||||
let tmp = std::env::temp_dir().join(format!("nomifun-upload-{}.txt", std::process::id()));
|
||||
std::fs::write(&tmp, b"upload fixture payload").expect("write temp upload file");
|
||||
let tmp_str = tmp.to_string_lossy().into_owned();
|
||||
let expected_name = tmp.file_name().unwrap().to_string_lossy().into_owned();
|
||||
|
||||
let backend = common::build_backend_for_fixture("facts-upload").await;
|
||||
backend
|
||||
.navigate(&common::fixture_url("upload.html"), false)
|
||||
.await
|
||||
.expect("navigate upload.html");
|
||||
|
||||
let obs = backend.observe(&ObserveOpts::default()).await.expect("observe");
|
||||
eprintln!("=== upload fixture entries ===");
|
||||
for e in &obs.entries {
|
||||
eprintln!(" ref={} role={} name={:?}", e.r#ref, e.role, e.name);
|
||||
}
|
||||
// file input 的 aria role 多为 "button"(file 控件)或带 "Attachment" accname;按 accname 找最稳。
|
||||
let input = obs
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.name.contains("Attachment"))
|
||||
.expect("fixture should expose the file input with accname \"Attachment\"");
|
||||
eprintln!("file input ref = {}", input.r#ref);
|
||||
|
||||
let p = Progress::new(Duration::from_secs(30));
|
||||
let res = backend
|
||||
.act(
|
||||
&ActSpec::UploadFile {
|
||||
r#ref: input.r#ref.clone(),
|
||||
paths: vec![std::path::PathBuf::from(&tmp_str)],
|
||||
},
|
||||
&p,
|
||||
)
|
||||
.await
|
||||
.expect("upload_file should succeed");
|
||||
eprintln!("upload result: success={} message={}", res.success, res.message);
|
||||
assert!(res.success, "upload_file should report success");
|
||||
|
||||
// verify 锚点:after_anchor = {count, first}。count==1 且 first 含上传文件名(绕系统框真设进去了)。
|
||||
let after = res.effect.after_anchor.expect("upload should carry a files anchor");
|
||||
eprintln!("after anchor = {after}");
|
||||
assert_eq!(
|
||||
after.get("count").and_then(|v| v.as_u64()),
|
||||
Some(1),
|
||||
"file input should hold exactly 1 file"
|
||||
);
|
||||
let first = after.get("first").and_then(|v| v.as_str()).unwrap_or("");
|
||||
assert!(
|
||||
first.contains(&expected_name) || expected_name.contains(first),
|
||||
"files[0].name {first:?} should match the uploaded file {expected_name:?}"
|
||||
);
|
||||
assert!(res.effect.changed, "upload should report changed (file now set)");
|
||||
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
|
||||
/// **download**:act(Download{url: data:URL}) → 文件落隔离 downloads 目录 + size>0(复用 E4 沙箱)。
|
||||
/// data: URL 自带 download 属性语义(注入的 `<a download>` href=data: → chrome 当附件下载)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn download_action_lands_file_in_sandbox() {
|
||||
let (backend, download_dir) =
|
||||
common::build_backend_for_fixture_with_downloads("facts-download").await;
|
||||
eprintln!("download_dir = {}", download_dir.display());
|
||||
|
||||
// 任意可下载页(需有 document.body 供注入 <a>)。download.html 即可(它有 body)。
|
||||
backend
|
||||
.navigate(&common::fixture_url("download.html"), false)
|
||||
.await
|
||||
.expect("navigate download.html");
|
||||
|
||||
// data: URL 内容(>0 字节,便于断言 size>0)。act(Download) 注入隐藏 <a download href=this> click。
|
||||
let url = "data:text/plain;charset=utf-8,F-actions%20download%20payload%0Aline2%0A";
|
||||
let p = Progress::new(Duration::from_secs(30));
|
||||
let res = backend
|
||||
.act(&ActSpec::Download { url: url.to_string() }, &p)
|
||||
.await
|
||||
.expect("download action should not error");
|
||||
eprintln!("download result: success={} message={}", res.success, res.message);
|
||||
|
||||
// act_download 自身已轮询落盘(success=true 即落盘成功 + after 锚点带文件名/字节数);如实断言。
|
||||
if res.success {
|
||||
let after = res.effect.after_anchor.expect("successful download carries a file anchor");
|
||||
eprintln!("after anchor = {after}");
|
||||
assert!(
|
||||
after.get("bytes").and_then(|v| v.as_u64()).unwrap_or(0) > 0,
|
||||
"downloaded file should be >0 bytes"
|
||||
);
|
||||
// 目录里也应能看到那个文件(双重确认,非 .crdownload)。
|
||||
let any_completed = std::fs::read_dir(&download_dir)
|
||||
.ok()
|
||||
.map(|rd| {
|
||||
rd.flatten().any(|e| {
|
||||
let p = e.path();
|
||||
p.extension().and_then(|x| x.to_str()) != Some("crdownload")
|
||||
&& std::fs::metadata(&p).map(|m| m.is_file() && m.len() > 0).unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
assert!(any_completed, "a completed download should be present in the sandbox dir");
|
||||
} else {
|
||||
// 若环境/时序未能在窗口内落盘(良性 false),至少不应 panic——report 真实结果供诊断。
|
||||
eprintln!("NOTE: download did not land in the wait window; message: {}", res.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// **save_as_pdf**:act(SaveAsPdf) → PDF 文件落隔离 downloads 目录 + size>0(headless 下 printToPDF 可靠)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn save_as_pdf_writes_nonempty_pdf_to_sandbox() {
|
||||
let (backend, download_dir) =
|
||||
common::build_backend_for_fixture_with_downloads("facts-pdf").await;
|
||||
eprintln!("download_dir = {}", download_dir.display());
|
||||
|
||||
backend
|
||||
.navigate(&common::fixture_url("c3.html"), false)
|
||||
.await
|
||||
.expect("navigate c3.html");
|
||||
|
||||
let p = Progress::new(Duration::from_secs(30));
|
||||
let res = backend.act(&ActSpec::SaveAsPdf, &p).await;
|
||||
eprintln!("save_as_pdf result = {res:?}");
|
||||
let res = res.expect("save_as_pdf should not error in headless");
|
||||
// headful 受限时本机默认 headless,故应成功;若意外失败,report 供诊断(TODO(verify-headful))。
|
||||
assert!(res.success, "save_as_pdf should succeed (headless printToPDF). message: {}", res.message);
|
||||
|
||||
let after = res.effect.after_anchor.expect("save_as_pdf carries a pdf_path/bytes anchor");
|
||||
eprintln!("after anchor = {after}");
|
||||
let bytes = after.get("bytes").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
assert!(bytes > 0, "PDF should be >0 bytes, got {bytes}");
|
||||
let pdf_path = after.get("pdf_path").and_then(|v| v.as_str()).expect("pdf_path");
|
||||
// 文件真在隔离 downloads 目录里 + 真有内容(%PDF header)。
|
||||
let meta = std::fs::metadata(pdf_path).expect("pdf file should exist on disk");
|
||||
assert!(meta.len() > 0, "pdf on disk should be non-empty");
|
||||
assert!(
|
||||
std::path::Path::new(pdf_path).starts_with(&download_dir),
|
||||
"pdf must be written inside the sandboxed downloads dir, got {pdf_path}"
|
||||
);
|
||||
let head = std::fs::read(pdf_path).expect("read pdf");
|
||||
assert_eq!(&head[..4.min(head.len())], b"%PDF", "file should be a real PDF");
|
||||
}
|
||||
|
||||
/// **save_as_pdf(headful 校验)**:Task 6 / 解 `TODO(verify-headful-printToPDF)`——在 **headful**
|
||||
/// (真可见窗口)Chrome 下跑 `act(SaveAsPdf)`,验 `Page.printToPDF` 是否仍产非空 PDF 落隔离 downloads。
|
||||
/// headful Chrome 历史上对 printToPDF 有限制;若本机/版本受限,引擎**如实**返回 `success=false`
|
||||
/// (绝不 panic、绝不写半截文件),测试据实记录。**绿条件 = 非空真 PDF *或* 干净的 success=false**
|
||||
/// (二者都证明引擎契约正确:headful 受限只降级为如实失败,不破坏不变量)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机 chrome + 显示器(headful):set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn save_as_pdf_headful_writes_pdf_or_reports_cleanly() {
|
||||
let (backend, download_dir) =
|
||||
common::build_backend_for_fixture_headful_with_downloads("facts-pdf-headful").await;
|
||||
eprintln!("[headful] download_dir = {}", download_dir.display());
|
||||
|
||||
backend
|
||||
.navigate(&common::fixture_url("c3.html"), false)
|
||||
.await
|
||||
.expect("navigate c3.html (headful)");
|
||||
|
||||
let p = Progress::new(Duration::from_secs(30));
|
||||
let res = backend.act(&ActSpec::SaveAsPdf, &p).await;
|
||||
eprintln!("[headful] save_as_pdf result = {res:?}");
|
||||
|
||||
// 引擎契约:headful 受限也绝不把 transport error 透到 Result 层——printToPDF 失败经
|
||||
// map_transport_err → Other → act 骨架转成 success=false 的 ActResult。故 act() 本身应 Ok。
|
||||
let res = res
|
||||
.expect("save_as_pdf must not surface a transport error (headful restriction → success=false)");
|
||||
|
||||
if res.success {
|
||||
// headful 也能产 PDF(现代 Chrome 多数支持)——验落盘真 PDF。
|
||||
let after = res
|
||||
.effect
|
||||
.after_anchor
|
||||
.expect("success → save_as_pdf carries a pdf_path/bytes anchor");
|
||||
eprintln!("[headful] after anchor = {after}");
|
||||
let bytes = after.get("bytes").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
assert!(bytes > 0, "[headful] PDF should be >0 bytes, got {bytes}");
|
||||
let pdf_path = after.get("pdf_path").and_then(|v| v.as_str()).expect("pdf_path");
|
||||
assert!(
|
||||
std::path::Path::new(pdf_path).starts_with(&download_dir),
|
||||
"[headful] pdf must be written inside the sandboxed downloads dir, got {pdf_path}"
|
||||
);
|
||||
let head = std::fs::read(pdf_path).expect("read headful pdf");
|
||||
assert!(!head.is_empty(), "[headful] pdf on disk should be non-empty");
|
||||
assert_eq!(&head[..4.min(head.len())], b"%PDF", "[headful] file should be a real PDF");
|
||||
eprintln!(
|
||||
"[headful] VERIFIED: Page.printToPDF works in headful — {bytes} bytes, %PDF header OK"
|
||||
);
|
||||
} else {
|
||||
// headful 受限:引擎如实 success=false(不 panic、不写半截文件)。这同样是被验证的正确行为。
|
||||
eprintln!(
|
||||
"[headful] NOTE: printToPDF appears restricted in headful on this Chrome — \
|
||||
engine reported success=false cleanly (no panic, no partial file): {}",
|
||||
res.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **extract**:act(Extract{schema}) → 结构化页面表示(aria snapshot + 可见文本)+ 回显 schema;
|
||||
/// **脱敏验证**:c3.html 的可见明文 secret(`sk-...` API key / `Bearer ...` token)绝不出现在输出明文里
|
||||
/// (redact + wrap_untrusted 守住,LLM 永不见 secret)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn extract_returns_structured_representation_and_redacts_secrets() {
|
||||
let backend = common::build_backend_for_fixture("facts-extract").await;
|
||||
backend
|
||||
.navigate(&common::fixture_url("c3.html"), false)
|
||||
.await
|
||||
.expect("navigate c3.html");
|
||||
// extract 自身内部 observe,但先 observe 一次确保 ref 表/缓存就绪(与正常用法一致)。
|
||||
backend.observe(&ObserveOpts::default()).await.expect("observe");
|
||||
|
||||
let schema = serde_json::json!({ "title": "string", "fields": ["order", "status"] });
|
||||
let p = Progress::new(Duration::from_secs(30));
|
||||
let res = backend
|
||||
.act(&ActSpec::Extract { schema: schema.clone() }, &p)
|
||||
.await
|
||||
.expect("extract should succeed");
|
||||
eprintln!("extract message (first 600 chars):\n{}", &res.message.chars().take(600).collect::<String>());
|
||||
|
||||
assert!(res.success, "extract should succeed");
|
||||
assert!(!res.effect.changed, "extract is read-only (changed=false)");
|
||||
// 回显 schema(请求字段提示)。
|
||||
assert!(
|
||||
res.message.contains("Requested schema") && res.message.contains("status"),
|
||||
"extract should echo the requested schema"
|
||||
);
|
||||
// deterministic plumbing markers:含 aria snapshot + 可见文本两段。
|
||||
assert!(res.message.contains("accessibility snapshot"), "should include the aria snapshot section");
|
||||
assert!(res.message.contains("visible text"), "should include the visible-text section");
|
||||
// 可见正文(非 secret)应进 extract 输出(确认它真捕获了页面文本,而非空)。
|
||||
assert!(
|
||||
res.message.contains("unique-sentinel-marker"),
|
||||
"extract should capture the page's visible text (sentinel marker)"
|
||||
);
|
||||
// P3 TODO 提示在文案里(引擎层无 LLM)。
|
||||
assert!(res.message.to_uppercase().contains("TODO(P3)"), "should note LLM extraction is P3");
|
||||
|
||||
// **脱敏铁律**:c3.html 的可见明文 secret 绝不出现在喂 LLM 的输出里(redact + wrap 守住)。
|
||||
assert!(
|
||||
!res.message.contains("sk-ABCDEFGHIJ0123456789xyzQRSTUV"),
|
||||
"SECURITY: the API key plaintext must NEVER appear in extract output"
|
||||
);
|
||||
assert!(
|
||||
!res.message.contains("abcdef0123456789ABCDEFghij"),
|
||||
"SECURITY: the Bearer token plaintext must NEVER appear in extract output"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! **IndexedDB storage_state 捕获端到端集成**(`#[ignore]`,需 `NOMIFUN_CHROME_BINARY`)。
|
||||
//!
|
||||
//! 验证 IndexedDB 完整序列化(含二进制值 base64 哨兵编码)的 capture 链:
|
||||
//! - 在真实 origin 页面写 IndexedDB 记录(含 ArrayBuffer 二进制值)→ `capture_index_db` 收集。
|
||||
//!
|
||||
//! 手动跑:
|
||||
//! NOMIFUN_CHROME_BINARY="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(capture_index_db)'
|
||||
|
||||
mod common;
|
||||
|
||||
use nomi_browser_engine::BrowserEngine;
|
||||
|
||||
const ORIGIN: &str = "https://example.com";
|
||||
|
||||
/// **capture_index_db_collects_records**: 在 example.com 写 2 条 IDB 记录(一普通/一二进制)→
|
||||
/// capture 返回 IndexedDbDump 含这 2 条记录 + 正确的 db name/version/store name/keyPath。
|
||||
#[tokio::test]
|
||||
#[ignore = "需 NOMIFUN_CHROME_BINARY(真 Chrome):IndexedDB capture 集成"]
|
||||
async fn capture_index_db_collects_records() {
|
||||
let backend = common::build_backend_for_fixture("idb-cap").await;
|
||||
backend
|
||||
.navigate(ORIGIN, false)
|
||||
.await
|
||||
.expect("navigate to example.com");
|
||||
|
||||
// Inject IDB with 2 records (one with binary ArrayBuffer).
|
||||
let seed_script = r#"(async () => {
|
||||
const req = indexedDB.open("testdb", 2);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains("items")) {
|
||||
db.createObjectStore("items", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
const tx = db.transaction("items", "readwrite");
|
||||
const store = tx.objectStore("items");
|
||||
store.put({ id: 1, name: "hello", tags: ["a", "b"] });
|
||||
store.put({ id: 2, data: new Uint8Array([0xCA, 0xFE, 0xBA, 0xBE]).buffer });
|
||||
await new Promise((resolve, reject) => {
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
return "seeded";
|
||||
})()"#;
|
||||
let r = backend
|
||||
.__eval_page_world_await_for_test(seed_script)
|
||||
.await
|
||||
.expect("seed IDB");
|
||||
eprintln!("=== IDB seed result: {r:?}");
|
||||
|
||||
// Capture IndexedDB.
|
||||
let dump = backend
|
||||
.capture_index_db()
|
||||
.await
|
||||
.expect("capture_index_db must succeed");
|
||||
let dump = dump.expect("IndexedDB dump must be Some on https://example.com");
|
||||
|
||||
eprintln!("=== capture_index_db result: {dump:?}");
|
||||
|
||||
assert!(!dump.databases.is_empty(), "must capture at least 1 database");
|
||||
let db = dump
|
||||
.databases
|
||||
.iter()
|
||||
.find(|d| d.name == "testdb")
|
||||
.expect("testdb found");
|
||||
assert_eq!(db.version, 2);
|
||||
assert!(!db.stores.is_empty(), "must have stores");
|
||||
let store = db
|
||||
.stores
|
||||
.iter()
|
||||
.find(|s| s.name == "items")
|
||||
.expect("items store");
|
||||
assert_eq!(store.key_path.as_deref(), Some("id"));
|
||||
assert_eq!(store.records.len(), 2, "must have 2 records");
|
||||
|
||||
// Verify first record content.
|
||||
let rec1 = store
|
||||
.records
|
||||
.iter()
|
||||
.find(|r| r.get("id") == Some(&serde_json::json!(1)));
|
||||
assert!(rec1.is_some(), "record with id=1 must exist");
|
||||
let rec1 = rec1.unwrap();
|
||||
assert_eq!(rec1.get("name"), Some(&serde_json::json!("hello")));
|
||||
|
||||
// Verify binary record contains __b64__ sentinel.
|
||||
let rec2 = store
|
||||
.records
|
||||
.iter()
|
||||
.find(|r| r.get("id") == Some(&serde_json::json!(2)));
|
||||
assert!(rec2.is_some(), "record with id=2 must exist");
|
||||
let rec2 = rec2.unwrap();
|
||||
let data_field = rec2.get("data").expect("data field in record 2");
|
||||
// Should be a base64 sentinel: {"__b64__": "..."}
|
||||
assert!(
|
||||
data_field.get("__b64__").is_some(),
|
||||
"binary field must be encoded as __b64__ sentinel, got: {data_field}"
|
||||
);
|
||||
// Decode and verify bytes.
|
||||
let decoded =
|
||||
nomi_browser_engine::decode_binary_sentinel(data_field).expect("decode base64 sentinel");
|
||||
assert_eq!(decoded, vec![0xCA, 0xFE, 0xBA, 0xBE], "binary must round-trip");
|
||||
|
||||
eprintln!("=== PASS: capture_index_db_collects_records");
|
||||
}
|
||||
|
||||
/// **restore_index_db_writes_back**: build a dump, restore to a new engine, verify records present.
|
||||
#[tokio::test]
|
||||
#[ignore = "需 NOMIFUN_CHROME_BINARY(真 Chrome):IndexedDB restore 集成"]
|
||||
async fn restore_index_db_writes_back() {
|
||||
use nomi_browser_engine::storage_state::{
|
||||
IdbDatabase, IdbStore, IndexedDbDump, OriginStorage, StorageState,
|
||||
};
|
||||
use nomi_browser_engine::encode_binary_sentinel;
|
||||
|
||||
// Build a StorageState with IndexedDB dump to restore.
|
||||
let dump = IndexedDbDump {
|
||||
databases: vec![IdbDatabase {
|
||||
name: "restoredb".into(),
|
||||
version: 1,
|
||||
stores: vec![IdbStore {
|
||||
name: "docs".into(),
|
||||
key_path: Some("id".into()),
|
||||
auto_increment: false,
|
||||
records: vec![
|
||||
serde_json::json!({"id": "doc1", "title": "First"}),
|
||||
serde_json::json!({"id": "doc2", "payload": encode_binary_sentinel(&[0xDE, 0xAD])}),
|
||||
],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let state = StorageState {
|
||||
cookies: vec![],
|
||||
local_storage: vec![OriginStorage {
|
||||
origin: ORIGIN.into(),
|
||||
local_storage: vec![],
|
||||
index_db: Some(dump),
|
||||
}],
|
||||
};
|
||||
|
||||
// Engine: navigate to the origin, restore IndexedDB, then read back.
|
||||
let backend = common::build_backend_for_fixture("idb-restore").await;
|
||||
backend.navigate(ORIGIN, false).await.expect("navigate");
|
||||
|
||||
// Restore IndexedDB for this origin.
|
||||
backend
|
||||
.restore_index_db(&state)
|
||||
.await
|
||||
.expect("restore_index_db must succeed");
|
||||
|
||||
// Verify: read back the records from IndexedDB via page eval.
|
||||
let verify_script = r#"(async () => {
|
||||
const req = indexedDB.open("restoredb", 1);
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
const tx = db.transaction("docs", "readonly");
|
||||
const store = tx.objectStore("docs");
|
||||
const all = await new Promise((resolve, reject) => {
|
||||
const r = store.getAll();
|
||||
r.onsuccess = () => resolve(r.result);
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
db.close();
|
||||
return all.map(rec => {
|
||||
const out = {...rec};
|
||||
if (out.payload instanceof ArrayBuffer) {
|
||||
out.payload = Array.from(new Uint8Array(out.payload));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
})()"#;
|
||||
let result = backend
|
||||
.__eval_page_world_await_for_test(verify_script)
|
||||
.await
|
||||
.expect("verify IDB");
|
||||
let value = result.get("value").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let records = value.as_array().expect("should return array of records");
|
||||
assert_eq!(records.len(), 2, "restored 2 records");
|
||||
|
||||
let doc1 = records
|
||||
.iter()
|
||||
.find(|r| r.get("id") == Some(&serde_json::json!("doc1")));
|
||||
assert!(doc1.is_some(), "doc1 must exist");
|
||||
assert_eq!(
|
||||
doc1.unwrap().get("title"),
|
||||
Some(&serde_json::json!("First"))
|
||||
);
|
||||
|
||||
let doc2 = records
|
||||
.iter()
|
||||
.find(|r| r.get("id") == Some(&serde_json::json!("doc2")));
|
||||
assert!(doc2.is_some(), "doc2 must exist");
|
||||
// Binary payload: restored as ArrayBuffer → we converted to array [0xDE, 0xAD].
|
||||
let payload = doc2.unwrap().get("payload").expect("payload field");
|
||||
let bytes: Vec<u8> = payload
|
||||
.as_array()
|
||||
.expect("array")
|
||||
.iter()
|
||||
.map(|v| v.as_u64().unwrap() as u8)
|
||||
.collect();
|
||||
assert_eq!(bytes, vec![0xDE, 0xAD], "binary payload must round-trip");
|
||||
|
||||
eprintln!("=== PASS: restore_index_db_writes_back");
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
//! **多 origin localStorage 自动遍历恢复端到端集成**(`#[ignore]`,需 `NOMIFUN_CHROME_BINARY`)。
|
||||
//!
|
||||
//! 验证 `restore_all_origins` 能在一个 session 内自动遍历多 origin 并恢复 localStorage,
|
||||
//! 且不与出口防火墙 loop 的 `Fetch.requestPaused` 冲突(用 `addScriptToEvaluateOnNewDocument`)。
|
||||
//!
|
||||
//! 手动跑:
|
||||
//! NOMIFUN_CHROME_BINARY="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(restore_all_origins)'
|
||||
|
||||
mod common;
|
||||
|
||||
use nomi_browser_engine::BrowserEngine;
|
||||
use nomi_browser_engine::storage_state::{LocalStorageItem, OriginStorage, StorageState};
|
||||
|
||||
/// **restore_all_origins_restores_two_origins**: 2-origin localStorage restored in one session
|
||||
/// without the caller pre-navigating each origin.
|
||||
#[tokio::test]
|
||||
#[ignore = "需 NOMIFUN_CHROME_BINARY(真 Chrome):multi-origin localStorage auto-restore"]
|
||||
async fn restore_all_origins_restores_two_origins() {
|
||||
const ORIGIN_A: &str = "https://example.com";
|
||||
const ORIGIN_B: &str = "https://www.iana.org";
|
||||
|
||||
let state = StorageState {
|
||||
cookies: vec![],
|
||||
local_storage: vec![
|
||||
OriginStorage {
|
||||
origin: ORIGIN_A.into(),
|
||||
local_storage: vec![
|
||||
LocalStorageItem { name: "key_a1".into(), value: "val_a1".into() },
|
||||
LocalStorageItem { name: "key_a2".into(), value: "val_a2".into() },
|
||||
],
|
||||
index_db: None,
|
||||
},
|
||||
OriginStorage {
|
||||
origin: ORIGIN_B.into(),
|
||||
local_storage: vec![
|
||||
LocalStorageItem { name: "key_b1".into(), value: "val_b1".into() },
|
||||
],
|
||||
index_db: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let backend = common::build_backend_for_fixture("multi-origin-ls").await;
|
||||
|
||||
// Restore all origins in one call (no manual per-origin navigate by caller).
|
||||
backend
|
||||
.restore_all_origins(&state)
|
||||
.await
|
||||
.expect("restore_all_origins must succeed");
|
||||
|
||||
// ── Verify origin A ──
|
||||
backend.navigate(ORIGIN_A, false).await.expect("nav A");
|
||||
let r = backend
|
||||
.__eval_page_world_for_test("localStorage.getItem('key_a1')")
|
||||
.await
|
||||
.expect("read key_a1");
|
||||
assert_eq!(
|
||||
r.get("value").and_then(|v| v.as_str()),
|
||||
Some("val_a1"),
|
||||
"origin A key_a1 must be restored"
|
||||
);
|
||||
let r = backend
|
||||
.__eval_page_world_for_test("localStorage.getItem('key_a2')")
|
||||
.await
|
||||
.expect("read key_a2");
|
||||
assert_eq!(
|
||||
r.get("value").and_then(|v| v.as_str()),
|
||||
Some("val_a2"),
|
||||
"origin A key_a2 must be restored"
|
||||
);
|
||||
|
||||
// ── Verify origin B ──
|
||||
backend.navigate(ORIGIN_B, false).await.expect("nav B");
|
||||
let r = backend
|
||||
.__eval_page_world_for_test("localStorage.getItem('key_b1')")
|
||||
.await
|
||||
.expect("read key_b1");
|
||||
assert_eq!(
|
||||
r.get("value").and_then(|v| v.as_str()),
|
||||
Some("val_b1"),
|
||||
"origin B key_b1 must be restored"
|
||||
);
|
||||
// Ensure origin B does NOT have origin A's keys (origin isolation).
|
||||
let r = backend
|
||||
.__eval_page_world_for_test("localStorage.getItem('key_a1')")
|
||||
.await
|
||||
.expect("read key_a1 from B");
|
||||
assert_eq!(
|
||||
r.get("value"),
|
||||
Some(&serde_json::Value::Null),
|
||||
"origin B must NOT have origin A's keys (origin isolation)"
|
||||
);
|
||||
|
||||
eprintln!("=== PASS: restore_all_origins_restores_two_origins");
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! **P2 D2:navigate settle 升级端到端集成**(`#[ignore]`,本机/打包 chrome)。
|
||||
//!
|
||||
//! 验证 D2 的成熟导航判定(DESIGN §12 + 裁决⑤):
|
||||
//! - 普通页 navigate → load_state 达 Load/NetworkIdle(视页面是否真静默)。
|
||||
//! - networkidle 永不空闲页(长轮询)→ networkidle 短 cap(~4s)降级返 Load,**不卡 30s**。
|
||||
//! - SPA history.pushState 软导航 → navigatedWithinDocument 降级判定(不重新等 load)。
|
||||
//! - 真站(HTTP)→ http_status==200 填充;redirect 用 URL-normalize 不误判 trailing-slash。
|
||||
//!
|
||||
//! 复用 `tests/common` 的 `build_backend_for_fixture`(勿再复制契约母本)。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=...\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(nav)'
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。
|
||||
//!
|
||||
//! 真实结果(本机首跑会 eprintln 出 load_state/http_status/耗时——填回任务汇报)。
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use nomi_browser_engine::{BrowserEngine, LoadState};
|
||||
|
||||
mod common;
|
||||
|
||||
/// 普通 file:// 页 navigate:settle 阶梯走通,load_state 达 Load(file:// 无 HTTP 故 http_status
|
||||
/// 可能为 None),耗时远小于 30s 总超时。act-c1.html 是静态页(脚本只挂事件,无持续网络)→ 应在
|
||||
/// networkidle 短 cap 内达到 NetworkIdle,或至少 Load。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn nav_normal_page_reaches_load_or_networkidle() {
|
||||
let backend = common::build_backend_for_fixture("nav-normal").await;
|
||||
|
||||
let t0 = Instant::now();
|
||||
let nav = backend
|
||||
.navigate(&common::fixture_url("act-c1.html"), false)
|
||||
.await
|
||||
.expect("navigate act-c1.html");
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
eprintln!(
|
||||
"=== nav_normal_page === final_url={} http_status={:?} redirected={} load_state={} elapsed={:?}",
|
||||
nav.final_url, nav.http_status, nav.redirected, nav.load_state, elapsed
|
||||
);
|
||||
|
||||
// 静态页:应达 Load 或 NetworkIdle(不会停在 commit/DCL——本页极快)。
|
||||
assert!(
|
||||
matches!(nav.load_state, LoadState::Load | LoadState::NetworkIdle),
|
||||
"static page should reach Load/NetworkIdle, got {}",
|
||||
nav.load_state
|
||||
);
|
||||
// file:// 自身不算 redirect(归一化比较:请求 url == final url)。
|
||||
assert!(!nav.redirected, "file:// self-nav must not be flagged redirect");
|
||||
// 整个 navigate 应远小于 30s(即便 networkidle cap 触发也只 +4s)。
|
||||
assert!(
|
||||
elapsed.as_secs() < 15,
|
||||
"navigate took too long ({elapsed:?}); networkidle cap must not blow up nav timeout"
|
||||
);
|
||||
}
|
||||
|
||||
/// networkidle 永不空闲页(长轮询 fixture):networkidle 短 cap(~4s)到点降级返 Load,**绝不**卡到
|
||||
/// 30s。这是裁决⑤ 的核心不变量——长轮询/SSE/WS 站永不 idle 也不能拖垮 navigate。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn nav_never_idle_page_degrades_to_load_within_cap() {
|
||||
let backend = common::build_backend_for_fixture("nav-never-idle").await;
|
||||
|
||||
let t0 = Instant::now();
|
||||
let nav = backend
|
||||
.navigate(&common::fixture_url("never-idle.html"), false)
|
||||
.await
|
||||
.expect("navigate never-idle.html");
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
eprintln!(
|
||||
"=== nav_never_idle === load_state={} elapsed={:?} (networkidle cap degrade expected)",
|
||||
nav.load_state, elapsed
|
||||
);
|
||||
|
||||
// 永不空闲 → networkidle 等不到 → 降级返 Load(良性,不报错)。
|
||||
assert_eq!(
|
||||
nav.load_state,
|
||||
LoadState::Load,
|
||||
"never-idle page must degrade to Load (not NetworkIdle)"
|
||||
);
|
||||
// 关键不变量:cap 独立,总耗时 ~ load + 4s cap,远小于 30s。给宽松上限 20s 防慢机器 flaky,
|
||||
// 但必须显著小于 30s 才能证明「cap 没并入 nav 超时」。
|
||||
assert!(
|
||||
elapsed.as_secs() < 20,
|
||||
"networkidle cap must be independent of 30s nav timeout; elapsed={elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// SPA 软导航:本 fixture load 后自动 history.pushState 改 URL(navigatedWithinDocument,无新文档)。
|
||||
/// navigate 应识别软导航降级路径(不重新等 load 超时),良性返回成功。
|
||||
///
|
||||
/// 注意:navigate 的初始文档 load 与之后的软导航是两件事——navigate 返回时通常已达 Load(初始文档
|
||||
/// 的 load 先到)。本测试主要验证「不报错、final_url 反映软导航后的 URL(若软导航在 navigate 返回
|
||||
/// 前发生)」+ 不卡死。软导航的「不重新等 load」降级路径在 run_settle 内(若软导航先于 load 到达)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn nav_spa_soft_navigation_is_benign() {
|
||||
let backend = common::build_backend_for_fixture("nav-spa").await;
|
||||
|
||||
let t0 = Instant::now();
|
||||
let nav = backend
|
||||
.navigate(&common::fixture_url("spa-softnav.html"), false)
|
||||
.await
|
||||
.expect("navigate spa-softnav.html");
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
eprintln!(
|
||||
"=== nav_spa === final_url={} load_state={} redirected={} elapsed={:?}",
|
||||
nav.final_url, nav.load_state, nav.redirected, elapsed
|
||||
);
|
||||
|
||||
// 软导航是良性态:navigate 成功(不 Err),不卡死。
|
||||
assert!(
|
||||
elapsed.as_secs() < 15,
|
||||
"SPA nav must not hang on load timeout; elapsed={elapsed:?}"
|
||||
);
|
||||
// load_state 至少达 DOMContentLoaded(DOM 已构建);多数情况达 Load(初始文档 load 先到)。
|
||||
assert!(
|
||||
matches!(
|
||||
nav.load_state,
|
||||
LoadState::DomContentLoaded | LoadState::Load | LoadState::NetworkIdle
|
||||
),
|
||||
"SPA page should reach at least DOMContentLoaded, got {}",
|
||||
nav.load_state
|
||||
);
|
||||
}
|
||||
|
||||
/// 真站(HTTP):http_status==200 填充 + redirect URL-normalize 不误判。仅当设了
|
||||
/// `NOMIFUN_NAV_HTTP_TEST=1`(避免离线环境 flaky)才真跑——否则 eprintln 跳过原因后返回。
|
||||
///
|
||||
/// 用 https://example.com(稳定、无重定向、明确 200)。验证:
|
||||
/// - http_status == Some(200)(D2 从主帧 Document responseReceived 取到)。
|
||||
/// - example.com → example.com/(trailing-slash)归一化后**不**算 redirect(裸 != 会误报)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome + 网络:set NOMIFUN_CHROME_BINARY + NOMIFUN_NAV_HTTP_TEST=1 后 --run-ignored all"]
|
||||
async fn nav_http_status_and_redirect_normalize() {
|
||||
if std::env::var("NOMIFUN_NAV_HTTP_TEST").ok().as_deref() != Some("1") {
|
||||
eprintln!("=== nav_http_status === SKIPPED (set NOMIFUN_NAV_HTTP_TEST=1 to run online)");
|
||||
return;
|
||||
}
|
||||
let backend = common::build_backend_for_fixture("nav-http").await;
|
||||
|
||||
let t0 = Instant::now();
|
||||
let nav = backend
|
||||
.navigate("https://example.com", false)
|
||||
.await
|
||||
.expect("navigate example.com");
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
eprintln!(
|
||||
"=== nav_http_status === final_url={} http_status={:?} redirected={} load_state={} elapsed={:?}",
|
||||
nav.final_url, nav.http_status, nav.redirected, nav.load_state, elapsed
|
||||
);
|
||||
|
||||
assert_eq!(nav.http_status, Some(200), "example.com should return HTTP 200");
|
||||
// example.com → example.com/(浏览器补 trailing slash):归一化比较**不**算 redirect。
|
||||
assert!(
|
||||
!nav.redirected,
|
||||
"trailing-slash difference must not be flagged as redirect (URL-normalize), final_url={}",
|
||||
nav.final_url
|
||||
);
|
||||
assert!(nav.final_url.contains("example.com"));
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! **OOPIF 真页验证**(`#[ignore]`,本机/打包 chrome):跨源 http 多源 → Chrome site-isolation 把
|
||||
//! 跨站 iframe 另起**跨进程子 session(OOPIF)**,走 `cdp.rs::spawn_oopif_arm_loop`(`TODO(verify-oopif)`)。
|
||||
//! file:// 离线 fixture 触发不到(同进程 iframe/srcdoc 不另起子 session),故须真 http 多源。
|
||||
//!
|
||||
//! 起两个本地 http server:外页 `http://127.0.0.1:PA/` 内嵌跨站 iframe `http://localhost:PB/`
|
||||
//! (`127.0.0.1` 与 `localhost` 是不同 host → 不同 site → site-isolation 起 OOPIF)。验证:
|
||||
//! ① `oopif_session_count_for_test() >= 1`(跨进程 OOPIF 子 session 真被 arm);
|
||||
//! ② 内页内容缝入 observe(跨帧 `f<seq>e<n>` ref);③ 内页 password 脱敏。
|
||||
//!
|
||||
//! 手动跑:`set NOMIFUN_CHROME_BINARY=...` 后 `cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(oopif)'`。
|
||||
//! 跑完核对无残留 chrome(Builder kill_on_drop 自动清)。
|
||||
//!
|
||||
//! 注:本地 server 在 loopback,默认防火墙封 RFC1918/loopback,故用放行 loopback 的 FirewallConfig。
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::time::Duration;
|
||||
|
||||
use nomi_browser_engine::firewall::FirewallConfig;
|
||||
use nomi_browser_engine::{BrowserEngine, ObserveOpts};
|
||||
|
||||
mod common;
|
||||
|
||||
/// 起极简静态 http server(`127.0.0.1:0` → OS 分配端口),对任意请求回固定 HTML。返回端口。
|
||||
/// 后台线程持有 listener,进程退出即随之结束(测试用,不 join)。
|
||||
fn serve_html(html: String) -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut s) = stream else { continue };
|
||||
let mut buf = [0u8; 2048];
|
||||
let _ = s.read(&mut buf); // 读请求(不解析,任意路径回同一页)
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
html.len(),
|
||||
html
|
||||
);
|
||||
let _ = s.write_all(resp.as_bytes());
|
||||
let _ = s.flush();
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome + 显示器(headful):跨源 http 多源触发 OOPIF(cdp.rs TODO(verify-oopif))"]
|
||||
async fn cross_origin_oopif_child_session_armed() {
|
||||
// 内页(origin = localhost:PB):含 password + 可观测锚点。
|
||||
let inner = r##"<!doctype html><html><head><meta charset="utf-8"></head><body>
|
||||
<p>INNERMARKER</p>
|
||||
<label>pw <input type="password" value="sekretoopifpw"></label>
|
||||
<a href="#inner">InnerLink</a>
|
||||
</body></html>"##
|
||||
.to_string();
|
||||
let port_b = serve_html(inner);
|
||||
// 外页(site = a.nomitest):跨站内嵌内页(site = b.nomitest)。两域经 --host-resolver-rules
|
||||
// 都映射到 127.0.0.1 的各自端口,但 Chrome 视作不同 registrable site → site-isolation 起 OOPIF。
|
||||
let outer = format!(
|
||||
r#"<!doctype html><html><head><meta charset="utf-8"></head><body>
|
||||
<h1>Outer</h1>
|
||||
<iframe src="http://b.nomitest:{port_b}/inner" width="360" height="240"></iframe>
|
||||
</body></html>"#
|
||||
);
|
||||
let port_a = serve_html(outer);
|
||||
|
||||
// 默认防火墙封 loopback;本地 server 在 loopback,故放行(block_private_ips=false)。其余不限制。
|
||||
let fw = FirewallConfig {
|
||||
block_private_ips: false,
|
||||
gate_cross_origin_post: false,
|
||||
allow_etld1: vec![],
|
||||
deny_etld1: vec![],
|
||||
};
|
||||
// headless Chrome 默认不对 localhost/127.0.0.1 做站点隔离 → 用 --host-resolver-rules 把两个
|
||||
// **不同 registrable site**(a.nomitest / b.nomitest)映射到 127.0.0.1,再 --site-per-process 强制
|
||||
// 站点隔离 → 跨站 iframe 真成 OOPIF。经 launch.rs 的 NOMI_CHROME_EXTRA_ARGS escape hatch(每行一参)注入。
|
||||
// SAFETY: nextest 进程级隔离每个测试,env 仅影响本测试进程;launch 前设、launch 后清。
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"NOMI_CHROME_EXTRA_ARGS",
|
||||
"--host-resolver-rules=MAP *.nomitest 127.0.0.1\n--site-per-process",
|
||||
);
|
||||
}
|
||||
let backend = common::build_backend_for_fixture_headful_with_firewall("oopif", fw).await;
|
||||
// chrome 已带 flag 启动;清掉 env(卫生)。
|
||||
unsafe {
|
||||
std::env::remove_var("NOMI_CHROME_EXTRA_ARGS");
|
||||
}
|
||||
|
||||
backend
|
||||
.navigate(&format!("http://a.nomitest:{port_a}/"), false)
|
||||
.await
|
||||
.expect("navigate outer http page");
|
||||
|
||||
// 等跨进程 OOPIF 子 session arm(site-isolation 起 type=="iframe" 子 session → spawn_oopif_arm_loop)。
|
||||
let mut oopif_n = 0usize;
|
||||
for _ in 0..50 {
|
||||
oopif_n = backend.oopif_session_count_for_test().await;
|
||||
if oopif_n >= 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
eprintln!("oopif_session_count = {oopif_n}");
|
||||
// OOPIF arm 后其 utility world 物化 + aria 注入是异步的;给一点 settle 再 observe。
|
||||
tokio::time::sleep(Duration::from_millis(1200)).await;
|
||||
|
||||
let obs = backend.observe(&ObserveOpts::default()).await.expect("observe");
|
||||
eprintln!("=== oopif observe yaml ===\n{}\n=== end ===", obs.yaml);
|
||||
|
||||
// ① **跨进程 OOPIF 子 session 真被 arm**(本测试主断言 + 2026-06-19 修复回归)。修前为 0:
|
||||
// 引擎只在 browser-root 设 setAutoAttach,OOPIF(page 的跨进程子帧)不自动 attach;修后
|
||||
// handle_attached 对 page/iframe 子 session 级联 setAutoAttach → OOPIF 自动 attach →
|
||||
// spawn_oopif_arm_loop 入 oopif_managers。实测 0→1。
|
||||
assert!(
|
||||
oopif_n >= 1,
|
||||
"跨站 iframe(a.nomitest ↔ b.nomitest)应触发跨进程 OOPIF 子 session arm(修后应=1);实得 {oopif_n}。\
|
||||
注:必须 headful(真浏览器进程模型)+ --site-per-process;--headless=new 单渲染进程不起 OOPIF"
|
||||
);
|
||||
// ② **OOPIF 内页内容缝入 observe**(2026-06-19 缝合修复回归)。修前:observe 能 arm + 快照 OOPIF
|
||||
// 子帧,但 `resolve_owner_iframe_ref` 在 **OOPIF 自身 session** 上发 `getFrameOwner(自身根帧)`
|
||||
// → `-32000 "Frame ... does not belong to the target"`(owner iframe 元素在**父 target**里,
|
||||
// 不在 OOPIF 自己 target)→ 路由失败 → 内页不内联(iframe 仍是叶子)。修后:对每个候选父帧在
|
||||
// **该父帧 session** 上发 getFrameOwner,真父帧(主帧/page session)命中 → resolveNode + _ariaRef.ref
|
||||
// 缝合。内页 `<a>InnerLink</a>` / `<p>INNERMARKER</p>` 应出现在 observe(作 `iframe` 子节点)。
|
||||
assert!(
|
||||
obs.yaml.contains("InnerLink") && obs.yaml.contains("INNERMARKER"),
|
||||
"OOPIF 内页内容应缝入 observe(iframe 节点下出现 InnerLink/INNERMARKER);实得:\n{}",
|
||||
obs.yaml
|
||||
);
|
||||
// ②b 缝合结构:内页内容必须是 `iframe` 节点的**子节点**(跨帧 `f1e<n>` ref),而非游离顶层。
|
||||
// 渲染按缩进表达父子;断言内页 ref 用了非 0 帧序前缀(`f1`+)= 真嵌在子帧里。
|
||||
assert!(
|
||||
obs.yaml.contains("[ref=f1e"),
|
||||
"OOPIF 内页应以子帧 ref(f1e<n>)缝在父 iframe 下,而非顶层 f0;实得:\n{}",
|
||||
obs.yaml
|
||||
);
|
||||
// ③ 红线守卫:无论是否缝合,内页 password 明文都绝不得出现在 observe 输出。
|
||||
assert!(
|
||||
!obs.yaml.contains("sekretoopifpw"),
|
||||
"OOPIF 内页 password 明文泄漏:\n{}",
|
||||
obs.yaml
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! **单受控标签验证**(`#[ignore]`,本机/打包 chrome):证明 `--no-startup-window` 消除了
|
||||
//! 命令行冗余 about:blank 启动标签——启动后浏览器里**恰好一个** `type=="page"` 的 target
|
||||
//! (引擎 `Target.createTarget` 出来的受控页),不再有命令行起始标签那个孤儿空白页。
|
||||
//!
|
||||
//! 旧行为 = 命令行 `about:blank` + createTarget 受控页 = **2** 个 page;
|
||||
//! 新行为 = 仅 createTarget 受控页 = **1** 个 page。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=...\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(single_tab)'
|
||||
//! headful 用例会开一个**可见** chrome 窗口(验 keep-alive 在 headful 下生效)。
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{build_backend_for_fixture, build_backend_for_fixture_headful};
|
||||
use nomi_browser_engine::BrowserEngine;
|
||||
|
||||
/// headless:`--no-startup-window` + `--headless=new` 下,启动后恰好一个受控 page。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机 chrome:headless 启动后恰好一个受控 page(无命令行冗余 about:blank)"]
|
||||
async fn single_tab_headless_has_exactly_one_page_target() {
|
||||
let backend = build_backend_for_fixture("single-tab-headless").await;
|
||||
let n = backend
|
||||
.page_target_count_for_test()
|
||||
.await
|
||||
.expect("Target.getTargets");
|
||||
assert_eq!(
|
||||
n, 1,
|
||||
"--no-startup-window 后应恰好一个受控 page(createTarget),无命令行 about:blank 孤儿,实得 {n}"
|
||||
);
|
||||
}
|
||||
|
||||
/// headful(关键风险路径):`--no-startup-window` 下 chrome 被 REMOTE_DEBUGGING keep-alive 拴住、
|
||||
/// **不无窗口自退**(launch_chrome 不报 "chrome exited before DevTools port")+ 恰好一个受控 page
|
||||
/// + 受控页可正常导航(active_target 链路不受影响)。会开一个可见 chrome 窗口。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机 chrome+显示器:headful --no-startup-window keep-alive + 单受控页 + navigate"]
|
||||
async fn single_tab_headful_keepalive_one_page_and_navigates() {
|
||||
// build_backend_for_fixture_headful 内部 launch_chrome(cfg, force_headless=false);若
|
||||
// --no-startup-window 在 headful 下无窗口自退,这一步就会 panic("launch headful chrome")——
|
||||
// 即评估唯一未经本机证实的风险点,跑通即证伪。
|
||||
let backend = build_backend_for_fixture_headful("single-tab-headful").await;
|
||||
|
||||
let n = backend
|
||||
.page_target_count_for_test()
|
||||
.await
|
||||
.expect("Target.getTargets");
|
||||
assert_eq!(n, 1, "headful --no-startup-window 后应恰好一个受控 page,实得 {n}");
|
||||
|
||||
// 受控页可正常导航(active_target 解引用链路健康)。
|
||||
let nav = backend
|
||||
.navigate("about:blank", false)
|
||||
.await
|
||||
.expect("navigate on the single controlled page should succeed");
|
||||
assert!(
|
||||
nav.final_url.contains("blank") || nav.final_url.contains("about"),
|
||||
"unexpected final_url after navigate: {}",
|
||||
nav.final_url
|
||||
);
|
||||
}
|
||||
|
||||
/// **父死自清(`--remote-debugging-pipe` keystone)**:控制进程一死,内核关闭继承的 fd3/fd4 →
|
||||
/// Chromium 的 DevTools 管道读到 EOF → 浏览器**自行退出**。这是跨平台父死安全网的最优解(含
|
||||
/// SIGKILL——内核关 fd 不需父进程跑任何代码,见 docs/superpowers/specs/browser-use/2026-06-19-
|
||||
/// macos-pdeath-pipe-transport-design.md)。本测试在同进程内**隔离出该机制**:连上后关闭**我们这端
|
||||
/// 的命令管道**(drop Connection → drop pipe Sender,chrome fd3 读到 EOF)但**不** kill child;用
|
||||
/// `try_wait` 探测(不触发 `kill_on_drop`——child 句柄全程持有)断言 chrome 数秒内自退。
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机 chrome:验 --remote-debugging-pipe 下父死/管道 EOF → chrome 自退"]
|
||||
async fn chrome_self_exits_when_command_pipe_closes() {
|
||||
use nomi_browser_engine::launch::{launch_chrome, LaunchConfig};
|
||||
use nomi_browser_engine::transport::Connection;
|
||||
use std::time::Duration;
|
||||
|
||||
let chrome = nomi_browser_engine::acquire::resolve_chrome_path(
|
||||
&std::env::temp_dir().join("nomifun-browser-data"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("resolve chrome (set NOMIFUN_CHROME_BINARY)");
|
||||
let cfg = LaunchConfig {
|
||||
chrome_path: chrome,
|
||||
user_data_dir: std::env::temp_dir().join("nomifun-pipe-selfexit-profile"),
|
||||
headful: false,
|
||||
};
|
||||
let launched = launch_chrome(&cfg, true).await.expect("launch chrome (pipe)");
|
||||
// 单独持有 child 句柄(不 drop → kill_on_drop 不触发,从而隔离出「管道 EOF 致自退」机制本身)。
|
||||
let mut child = launched.child;
|
||||
let conn = Connection::connect_launched(launched.transport)
|
||||
.await
|
||||
.expect("connect over pipe");
|
||||
// 确认管道双向可用(命令发得出、回包收得到)后再测关闭。
|
||||
conn.enable_auto_attach().await.expect("auto attach over pipe");
|
||||
assert!(
|
||||
child.try_wait().expect("try_wait").is_none(),
|
||||
"chrome should be running before we close the pipe"
|
||||
);
|
||||
|
||||
// 模拟父死:关闭我们这端的命令管道(drop Connection → drop Sender,chrome fd3 读到 EOF),不 kill。
|
||||
drop(conn);
|
||||
|
||||
// chrome 应在数秒内自退。try_wait 探测(不 kill、不阻塞);kill_on_drop 因 child 仍被持有而未触发。
|
||||
let mut exited = false;
|
||||
for _ in 0..100 {
|
||||
if child.try_wait().expect("try_wait").is_some() {
|
||||
exited = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert!(
|
||||
exited,
|
||||
"chrome 必须在命令管道关闭后自退(--remote-debugging-pipe 的父死自清,SIGKILL 等价)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Task 3: `bring_to_front` engine seam — `#[ignore]` real-Chrome test (headful).
|
||||
//!
|
||||
//! Verifies that `CdpBackend::bring_to_front()` successfully sends `Page.bringToFront`
|
||||
//! + `Target.activateTarget` when the engine is headful. Also verifies that a headless
|
||||
//! engine returns `BrowserError::Unsupported`.
|
||||
//!
|
||||
//! Manual run (requires a display + Chrome):
|
||||
//! NOMIFUN_CHROME_BINARY="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(bring_)'
|
||||
|
||||
mod common;
|
||||
|
||||
use nomi_browser_engine::BrowserEngine;
|
||||
|
||||
/// Headful engine: `bring_to_front` succeeds (no error).
|
||||
#[tokio::test]
|
||||
#[ignore = "requires NOMIFUN_CHROME_BINARY + display (headful)"]
|
||||
async fn bring_window_to_front_succeeds_headful() {
|
||||
let backend = common::build_backend_for_fixture_headful("bring-front").await;
|
||||
// Navigate to a simple page so there's something to foreground.
|
||||
let _nav = backend
|
||||
.navigate(&common::fixture_url("act-c1.html"), false)
|
||||
.await
|
||||
.expect("navigate");
|
||||
// bring_to_front should succeed on a headful engine.
|
||||
backend
|
||||
.bring_to_front()
|
||||
.await
|
||||
.expect("bring_to_front on headful engine must succeed");
|
||||
}
|
||||
|
||||
/// Headless engine: `bring_to_front` returns `Unsupported` gracefully.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires NOMIFUN_CHROME_BINARY (headless, no display needed)"]
|
||||
async fn bring_to_front_headless_returns_unsupported() {
|
||||
let backend = common::build_backend_for_fixture("bring-front-headless").await;
|
||||
let result = backend.bring_to_front().await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(nomi_browser_engine::BrowserError::Unsupported { .. })
|
||||
),
|
||||
"headless bring_to_front must return Unsupported, got {result:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! **P3 W4b:cookie storage_state 捕获/恢复 端到端集成**(`#[ignore]`,本机/打包 chrome)。
|
||||
//!
|
||||
//! 验证 W4b(cookie 捕获/恢复机制):
|
||||
//! - **往返保真**:restore 一条全字段 cookie(含 sameSite + 持久 expires)→
|
||||
//! capture 读回 → 名/值/域/sameSite/expires 全保真(**核心验收:cookie 跨 capture/restore 往返保真**)。
|
||||
//! - **登录态可灌**:restore 的 cookie 真写进 cookie store(capture 读回即证「灌入生效」,
|
||||
//! 且 navigate 到该域后仍在——浏览器接受了它为该域的 cookie,跨导航存活 = 登录态可经 restore 灌入)。
|
||||
//! - **默认 context 零回归**:capture/restore 走默认 context(不传 browserContextId),机制仍工作。
|
||||
//!
|
||||
//! **W4b 不验 localStorage/IndexedDB(W4c)也不验磁盘 vault 持久化(W4d)**。本测试只走**内存往返**
|
||||
//! (capture → StorageState → restore)。
|
||||
//!
|
||||
//! **为何用 `Storage.getCookies/setCookies` 而非 `Network.getAllCookies`**:见
|
||||
//! [`nomi_browser_engine::backend::CdpBackend::capture_cookies`] 文档——本 chromiumoxide_cdp 版本无
|
||||
//! `Network.getAllCookies`,且 `Network.*` 是 session 级无法按 browserContextId 取/设;`Storage.*` 支持
|
||||
//! `browserContextId`,是正确的 CDP 面。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=...\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(w4b)'
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop + disposeOnDetach 自动清)。
|
||||
//!
|
||||
//! 真实结果(本机首跑 eprintln 出捕到的 cookie——填回任务汇报)。
|
||||
|
||||
use nomi_browser_engine::storage_state::{SameSite, StorageState, StorageStateCookie};
|
||||
use nomi_browser_engine::BrowserEngine;
|
||||
|
||||
mod common;
|
||||
|
||||
/// 造一条**全字段非默认**的 storage_state cookie(持久 + sameSite=Lax + secure),灌进 context 后
|
||||
/// 应能原样 capture 回。`name`/`value` 用易辨识串便于断言。
|
||||
///
|
||||
/// **注**:不带 CHIPS partitionKey——分区 cookie 的 `Storage.setCookies` 接受依赖 chrome 的 CHIPS 支持
|
||||
/// 与 topLevelSite 形态,集成里灌非分区 cookie 验主路径(partitionKey 的纯逻辑往返已在
|
||||
/// `storage_state::tests` 钉死;分区 cookie 真灌入留 W4d/真站验)。
|
||||
fn login_cookie(name: &str, value: &str) -> StorageStateCookie {
|
||||
StorageStateCookie {
|
||||
name: name.into(),
|
||||
value: value.into(),
|
||||
// example.com(与下方 navigate 目标同域,验「登录态灌入该域」)。
|
||||
domain: ".example.com".into(),
|
||||
path: "/".into(),
|
||||
// 持久 cookie(远未来过期),非 session——跨导航存活。
|
||||
expires: 4_102_444_800.0, // 2100-01-01
|
||||
http_only: false, // 非 httpOnly:若要 document.cookie 验也读得到(本测试以 capture 为主)。
|
||||
secure: false,
|
||||
session: false,
|
||||
same_site: Some(SameSite::Lax),
|
||||
priority: nomi_browser_engine::storage_state::Priority::Medium,
|
||||
source_scheme: nomi_browser_engine::storage_state::SourceScheme::NonSecure,
|
||||
source_port: -1,
|
||||
partition_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 在一组 capture 回的 cookie 里按 name 找。
|
||||
fn find<'a>(state: &'a StorageState, name: &str) -> Option<&'a StorageStateCookie> {
|
||||
state.cookies.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// **往返保真 + 登录态可灌**:restore 一条全字段 cookie → capture 读回保真 →
|
||||
/// navigate 到该域后再 capture 仍在(浏览器接受为该域 cookie,跨导航存活 = 登录态灌入生效)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn w4b_cookie_round_trip_and_login_state_loadable() {
|
||||
let backend = common::build_backend_for_fixture("w4b-rt").await;
|
||||
|
||||
// 1) restore:把一条登录态 cookie 灌进默认 context。
|
||||
let state = StorageState {
|
||||
cookies: vec![login_cookie("nomi_session", "login-token-abc123")],
|
||||
..Default::default()
|
||||
};
|
||||
backend
|
||||
.restore_cookies(&state)
|
||||
.await
|
||||
.expect("restore_cookies into default context");
|
||||
|
||||
// 2) capture:读回——名/值/域/sameSite/expires 全保真(核心验收:往返保真)。
|
||||
let captured = backend.capture_cookies().await.expect("capture_cookies");
|
||||
eprintln!(
|
||||
"=== w4b captured {} cookie(s) === {:?}",
|
||||
captured.cookies.len(),
|
||||
captured.cookies
|
||||
);
|
||||
let c = find(&captured, "nomi_session")
|
||||
.expect("restored cookie must be captured back (login state loaded into context)");
|
||||
assert_eq!(c.value, "login-token-abc123", "cookie value must round-trip");
|
||||
assert_eq!(c.domain, ".example.com", "cookie domain must round-trip");
|
||||
assert_eq!(c.path, "/", "cookie path must round-trip");
|
||||
assert_eq!(c.same_site, Some(SameSite::Lax), "sameSite must round-trip (not lost/defaulted)");
|
||||
assert!(!c.session, "persistent cookie must not be a session cookie");
|
||||
// 持久 cookie:expires 是一个真实的未来时间戳(非 -1/session)。**注**:Chrome 把远未来过期时间
|
||||
// 钳到「~400 天上限」(cookie max-age 政策,RFC 6265bis),故灌 2100 年会被钳到约一年后——这是
|
||||
// 浏览器正确行为,不是丢字段。验「仍是持久 cookie(expires>0 且远在未来)」而非精确值。
|
||||
assert!(
|
||||
c.expires > 1_700_000_000.0,
|
||||
"persistent cookie must keep a real future expiry (Chrome clamps far-future to ~400d), got {}",
|
||||
c.expires
|
||||
);
|
||||
|
||||
// 3) 登录态可灌:navigate 到该域后 cookie 仍在 context(浏览器接受为 example.com 的 cookie,
|
||||
// 跨导航存活)——这就是「登录态经 restore 灌入、后续请求带得上」的真实信号。
|
||||
let nav = backend
|
||||
.navigate("https://example.com", false)
|
||||
.await
|
||||
.expect("navigate to example.com");
|
||||
eprintln!("=== w4b nav after restore === final_url={}", nav.final_url);
|
||||
let after_nav = backend.capture_cookies().await.expect("capture after navigate");
|
||||
assert!(
|
||||
find(&after_nav, "nomi_session").is_some(),
|
||||
"restored cookie must survive navigation (login state loadable into the domain): {:?}",
|
||||
after_nav.cookies
|
||||
);
|
||||
}
|
||||
|
||||
/// **默认 context 零回归**:capture/restore 走默认 context(不传
|
||||
/// browserContextId),机制仍工作(restore→capture 往返)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn w4b_default_context_capture_restore_works() {
|
||||
let backend = common::build_backend_for_fixture("w4b-default").await;
|
||||
|
||||
let state = StorageState {
|
||||
cookies: vec![login_cookie("default_ctx_cookie", "v-default")],
|
||||
..Default::default()
|
||||
};
|
||||
backend.restore_cookies(&state).await.expect("restore default context");
|
||||
let captured = backend.capture_cookies().await.expect("capture default context");
|
||||
eprintln!("=== w4b default-context captured {} cookie(s) ===", captured.cookies.len());
|
||||
let c = find(&captured, "default_ctx_cookie")
|
||||
.expect("default-context cookie must round-trip");
|
||||
assert_eq!(c.value, "v-default");
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! **P3 W4c:localStorage storage_state 捕获/恢复 端到端集成**(`#[ignore]`,本机/打包 chrome)。
|
||||
//!
|
||||
//! 验证 W4c(localStorage(origin-bound)捕获/恢复机制):
|
||||
//! - **localStorage 跨 capture/restore 往返保真(origin-bound,核心验收)**:在引擎 A 页面
|
||||
//! `setItem` 几个键 → `capture_local_storage` 读回(origin + 键值保真)→ 在**新**引擎 B
|
||||
//! navigate 到同 origin → `restore_local_storage` 灌进去 → 页面读回 `getItem` 验值生效。
|
||||
//! - **origin-bound**:捕获/恢复都绑**当前页面 origin**(localStorage 同源分区,无法跨 origin 全局取/设);
|
||||
//! restore 只灌 state 中 origin == 当前页面 origin 的那份(不跨 origin 误写)。
|
||||
//! - **默认 context 零回归**:capture/restore 走默认 context 页面,机制仍工作。
|
||||
//!
|
||||
//! **W4c 不验 IndexedDB(best-effort/TODO,`OriginStorage.index_db` 恒 None)也不验磁盘 vault 持久化
|
||||
//! (W4d)**。只走**内存往返**(capture → StorageState → restore)+ origin-bound 注入。
|
||||
//!
|
||||
//! **为何用真实 `https://example.com` 而非 file:// fixture**:localStorage 在 `file://` origin 上
|
||||
//! Chrome 行为不稳(origin 形态 `null`/`file://` 视版本而异,且 storage 可能被分区/禁用);`https://
|
||||
//! example.com` 是稳定可达的真实 origin(与 W4b cookie 测试同选),localStorage 在其上可靠工作——
|
||||
//! 这是「origin-bound 往返保真」最贴近真实登录态的验证场景。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=...\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(w4c)'
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop + disposeOnDetach 自动清)。
|
||||
//!
|
||||
//! 真实结果(本机首跑 eprintln 出捕到的 localStorage——填回任务汇报)。
|
||||
|
||||
use nomi_browser_engine::storage_state::{OriginStorage, StorageState};
|
||||
use nomi_browser_engine::BrowserEngine;
|
||||
|
||||
mod common;
|
||||
|
||||
const ORIGIN: &str = "https://example.com";
|
||||
|
||||
/// 在当前页面(默认 page world)`setItem` 一组键值(测试 seam:模拟「页面写了 localStorage 登录态」)。
|
||||
async fn seed_local_storage(
|
||||
backend: &nomi_browser_engine::backend::CdpBackend,
|
||||
pairs: &[(&str, &str)],
|
||||
) {
|
||||
// 用 JSON 安全编码键值,逐键 setItem。
|
||||
let pairs_owned: Vec<[&str; 2]> = pairs.iter().map(|(k, v)| [*k, *v]).collect();
|
||||
let pairs_json = serde_json::to_string(&pairs_owned).expect("json pairs");
|
||||
let script = format!(
|
||||
"(() => {{ const pairs = {pairs_json}; for (const [k, v] of pairs) localStorage.setItem(k, v); return localStorage.length; }})()"
|
||||
);
|
||||
let r = backend
|
||||
.__eval_page_world_for_test(&script)
|
||||
.await
|
||||
.expect("seed localStorage setItem");
|
||||
eprintln!("=== seeded localStorage, length now = {:?}", r.get("value"));
|
||||
}
|
||||
|
||||
/// 读回当前页面某 localStorage 键(默认 page world `getItem`)。None = 键不存在(返回 JS null)。
|
||||
async fn read_local_storage(
|
||||
backend: &nomi_browser_engine::backend::CdpBackend,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
let key_json = serde_json::to_string(key).expect("json key");
|
||||
let script = format!("localStorage.getItem({key_json})");
|
||||
let r = backend
|
||||
.__eval_page_world_for_test(&script)
|
||||
.await
|
||||
.expect("read localStorage getItem");
|
||||
r.get("value").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// **核心:localStorage 跨 capture/restore 往返保真(origin-bound)**。
|
||||
/// engine A:navigate example.com → seed localStorage → capture(origin + 键值保真)。
|
||||
/// engine B(新引擎,独立 profile):navigate example.com → restore A 的快照 → 页面 getItem 读回值生效。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn w4c_local_storage_round_trip_origin_bound() {
|
||||
// ── 捕获侧:engine A ──────────────────────────────────────────────────────
|
||||
let backend_a = common::build_backend_for_fixture("w4c-capA").await;
|
||||
let nav = backend_a
|
||||
.navigate(ORIGIN, false)
|
||||
.await
|
||||
.expect("navigate A to example.com");
|
||||
eprintln!("=== w4c capture nav === final_url={}", nav.final_url);
|
||||
|
||||
// 页面写 localStorage(含特殊字符值,验往返不被吞)。
|
||||
seed_local_storage(
|
||||
&backend_a,
|
||||
&[
|
||||
("ls_auth", "jwt.eyJhbGciOiJ9.sig-abc123"),
|
||||
("ls_theme", "dark"),
|
||||
("ls_json", "{\"a\":1,\"b\":\"x=y&z\"}"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
// capture:读回当前 origin 的 localStorage(origin + 键值保真)。
|
||||
let captured = backend_a
|
||||
.capture_local_storage()
|
||||
.await
|
||||
.expect("capture_local_storage")
|
||||
.expect("page has an origin to capture");
|
||||
eprintln!(
|
||||
"=== w4c captured origin={} items={:?}",
|
||||
captured.origin, captured.local_storage
|
||||
);
|
||||
// **核心验收:捕到的 origin == 页面 origin;键值保真**。
|
||||
assert_eq!(captured.origin, ORIGIN, "captured origin must match page origin (origin-bound)");
|
||||
let find = |k: &str| {
|
||||
captured
|
||||
.local_storage
|
||||
.iter()
|
||||
.find(|i| i.name == k)
|
||||
.map(|i| i.value.as_str())
|
||||
};
|
||||
assert_eq!(find("ls_auth"), Some("jwt.eyJhbGciOiJ9.sig-abc123"), "auth value captured");
|
||||
assert_eq!(find("ls_theme"), Some("dark"), "theme value captured");
|
||||
assert_eq!(find("ls_json"), Some("{\"a\":1,\"b\":\"x=y&z\"}"), "special chars captured intact");
|
||||
|
||||
// 组装一份 storage_state(只含 localStorage——本任务范围)。
|
||||
let state = StorageState {
|
||||
cookies: vec![],
|
||||
local_storage: vec![captured.clone()],
|
||||
};
|
||||
|
||||
// ── 恢复侧:engine B(独立 profile,localStorage 必为空白起点)──────────────────
|
||||
let backend_b = common::build_backend_for_fixture("w4c-resB").await;
|
||||
backend_b
|
||||
.navigate(ORIGIN, false)
|
||||
.await
|
||||
.expect("navigate B to example.com");
|
||||
|
||||
// 恢复前:B 的 localStorage 该键应不存在(新引擎 + 独立 profile = 干净起点)。
|
||||
assert_eq!(
|
||||
read_local_storage(&backend_b, "ls_auth").await,
|
||||
None,
|
||||
"fresh engine B must not have A's localStorage before restore"
|
||||
);
|
||||
|
||||
// restore:把 A 的 localStorage 灌进 B 当前页面(origin-bound:origin 匹配 example.com)。
|
||||
backend_b
|
||||
.restore_local_storage(&state)
|
||||
.await
|
||||
.expect("restore_local_storage into B");
|
||||
|
||||
// 页面读回:值生效(往返保真——这是「localStorage 登录态经 restore 灌入、页面读得到」的真实信号)。
|
||||
let auth = read_local_storage(&backend_b, "ls_auth").await;
|
||||
let theme = read_local_storage(&backend_b, "ls_theme").await;
|
||||
let json = read_local_storage(&backend_b, "ls_json").await;
|
||||
eprintln!("=== w4c restored read-back === ls_auth={auth:?} ls_theme={theme:?} ls_json={json:?}");
|
||||
assert_eq!(
|
||||
auth.as_deref(),
|
||||
Some("jwt.eyJhbGciOiJ9.sig-abc123"),
|
||||
"restored localStorage value must be readable in page (round-trip fidelity)"
|
||||
);
|
||||
assert_eq!(theme.as_deref(), Some("dark"));
|
||||
assert_eq!(json.as_deref(), Some("{\"a\":1,\"b\":\"x=y&z\"}"), "special chars survive restore");
|
||||
|
||||
// re-capture 在 B:应捕到刚恢复的项(capture↔restore 对称)。
|
||||
let recap = backend_b
|
||||
.capture_local_storage()
|
||||
.await
|
||||
.expect("re-capture B")
|
||||
.expect("B has origin");
|
||||
assert!(
|
||||
recap.local_storage.iter().any(|i| i.name == "ls_auth"),
|
||||
"re-capture after restore must see the restored key: {:?}",
|
||||
recap.local_storage
|
||||
);
|
||||
}
|
||||
|
||||
/// **origin-bound:restore 不跨 origin 误写**。state 里只有 example.com 的 localStorage;当前页面停在
|
||||
/// about:blank(无 example.com origin)→ restore 是 no-op(不把 example.com 的键写进无关页面)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn w4c_restore_is_origin_bound_no_cross_origin_write() {
|
||||
let backend = common::build_backend_for_fixture("w4c-origin").await;
|
||||
|
||||
// 当前页面在另一个 origin(fixture file://),与 state 里的 example.com 不同 origin。
|
||||
backend
|
||||
.navigate(&common::fixture_url("act-c1.html"), false)
|
||||
.await
|
||||
.expect("navigate to fixture origin");
|
||||
|
||||
// state 只含 example.com 的 localStorage。
|
||||
let state = StorageState {
|
||||
cookies: vec![],
|
||||
local_storage: vec![OriginStorage::new_local_storage(
|
||||
ORIGIN,
|
||||
[("ls_cross", "should-not-leak".to_string())]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v)),
|
||||
)],
|
||||
};
|
||||
// restore:当前页面 origin != example.com → no-op(绝不把 example.com 键写进 fixture 页面)。
|
||||
backend
|
||||
.restore_local_storage(&state)
|
||||
.await
|
||||
.expect("restore is no-op when origin mismatches (origin-bound)");
|
||||
|
||||
// fixture 页面不该出现 example.com 的键(origin-bound 防误写)。
|
||||
let leaked = read_local_storage(&backend, "ls_cross").await;
|
||||
eprintln!("=== w4c origin-bound no-cross-write === fixture-page ls_cross={leaked:?}");
|
||||
assert_eq!(
|
||||
leaked, None,
|
||||
"restore must NOT write example.com's localStorage into a different-origin page (origin-bound)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **默认 context 零回归**:capture/restore 走默认 context 页面,机制仍工作
|
||||
/// (往返保真)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn w4c_default_context_local_storage_works() {
|
||||
let backend = common::build_backend_for_fixture("w4c-default").await;
|
||||
|
||||
backend
|
||||
.navigate(ORIGIN, false)
|
||||
.await
|
||||
.expect("navigate default context to example.com");
|
||||
seed_local_storage(&backend, &[("ls_default", "v-default-ctx")]).await;
|
||||
|
||||
let captured = backend
|
||||
.capture_local_storage()
|
||||
.await
|
||||
.expect("capture default context")
|
||||
.expect("default context page has origin");
|
||||
eprintln!(
|
||||
"=== w4c default-context captured origin={} items={:?}",
|
||||
captured.origin, captured.local_storage
|
||||
);
|
||||
assert_eq!(captured.origin, ORIGIN);
|
||||
assert!(
|
||||
captured.local_storage.iter().any(|i| i.name == "ls_default" && i.value == "v-default-ctx"),
|
||||
"default-context localStorage must round-trip: {:?}",
|
||||
captured.local_storage
|
||||
);
|
||||
|
||||
// restore 同一份回当前页面(覆盖即幂等)→ 读回仍在。
|
||||
let state = StorageState {
|
||||
cookies: vec![],
|
||||
local_storage: vec![captured],
|
||||
};
|
||||
backend
|
||||
.restore_local_storage(&state)
|
||||
.await
|
||||
.expect("restore default context");
|
||||
assert_eq!(
|
||||
read_local_storage(&backend, "ls_default").await.as_deref(),
|
||||
Some("v-default-ctx"),
|
||||
"default-context restore round-trip"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! **P3 W4d:storage_state 持久化 vault(加密)+ 启动 inject 持久登录 端到端集成**
|
||||
//! (`#[ignore]`,本机/打包 chrome)。
|
||||
//!
|
||||
//! 验证 W4d(吸收原 P6 持久登录):W4b/c 是**内存往返**(capture↔restore),W4d 在两端之间塞**磁盘
|
||||
//! vault**,证**跨引擎/会话的持久登录闭环**:
|
||||
//!
|
||||
//! ```text
|
||||
//! 引擎 A(会话 A 登录):navigate example.com → 写 cookie + localStorage(模拟登录)
|
||||
//! → capture_cookies + capture_local_storage → save_storage_state(vault) [加密]
|
||||
//! ── A close(登录态只在加密 vault 文件里)──
|
||||
//! 引擎 B(新引擎/会话 B):load_storage_state(vault) → EngineConfig.storage_state
|
||||
//! → 引擎启动注入 cookie(navigate 前即灌)
|
||||
//! → navigate example.com(带上恢复的 cookie)→ restore_local_storage(origin-bound)
|
||||
//! → cookie + localStorage **都恢复存活**(读回)= 持久登录成立。
|
||||
//! ```
|
||||
//!
|
||||
//! **加密验收**:vault 文件落盘是 AES-256-GCM 密文(不含明文 cookie token / JWT),换 key 解不开
|
||||
//! (纯逻辑已在 `vault::tests` 钉死;本集成跑真 save/load 往返证密文可解回真登录态)。
|
||||
//!
|
||||
//! **默认 None 零回归**:`storage_state=None`(不灌)→ 引擎启动不碰 cookie/localStorage(现行为)。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=...\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(w4d)'
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop + disposeOnDetach 自动清)。
|
||||
//!
|
||||
//! 真实结果(本机首跑 eprintln 出 vault 路径 + 恢复读回的 cookie/localStorage——填回任务汇报)。
|
||||
|
||||
use nomi_browser_engine::storage_state::{SameSite, StorageState, StorageStateCookie};
|
||||
use nomi_browser_engine::{
|
||||
load_storage_state, save_storage_state, shared_storage_state_path, storage_state_path,
|
||||
};
|
||||
use nomi_browser_engine::BrowserEngine;
|
||||
|
||||
mod common;
|
||||
|
||||
const ORIGIN: &str = "https://example.com";
|
||||
|
||||
/// 测试用 32 字节 key(机器绑定 key 的占位;真机用 app provision 的 encryption_key)。
|
||||
fn machine_key() -> [u8; 32] {
|
||||
[0x5a; 32]
|
||||
}
|
||||
|
||||
/// 造一条登录态 cookie(持久 + sameSite=Lax,绑 example.com——与 navigate 目标同域)。
|
||||
fn login_cookie(name: &str, value: &str) -> StorageStateCookie {
|
||||
StorageStateCookie {
|
||||
name: name.into(),
|
||||
value: value.into(),
|
||||
domain: ".example.com".into(),
|
||||
path: "/".into(),
|
||||
expires: 4_102_444_800.0, // 2100-01-01(Chrome 钳到 ~400d,仍是持久 cookie)
|
||||
http_only: false,
|
||||
secure: false,
|
||||
session: false,
|
||||
same_site: Some(SameSite::Lax),
|
||||
priority: nomi_browser_engine::storage_state::Priority::Medium,
|
||||
source_scheme: nomi_browser_engine::storage_state::SourceScheme::NonSecure,
|
||||
source_port: -1,
|
||||
partition_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 在当前页面 `setItem` 一组 localStorage 键值(模拟「页面写了 localStorage 登录态」)。
|
||||
async fn seed_local_storage(backend: &nomi_browser_engine::backend::CdpBackend, pairs: &[(&str, &str)]) {
|
||||
let pairs_owned: Vec<[&str; 2]> = pairs.iter().map(|(k, v)| [*k, *v]).collect();
|
||||
let pairs_json = serde_json::to_string(&pairs_owned).expect("json pairs");
|
||||
let script = format!(
|
||||
"(() => {{ const pairs = {pairs_json}; for (const [k, v] of pairs) localStorage.setItem(k, v); return localStorage.length; }})()"
|
||||
);
|
||||
backend
|
||||
.__eval_page_world_for_test(&script)
|
||||
.await
|
||||
.expect("seed localStorage");
|
||||
}
|
||||
|
||||
/// 读回当前页面某 localStorage 键(None = 不存在)。
|
||||
async fn read_local_storage(backend: &nomi_browser_engine::backend::CdpBackend, key: &str) -> Option<String> {
|
||||
let key_json = serde_json::to_string(key).expect("json key");
|
||||
let script = format!("localStorage.getItem({key_json})");
|
||||
let r = backend
|
||||
.__eval_page_world_for_test(&script)
|
||||
.await
|
||||
.expect("read localStorage");
|
||||
r.get("value").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn find_cookie<'a>(state: &'a StorageState, name: &str) -> Option<&'a StorageStateCookie> {
|
||||
state.cookies.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// **核心:跨引擎/会话持久登录闭环(cookie + localStorage 经加密 vault 持久 → 启动注入恢复)**。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn w4d_persistent_login_cross_engine_via_encrypted_vault() {
|
||||
// vault 目录(临时目录当 workspace)。
|
||||
let vault_dir = tempfile::tempdir().expect("vault tempdir");
|
||||
let vault_path = storage_state_path(vault_dir.path());
|
||||
let key = machine_key();
|
||||
eprintln!("=== w4d vault path = {} ===", vault_path.display());
|
||||
|
||||
// ── 会话 A:登录 → capture → save 加密 vault ──────────────────────────────────
|
||||
{
|
||||
let backend_a = common::build_backend_for_fixture("w4d-A").await;
|
||||
// 灌一条登录态 cookie(模拟「会话 A 登录拿到了 cookie」)。
|
||||
let seed_state = StorageState {
|
||||
cookies: vec![login_cookie("nomi_session", "persisted-login-tok-abc")],
|
||||
..Default::default()
|
||||
};
|
||||
backend_a.restore_cookies(&seed_state).await.expect("seed cookie into A");
|
||||
// navigate + 写 localStorage 登录态。
|
||||
backend_a.navigate(ORIGIN, false).await.expect("A navigate example.com");
|
||||
seed_local_storage(&backend_a, &[("ls_auth", "jwt.persisted.signature")]).await;
|
||||
|
||||
// capture A 的登录态(cookie + localStorage)。
|
||||
let cookies = backend_a.capture_cookies().await.expect("capture A cookies");
|
||||
let ls = backend_a
|
||||
.capture_local_storage()
|
||||
.await
|
||||
.expect("capture A localStorage")
|
||||
.expect("A page has origin");
|
||||
let state = StorageState {
|
||||
cookies: cookies.cookies,
|
||||
local_storage: vec![ls],
|
||||
};
|
||||
assert!(find_cookie(&state, "nomi_session").is_some(), "A must have the login cookie before save");
|
||||
eprintln!(
|
||||
"=== w4d A captured: {} cookie(s), localStorage origin={} ===",
|
||||
state.cookies.len(),
|
||||
state.local_storage[0].origin
|
||||
);
|
||||
|
||||
// **save 加密 vault**(登录态落盘,加密)。
|
||||
save_storage_state(&state, &vault_path, &key).expect("save_storage_state encrypted vault");
|
||||
}
|
||||
|
||||
// **加密验收**:vault 文件内容是密文,不含明文 cookie token / JWT。
|
||||
let raw = std::fs::read_to_string(&vault_path).expect("read raw vault");
|
||||
assert!(!raw.contains("persisted-login-tok-abc"), "cookie token must NOT be plaintext in vault");
|
||||
assert!(!raw.contains("jwt.persisted.signature"), "localStorage JWT must NOT be plaintext in vault");
|
||||
eprintln!("=== w4d vault is ciphertext ({} base64 chars), no plaintext login state ===", raw.len());
|
||||
|
||||
// ── 会话 B:load 加密 vault → 启动注入 → 登录态恢复 ───────────────────────────
|
||||
// load_storage_state 解密读回(坏 vault 会返 None,这里应 Some)。
|
||||
let loaded = load_storage_state(&vault_path, &key).expect("load_storage_state decrypts vault");
|
||||
assert!(find_cookie(&loaded, "nomi_session").is_some(), "vault must carry the login cookie");
|
||||
// 喂给新引擎的 EngineConfig.storage_state(JSON 形态)。
|
||||
let inject = loaded.to_json().expect("storage_state to_json for inject");
|
||||
|
||||
// **全新引擎 B + storage_state 注入**——启动即 restore_cookies。
|
||||
let backend_b = common::build_backend_for_fixture_with_storage_state(
|
||||
"w4d-B",
|
||||
Some(inject),
|
||||
)
|
||||
.await;
|
||||
|
||||
// navigate example.com:恢复的 cookie 是启动时已灌,navigate 后该域 cookie 仍在。
|
||||
backend_b.navigate(ORIGIN, false).await.expect("B navigate example.com");
|
||||
let after = backend_b.capture_cookies().await.expect("B capture cookies after nav");
|
||||
eprintln!("=== w4d B has {} cookie(s) after startup inject + nav ===", after.cookies.len());
|
||||
let c = find_cookie(&after, "nomi_session")
|
||||
.expect("login cookie must be restored into B at startup (persistent login via vault)");
|
||||
assert_eq!(c.value, "persisted-login-tok-abc", "restored cookie value must match the persisted login");
|
||||
|
||||
// localStorage 是 origin-bound:B 现已在 example.com,restore_local_storage 灌回该 origin 的项。
|
||||
backend_b
|
||||
.restore_local_storage(&loaded)
|
||||
.await
|
||||
.expect("restore localStorage into B (now on the matching origin)");
|
||||
let ls_auth = read_local_storage(&backend_b, "ls_auth").await;
|
||||
eprintln!("=== w4d B localStorage ls_auth after restore = {ls_auth:?} ===");
|
||||
assert_eq!(
|
||||
ls_auth.as_deref(),
|
||||
Some("jwt.persisted.signature"),
|
||||
"localStorage login state must be restored into B (round-trip via vault)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **默认 None 零回归**:`storage_state=None` → 引擎启动不注入任何登录态(干净起点)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn w4d_none_storage_state_injects_nothing() {
|
||||
// 无注入(storage_state=None)——与现行为完全一致。
|
||||
let backend = common::build_backend_for_fixture_with_storage_state(
|
||||
"w4d-none",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
backend.navigate(ORIGIN, false).await.expect("navigate example.com");
|
||||
// 未注入 → example.com 无任何我们灌的 cookie(干净起点;可能有页面自设 cookie,
|
||||
// 但绝不该有 w4d 的 nomi_session)。
|
||||
let captured = backend.capture_cookies().await.expect("capture");
|
||||
eprintln!("=== w4d none-inject: {} cookie(s) (must not contain injected login) ===", captured.cookies.len());
|
||||
assert!(
|
||||
find_cookie(&captured, "nomi_session").is_none(),
|
||||
"None storage_state must inject nothing (zero regression): {:?}",
|
||||
captured.cookies
|
||||
);
|
||||
// localStorage 同样干净(未注入)。
|
||||
assert_eq!(
|
||||
read_local_storage(&backend, "ls_auth").await,
|
||||
None,
|
||||
"None storage_state must not inject localStorage"
|
||||
);
|
||||
}
|
||||
|
||||
/// **共享浏览器身份端到端**:伙伴 A 登录 → save 到**共享** vault
|
||||
/// `{data_dir}/browser-state/storage_state.enc` → 伙伴 B load **同一份共享** vault
|
||||
/// → 启动注入 → B 直接拥有 A 的登录态(cookie + localStorage 跨伙伴共享)。
|
||||
///
|
||||
/// set NOMIFUN_CHROME_BINARY=...\chrome.exe
|
||||
/// cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(shared_identity)'
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all -E 'test(shared_identity)'"]
|
||||
async fn shared_identity_login_crosses_companions_via_shared_vault() {
|
||||
// 共享 vault 落 {data_dir}/browser-state/storage_state.enc(data_dir 用临时目录当 app data-dir)。
|
||||
let data_dir = tempfile::tempdir().expect("data_dir tempdir");
|
||||
let vault_path = shared_storage_state_path(data_dir.path());
|
||||
let key = machine_key();
|
||||
eprintln!("=== shared identity vault path = {} ===", vault_path.display());
|
||||
// 共享单例硬证据:两次解析同一份(与伙伴/会话无关)。
|
||||
assert_eq!(vault_path, shared_storage_state_path(data_dir.path()), "shared vault path is a singleton");
|
||||
|
||||
// ── 伙伴 A:登录 → capture → save 到共享 vault ──────────────────────────────────
|
||||
{
|
||||
let backend_a = common::build_backend_for_fixture("shared-A").await;
|
||||
let seed_state = StorageState {
|
||||
cookies: vec![login_cookie("nomi_session", "shared-login-tok-xyz")],
|
||||
..Default::default()
|
||||
};
|
||||
backend_a.restore_cookies(&seed_state).await.expect("seed cookie into A");
|
||||
backend_a.navigate(ORIGIN, false).await.expect("A navigate example.com");
|
||||
seed_local_storage(&backend_a, &[("ls_auth", "jwt.shared.signature")]).await;
|
||||
|
||||
let cookies = backend_a.capture_cookies().await.expect("capture A cookies");
|
||||
let ls = backend_a
|
||||
.capture_local_storage()
|
||||
.await
|
||||
.expect("capture A localStorage")
|
||||
.expect("A page has origin");
|
||||
let state = StorageState { cookies: cookies.cookies, local_storage: vec![ls] };
|
||||
// **save 到共享 vault**(不绑 A 的 workspace——这是「共享」的本质)。
|
||||
save_storage_state(&state, &vault_path, &key).expect("save to SHARED vault");
|
||||
}
|
||||
|
||||
// ── 伙伴 B:load 同一共享 vault → 启动注入 → 登录态恢复 ──
|
||||
let loaded = load_storage_state(&vault_path, &key).expect("B loads the SHARED vault");
|
||||
assert!(
|
||||
find_cookie(&loaded, "nomi_session").is_some(),
|
||||
"shared vault must carry A's login cookie (cross-companion sharing)"
|
||||
);
|
||||
let inject = loaded.to_json().expect("storage_state to_json");
|
||||
// B 用独立引擎但共享同一登录 vault。
|
||||
let backend_b = common::build_backend_for_fixture_with_storage_state(
|
||||
"shared-B",
|
||||
Some(inject),
|
||||
)
|
||||
.await;
|
||||
backend_b.navigate(ORIGIN, false).await.expect("B navigate example.com");
|
||||
let after = backend_b.capture_cookies().await.expect("B capture cookies after nav");
|
||||
let c = find_cookie(&after, "nomi_session")
|
||||
.expect("A's login cookie must be present in B via the SHARED vault (shared identity)");
|
||||
assert_eq!(c.value, "shared-login-tok-xyz", "B inherits A's login (shared browser identity)");
|
||||
|
||||
backend_b
|
||||
.restore_local_storage(&loaded)
|
||||
.await
|
||||
.expect("restore localStorage into B");
|
||||
assert_eq!(
|
||||
read_local_storage(&backend_b, "ls_auth").await.as_deref(),
|
||||
Some("jwt.shared.signature"),
|
||||
"A's localStorage login must be visible to B (shared identity via shared vault)"
|
||||
);
|
||||
|
||||
// 共享 vault 不在 B 的 per-workspace 下(证明用的是共享路径)。
|
||||
let b_workspace_vault = storage_state_path(data_dir.path().join("companionB").join("workspace").as_path());
|
||||
assert_ne!(vault_path, b_workspace_vault, "B reads the SHARED vault, not a per-workspace one");
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! observe 注入契约快照测试(`#[ignore]`,本机/打包 chrome)。
|
||||
//!
|
||||
//! 对一个**固定 HTML fixture**(`tests/fixtures/iframe.html`,file:// 加载)跑注入侧的
|
||||
//! `incrementalAriaSnapshot`,把它返回的 `.full`(aria YAML,给 LLM 看的那一版)冻成 insta
|
||||
//! 快照。目的:
|
||||
//! - **供人审**:aria 输出形态(role/name/ref=f0e<n>)一眼可读、可在 review 里核对;
|
||||
//! - **防漂移**:vendor 的 Playwright InjectedScript 升级后若 aria 序列化形态变了,快照 diff
|
||||
//! 会立刻报出来(DESIGN:整包 vendor 不 fork,靠契约测试钉住外部行为)。
|
||||
//!
|
||||
//! 接线母本 = `src/injected.rs` 的 `inject_aria_snapshot_smoke`(八步:launch → connect →
|
||||
//! run_attach_loop → enable_auto_attach → createTarget page → navigate(file://) → arm →
|
||||
//! `Runtime.evaluate("document.body")` 取 objectId → `call_injected("incrementalAriaSnapshot",
|
||||
//! [body, opts], by_value=true)` → 取 `.full`)。
|
||||
//!
|
||||
//! 手动跑(本机 Windows 有系统 Chrome):
|
||||
//! set NOMIFUN_CHROME_BINARY=...\chrome.exe
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(observe_inject_contract)'
|
||||
//! 首跑写 `.snap.new`;`cargo insta accept`(或手动改名 .snap)接受为基线。
|
||||
//! 跑完核对任务管理器无残留 chrome(Builder kill_on_drop 应自动清)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chromiumoxide::cdp::browser_protocol::page::{
|
||||
EnableParams as PageEnable, NavigateParams,
|
||||
};
|
||||
use chromiumoxide::cdp::browser_protocol::target::{CreateTargetParams, EventAttachedToTarget};
|
||||
use chromiumoxide::cdp::js_protocol::runtime::{
|
||||
CallArgument, EvaluateParams, ExecutionContextId, RemoteObjectId,
|
||||
};
|
||||
|
||||
use nomi_browser_engine::injected::InjectionManager;
|
||||
use nomi_browser_engine::launch::{launch_chrome, LaunchConfig};
|
||||
use nomi_browser_engine::transport::Connection;
|
||||
|
||||
mod common;
|
||||
|
||||
/// fixture 的 file:// URL(`file://` + 单前导斜杠 + POSIX 路径;unix `/abs` 已带斜杠、windows
|
||||
/// `C:/abs` 补一个。旧 `file:///{manifest}` 在 unix 产四斜杠,被 chrome 归一后破坏 url 比较)。
|
||||
fn fixture_url() -> String {
|
||||
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
|
||||
let abs = if manifest.starts_with('/') {
|
||||
manifest
|
||||
} else {
|
||||
format!("/{manifest}")
|
||||
};
|
||||
format!("file://{abs}/tests/fixtures/iframe.html")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn observe_inject_contract_iframe() {
|
||||
// 1) resolve chrome(env NOMIFUN_CHROME_BINARY > 打包 > 数据目录 > 下载兜底)+ launch headless。
|
||||
let chrome = nomi_browser_engine::acquire::resolve_chrome_path(
|
||||
&std::env::temp_dir().join("nomifun-browser-data"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("resolve chrome (set NOMIFUN_CHROME_BINARY)");
|
||||
let cfg = LaunchConfig {
|
||||
chrome_path: chrome,
|
||||
user_data_dir: std::env::temp_dir().join("nomifun-observe-contract-profile"),
|
||||
headful: false,
|
||||
};
|
||||
let launched = launch_chrome(&cfg, true).await.expect("launch chrome");
|
||||
|
||||
// 2) connect + 先 run_attach_loop 后 enable_auto_attach(顺序铁律,否则首批 attach 事件丢)。
|
||||
let _child = launched.child; // 保活 chrome(drop 即清理)。
|
||||
let conn = Connection::connect_launched(launched.transport)
|
||||
.await
|
||||
.expect("connect");
|
||||
let _attach_loop = conn.run_attach_loop();
|
||||
conn.enable_auto_attach().await.expect("auto attach");
|
||||
|
||||
// 3) 取一个 page session(createTarget about:blank + 等其 attachedToTarget)。
|
||||
let mut attached = conn.subscribe(EventAttachedToTarget::IDENTIFIER, None);
|
||||
let create = CreateTargetParams::new("about:blank");
|
||||
let cr = conn
|
||||
.send::<CreateTargetParams>(nomi_browser_engine::transport::ROOT_SESSION, &create)
|
||||
.await
|
||||
.expect("createTarget");
|
||||
let target_id = cr["targetId"].as_str().expect("targetId").to_string();
|
||||
let page_session = loop {
|
||||
let ev = tokio::time::timeout(Duration::from_secs(10), attached.recv())
|
||||
.await
|
||||
.expect("attach timeout")
|
||||
.expect("attach recv");
|
||||
if let Ok(att) = serde_json::from_value::<EventAttachedToTarget>(ev.params.clone()) {
|
||||
let tid: String = att.target_info.target_id.clone().into();
|
||||
if tid == target_id && att.target_info.r#type == "page" {
|
||||
break String::from(att.session_id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 4) navigate 到固定 fixture(file://,含 h1/button/textbox/iframe,aria 形态稳定)。
|
||||
conn.send::<PageEnable>(&page_session, &PageEnable::default())
|
||||
.await
|
||||
.expect("Page.enable");
|
||||
let mut load_rx = conn.subscribe("Page.loadEventFired", Some(&page_session));
|
||||
conn.send::<NavigateParams>(&page_session, &NavigateParams::new(fixture_url()))
|
||||
.await
|
||||
.expect("navigate");
|
||||
let _ = tokio::time::timeout(Duration::from_secs(15), load_rx.recv()).await;
|
||||
|
||||
// 5) arm 注入管线 + 等主 frame 的 utility-world context 就绪(主 frameId = page targetId)。
|
||||
let mgr = InjectionManager::new(conn.clone(), page_session.clone());
|
||||
let _ctx_loop = mgr.arm().await.expect("arm injection");
|
||||
let frame_id = target_id.clone();
|
||||
let mut ctx_id = None;
|
||||
for _ in 0..50 {
|
||||
if let Ok(id) = mgr.context_id_for(&frame_id) {
|
||||
ctx_id = Some(id);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let ctx_id = ctx_id.expect("utility world context never registered for main frame");
|
||||
|
||||
// 6) 取 document.body 的 objectId(同 utility world 的元素句柄),作 incrementalAriaSnapshot 的 node。
|
||||
let mut body_eval = EvaluateParams::new("document.body".to_string());
|
||||
body_eval.context_id = Some(ExecutionContextId::new(ctx_id));
|
||||
body_eval.return_by_value = Some(false);
|
||||
let body_res = conn
|
||||
.send::<EvaluateParams>(&page_session, &body_eval)
|
||||
.await
|
||||
.expect("evaluate document.body");
|
||||
let body_obj_id = body_res["result"]["objectId"]
|
||||
.as_str()
|
||||
.expect("document.body objectId")
|
||||
.to_string();
|
||||
|
||||
// 7) call_injected incrementalAriaSnapshot(body, {mode:ai, refPrefix:f0, depth:12});node 走
|
||||
// objectId,opts 走 by-value;返回 by-value(result.value 是 {full, iframeRefs, ...})。
|
||||
let node_arg = CallArgument {
|
||||
object_id: Some(RemoteObjectId::new(body_obj_id)),
|
||||
..Default::default()
|
||||
};
|
||||
let opts_arg = CallArgument {
|
||||
value: Some(serde_json::json!({"mode": "ai", "refPrefix": "f0", "depth": 12})),
|
||||
..Default::default()
|
||||
};
|
||||
let result = mgr
|
||||
.call_injected(
|
||||
&frame_id,
|
||||
"incrementalAriaSnapshot",
|
||||
vec![node_arg, opts_arg],
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("call_injected incrementalAriaSnapshot");
|
||||
|
||||
// 8) 取 .full(aria YAML 字符串)并快照。aria YAML 只含页面结构(generic/button/textbox/iframe
|
||||
// + ref=f0e<n>),不含随机 world 名,故无需归一。
|
||||
let full = result["value"]["full"]
|
||||
.as_str()
|
||||
.expect("incrementalAriaSnapshot result.value.full is a string")
|
||||
.to_string();
|
||||
assert!(!full.trim().is_empty(), "aria .full must be non-empty");
|
||||
|
||||
insta::assert_snapshot!(full);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 任务 6:CdpBackend::observe 全链集成测试(`#[ignore]`,本机/打包 chrome)。
|
||||
// 复用 tests/common 的 build_backend_for_fixture helper(勿再复制契约母本)。
|
||||
// 被测对象是 engine.observe():逐帧 incrementalAriaSnapshot → 缝合 → 脱敏 → 代际翻新 ref 表。
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
use nomi_browser_engine::{BrowserEngine, ObserveOpts};
|
||||
|
||||
/// 同进程 iframe 缝合:navigate iframe.html(srcdoc iframe 是同进程,应能缝)→ observe →
|
||||
/// 快照 yaml + 断言含父帧 ref f0e*;若同进程子帧缝上则含子内容(缩进的 Inner 链接)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn observe_iframe_stitched() {
|
||||
let engine = common::build_backend_for_fixture("iframe").await;
|
||||
engine
|
||||
.navigate(&common::fixture_url("iframe.html"), false)
|
||||
.await
|
||||
.expect("navigate iframe.html");
|
||||
let obs = engine
|
||||
.observe(&ObserveOpts::default())
|
||||
.await
|
||||
.expect("observe");
|
||||
|
||||
eprintln!("=== observe_iframe_stitched yaml ===\n{}\n=== end ===", obs.yaml);
|
||||
// 父帧 ref(f0e*)必现。
|
||||
assert!(obs.yaml.contains("f0e"), "expected parent-frame ref f0e*:\n{}", obs.yaml);
|
||||
// <data> 包裹。
|
||||
assert!(obs.yaml.contains("<data"), "expected <data> wrap:\n{}", obs.yaml);
|
||||
// 同进程 srcdoc 子帧应缝入(含其链接文本 Inner);srcdoc iframe 是同进程,应能缝。
|
||||
//
|
||||
// 快照里的 origin 是本机绝对 file:// 路径——快照前归一成稳定占位符,使快照可移植
|
||||
// (不钉死 checkout 路径),同时仍审 <data origin=…> 包裹 + 缝合结构。
|
||||
let normalized = normalize_fixture_origin(&obs.yaml, "iframe.html");
|
||||
insta::assert_snapshot!("observe_iframe_stitched", normalized);
|
||||
}
|
||||
|
||||
/// 把 yaml 里本机绝对 `origin="file:///.../tests/fixtures/<name>"` 归一成
|
||||
/// `origin="file://<FIXTURE>/<name>"`,让 insta 快照不钉死 checkout 路径。
|
||||
fn normalize_fixture_origin(yaml: &str, name: &str) -> String {
|
||||
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
|
||||
// Chrome reports file:// origins with exactly ONE leading slash before the
|
||||
// POSIX path (unix: `file:///abs`; windows: `file:///C:/abs`). But
|
||||
// CARGO_MANIFEST_DIR is `/abs` on unix and `C:/abs` on windows — so prepend a
|
||||
// slash only when it doesn't already start with one. The old unconditional
|
||||
// `file:///{manifest}` produced FOUR slashes on unix (`file:////Users/...`),
|
||||
// never matched the reported origin, and leaked the absolute checkout path
|
||||
// into the snapshot. This makes the placeholder fire on every host.
|
||||
let abs = if manifest.starts_with('/') {
|
||||
manifest
|
||||
} else {
|
||||
format!("/{manifest}")
|
||||
};
|
||||
let actual = format!("file://{abs}/tests/fixtures/{name}");
|
||||
let placeholder = format!("file://<FIXTURE>/{name}");
|
||||
yaml.replace(&actual, &placeholder)
|
||||
}
|
||||
|
||||
/// open shadow 可见、closed shadow 不可见(D11)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn observe_shadow_open_visible_closed_absent() {
|
||||
let engine = common::build_backend_for_fixture("shadow").await;
|
||||
engine
|
||||
.navigate(&common::fixture_url("shadow.html"), false)
|
||||
.await
|
||||
.expect("navigate shadow.html");
|
||||
let obs = engine
|
||||
.observe(&ObserveOpts::default())
|
||||
.await
|
||||
.expect("observe");
|
||||
|
||||
eprintln!("=== observe_shadow yaml ===\n{}\n=== end ===", obs.yaml);
|
||||
assert!(
|
||||
obs.yaml.contains("OpenShadowBtn"),
|
||||
"open shadow content must be visible:\n{}",
|
||||
obs.yaml
|
||||
);
|
||||
assert!(
|
||||
!obs.yaml.contains("ClosedShadowBtn"),
|
||||
"closed shadow content must NOT be visible:\n{}",
|
||||
obs.yaml
|
||||
);
|
||||
}
|
||||
|
||||
/// 代际单调递增(无导航两次 observe)+ 导航后旧 ref 在新表 resolve 不到(generation 跳变隔离)。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn observe_generation_increments_and_renews() {
|
||||
let engine = common::build_backend_for_fixture("gen").await;
|
||||
engine
|
||||
.navigate(&common::fixture_url("shadow.html"), false)
|
||||
.await
|
||||
.expect("navigate");
|
||||
let obs1 = engine.observe(&ObserveOpts::default()).await.expect("observe1");
|
||||
let obs2 = engine.observe(&ObserveOpts::default()).await.expect("observe2");
|
||||
// 无导航两次 observe:代际单调 +1。
|
||||
assert_eq!(
|
||||
obs2.generation.0,
|
||||
obs1.generation.0 + 1,
|
||||
"generation must increment per observe: {:?} -> {:?}",
|
||||
obs1.generation,
|
||||
obs2.generation
|
||||
);
|
||||
// 导航到不同页再 observe:代际继续递增(旧代际 ref 不再属于新表)。
|
||||
engine
|
||||
.navigate(&common::fixture_url("secrets.html"), false)
|
||||
.await
|
||||
.expect("navigate2");
|
||||
let obs3 = engine.observe(&ObserveOpts::default()).await.expect("observe3");
|
||||
assert!(
|
||||
obs3.generation.0 > obs2.generation.0,
|
||||
"generation must advance after navigation: {:?} -> {:?}",
|
||||
obs2.generation,
|
||||
obs3.generation
|
||||
);
|
||||
// 每代 entries 应非空(页面有可操作元素:button 等)。
|
||||
assert!(!obs1.entries.is_empty(), "obs1 entries should be non-empty");
|
||||
assert!(!obs3.entries.is_empty(), "obs3 entries should be non-empty");
|
||||
}
|
||||
|
||||
/// 脱敏:secrets.html 的明文 sk-/Bearer token 不出现在 yaml,且整体被 <data> 包裹。
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
|
||||
async fn observe_redacts_secrets() {
|
||||
let engine = common::build_backend_for_fixture("secrets").await;
|
||||
engine
|
||||
.navigate(&common::fixture_url("secrets.html"), false)
|
||||
.await
|
||||
.expect("navigate secrets.html");
|
||||
let obs = engine
|
||||
.observe(&ObserveOpts::default())
|
||||
.await
|
||||
.expect("observe");
|
||||
|
||||
eprintln!("=== observe_redacts_secrets yaml ===\n{}\n=== end ===", obs.yaml);
|
||||
assert!(obs.yaml.contains("<data"), "expected <data> wrap:\n{}", obs.yaml);
|
||||
// 明文 secret 实体不得出现。
|
||||
assert!(
|
||||
!obs.yaml.contains("sk-ABCDEFGHIJ0123456789xyzQRSTUV"),
|
||||
"plaintext sk- token leaked:\n{}",
|
||||
obs.yaml
|
||||
);
|
||||
assert!(
|
||||
!obs.yaml.contains("abcdef0123456789ABCDEFghij"),
|
||||
"plaintext Bearer token leaked:\n{}",
|
||||
obs.yaml
|
||||
);
|
||||
// D5(Critical):password <input value="hun]ter2sk"> 的明文 value 不得进 YAML。
|
||||
// 序列化层(observe)按 DOM type=password 收 ref 后宿主侧抹掉内联 value(短/低熵口令
|
||||
// 正则脱敏救不了,必须靠 type=password 信号置空)。**value 含 `]`** 压测确定性锚点 bug:
|
||||
// 旧 blank_inline_value 用全行 rfind(']') → 锚点落进 value 内部漏抹 → 明文泄露;改 ref-token
|
||||
// 锚后必不泄露。
|
||||
assert!(
|
||||
!obs.yaml.contains("hun]ter2"),
|
||||
"password value (with ]) leaked into observe yaml:\n{}",
|
||||
obs.yaml
|
||||
);
|
||||
// 脱敏占位符应在(确认确实脱敏,而非该文本压根没进 aria 树)。
|
||||
assert!(
|
||||
obs.yaml.contains("[REDACTED_SECRET]"),
|
||||
"expected redaction placeholder (text must have entered the aria tree):\n{}",
|
||||
obs.yaml
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! **引擎级 observe⊥act 互斥真 Chrome 冒烟**(`#[ignore]`,需 `NOMIFUN_CHROME_BINARY`)。
|
||||
//!
|
||||
//! 证明 `observe` 与 `act` 在**同一** `CdpBackend` 上无法交错(DESIGN §22 observe⊥act):并发对同一
|
||||
//! 引擎发一个 `act`(scroll)和一个 `observe`,二者都必须返回 `Ok`(被 `op_mutex` 串行化),且引擎绝不
|
||||
//! 因「动作改 DOM 撞上快照序列化」而报陈旧 ref / NodeStale 崩。`CdpBackend` 无 Chrome 不可构造,故常驻
|
||||
//! 单测只能钉 `op_mutex` 原语序列化(见 cdp.rs `op_mutex_tests`);真交错证明在此真 Chrome 测试。
|
||||
//!
|
||||
//! 手动跑:`NOMIFUN_CHROME_BINARY="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
||||
//! cargo nextest run -p nomi-browser-engine --run-ignored all -E 'test(observe_act_do_not_interleave)'`
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use nomi_browser_engine::progress::Progress;
|
||||
use nomi_browser_engine::{
|
||||
create_engine, ActSpec, EngineConfig, ObserveOpts, ScrollDir, ScrollTarget,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "需 NOMIFUN_CHROME_BINARY(真 Chrome):observe⊥act 引擎级互斥交错冒烟"]
|
||||
async fn observe_act_do_not_interleave() {
|
||||
let engine = create_engine(EngineConfig::default())
|
||||
.await
|
||||
.expect("engine builds with NOMIFUN_CHROME_BINARY set");
|
||||
engine
|
||||
.navigate("data:text/html,<button id=b>hi</button>", false)
|
||||
.await
|
||||
.expect("navigate");
|
||||
// 先 observe 建一张 ref 表,让后续动作有可瞄准的页面态。
|
||||
engine
|
||||
.observe(&ObserveOpts::default())
|
||||
.await
|
||||
.expect("prime observe");
|
||||
|
||||
let e1 = Arc::clone(&engine);
|
||||
let e2 = Arc::clone(&engine);
|
||||
|
||||
// 并发两操作:一个 scroll act + 一个 observe。二者必须被 op_mutex 串行化——都返回 Ok,
|
||||
// 且引擎不在动作中途被快照撞出 stale-ref 崩。
|
||||
let act = tokio::spawn(async move {
|
||||
let p = Progress::new(Duration::from_secs(10));
|
||||
e1.act(
|
||||
&ActSpec::Scroll {
|
||||
target: ScrollTarget::Viewport,
|
||||
direction: ScrollDir::Down,
|
||||
amount: Some(100.0),
|
||||
},
|
||||
&p,
|
||||
)
|
||||
.await
|
||||
});
|
||||
let obs = tokio::spawn(async move { e2.observe(&ObserveOpts::default()).await });
|
||||
|
||||
let (a, o) = tokio::join!(act, obs);
|
||||
assert!(a.unwrap().is_ok(), "并发 act 必须成功(被串行化)");
|
||||
assert!(o.unwrap().is_ok(), "并发 observe 必须成功(被串行化)");
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: crates/agent/nomi-browser-engine/tests/integration_act.rs
|
||||
expression: summary
|
||||
---
|
||||
normal(button,visible+stable+enabled) = Pass
|
||||
disabled(button,visible+stable+enabled) = Missing("enabled")
|
||||
readonly(input,editable) = Missing("editable")
|
||||
non_editable(heading,editable) = Err(Blocked)=true
|
||||
hidden(input,visible) = Missing("visible")
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: crates/agent/nomi-browser-engine/tests/integration_act.rs
|
||||
expression: summary
|
||||
---
|
||||
reachable.expect_hit_target((120,64)) = Ok
|
||||
reachable.hit_setup(mouse,block=false) = Ok(interceptor handle, group=act-0)
|
||||
reachable.hit_stop(no click) = Ok
|
||||
covered.expect_hit_target((120,224)) = Err(Blocked: <div id="overlay"></div>)
|
||||
covered.hit_setup(preliminary) = Err(Blocked: <div id="overlay"></div>)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: crates/agent/nomi-browser-engine/tests/observe_fixtures.rs
|
||||
expression: normalized
|
||||
---
|
||||
<data origin="file://<FIXTURE>/iframe.html">
|
||||
- generic [active] [ref=f0e1]:
|
||||
- heading "Smoke" [level=1] [ref=f0e2]
|
||||
- button "Submit order" [ref=f0e3]
|
||||
- generic [ref=f0e4]:
|
||||
- text: Email
|
||||
- textbox "Email" [ref=f0e5]
|
||||
- iframe [ref=f0e6]:
|
||||
- link "Inner" [ref=f1e2] [cursor=pointer]:
|
||||
- /url: "#"
|
||||
</data>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: crates/agent/nomi-browser-engine/tests/observe_fixtures.rs
|
||||
expression: full
|
||||
---
|
||||
- generic [active] [ref=f0e1]:
|
||||
- heading "Smoke" [level=1] [ref=f0e2]
|
||||
- button "Submit order" [ref=f0e3]
|
||||
- generic [ref=f0e4]:
|
||||
- text: Email
|
||||
- textbox "Email" [ref=f0e5]
|
||||
- iframe [ref=f0e6]
|
||||
Reference in New Issue
Block a user