Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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)接受为基线。
//! 跑完核对任务管理器无残留 chromeBuilder 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}")
}
/// 解析 chromeenv NOMIFUN_CHROME_BINARY > 打包 > 数据目录 > 下载兜底)+ 托管 launchheadless
/// + 自建 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 **之前**就 Blockapprover 永不被咨询)。`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 seamheadful**:同 [`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 下是否仍产非空 PDFheadful 历史上对 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_dirsave_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-2fixture 测试默认给 temp_dir 作为 workspace(上传沙箱需要一个根;实际产品环境是 per-pet
// workspace,此处用 temp_dir 让 temp 下创建的测试文件都能通过沙箱校验)。
let workspace_dir = Some(std::env::temp_dir());
// headful=false / display=falseobserve 不依赖显示,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 buttonsubmit 时打标记(不真导航,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 点击切换 checkedURL 不变也能经元素态锚点判 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>
@@ -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=0scroll 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:可读正文含唯一 sentinelsearch 命中)+ 明文 secret(验证脱敏)。 -->
<div class="row">
<p id="intro">Welcome to the unique-sentinel-marker order page. Your order is ready.</p>
<!-- 明文 secretget_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_elementsCSS 选择器 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>
@@ -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
即 cancelDownloadfail-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=&quot;#&quot;>Inner</a>"></iframe>
</body></html>
@@ -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>
@@ -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>
@@ -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>
@@ -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 改 URLsame-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>
@@ -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 D4back/forward/reload/switch_frame 端到端集成**`#[ignore]`,本机/打包 chrome)。
//!
//! 验证 D4DESIGN §12 导航 / §13 Target-Tab + 裁决⑤ settle 复用 / ⑧ POST reload→IRREVERSIBLE):
//! - **back/forward**navigate A → navigate B → `act(Back)` → URL/内容回 A → `act(Forward)` → 回 B
//! settle 复用 D2load_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)'
//! 跑完核对任务管理器无残留 chromeBuilder 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 复用 D2load_state 正确;
/// 末页再 forward / 首页再 back → 良性「无更多历史」success=true 不报错。一个测试覆盖全 history 链
/// (建一次 chrome 最省资源)。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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 → 回 Asettle 复用 D2load_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
);
// 内容也应是 APAGE_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` 初始历史 entrylaunch + 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 后页面仍是同一 URLGET 页,非 POST,故 effect.after_anchor.irreversible == false)。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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 → 非 IRREVERSIBLEafter_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 = "需本机/打包 chromeset 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 元素 refobserve 把 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
/// (主帧 buttonswitch_frame → success=false(引导换 ref),不 Err、不 panic。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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 + fetchbase64 编码避免 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)'
//! 跑完核对任务管理器无残留 chromeBuilder 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>0Windows 上额外验
/// `Zone.Identifier` ADS 含 ZoneId=3。一个测试覆盖「沙箱落点 + MOTW」全链(建一次 chrome)。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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);
}
// 取下载链接的 reffixture 固定有一个 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.quarantineGatekeeper 打开可执行/归档时校验)。
// 两个写者:① chrome 原生 LSFileQuarantineagent=Chrome,形态更全含源URL+事件UUID;
// ② 我方 write_motw 兜底(XATTR_CREATE,仅 chrome 未落时填,agent=NomiFun)。二者异步竞争,
// 最终 agent 不定——但**安全不变量=quarantine 存在且标 web-download0081;**,与 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 是 Chromechrome 原生先写)或 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")))]
{
// linuxwrite_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 = "需本机/打包 chromeset 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)'
//! 跑完核对任务管理器无残留 chromeBuilder 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 封禁 enforcementfetch POST 到云元数据 IP 169.254.169.254 被防火墙 failRequest →
/// fetch 快速 reject。证明 `Fetch.enable` 拦截 + `is_blocked_ip` 在真请求路径生效。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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");
// 触发 fetchfire-and-forgetJS 把结果填进 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 = "需本机/打包 chromeset 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 = "需本机/打包 chromeset 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-D1FirewallConfig 不再 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-D2GatePost 悬挂等审批(批准 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-closedapprover
/// 永不被咨询——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 = "需本机/打包 chromeset 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 被 failRequestfail-closed,泄漏窗口闭合)→ fetch reject。关键
/// 不变量:approver 返回 Fail 后请求被**阻断**(不放行),且请求在有界时间内 settle(不永久挂起)。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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 **之前**就硬 BlockfailRequest)→ approver **永不**被咨询。这是有意
/// 的安全次序——绝不让人去「批准」一个发往内网/元数据 IP 的出口(即便审批者会点同意也轮不到它)。
///
/// 此测试同时是上面两个 approver 测试静默回归的**根因锁**:SD-1 落在 P3-D2 之后,离线伪域走真实
/// DNS 返 NXDOMAIN → 守卫 fail-closed 提前 Block → approver 没被咨询。当时 resolver 在引擎里硬编码、
/// 测试无从注入,故这条「守卫先于 approver」的交互从未被覆盖,回归才会静默。现 resolver 可注入后补此锁。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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 之前被 Blockapprover 永不被咨询。
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-actionsupload_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>0headless 下 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)'
//! 跑完核对任务管理器无残留 chromeBuilder 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 = "需本机/打包 chromeset 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 = "需本机/打包 chromeset 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>0headless 下 printToPDF 可靠)。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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_pdfheadful 校验)**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 = "需本机/打包 chromeset 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");
}
@@ -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 D2navigate 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)'
//! 跑完核对任务管理器无残留 chromeBuilder kill_on_drop 应自动清)。
//!
//! 真实结果(本机首跑会 eprintln 出 load_state/http_status/耗时——填回任务汇报)。
use std::time::Instant;
use nomi_browser_engine::{BrowserEngine, LoadState};
mod common;
/// 普通 file:// 页 navigatesettle 阶梯走通,load_state 达 Loadfile:// 无 HTTP 故 http_status
/// 可能为 None),耗时远小于 30s 总超时。act-c1.html 是静态页(脚本只挂事件,无持续网络)→ 应在
/// networkidle 短 cap 内达到 NetworkIdle,或至少 Load。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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 = "需本机/打包 chromeset 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 改 URLnavigatedWithinDocument,无新文档)。
/// navigate 应识别软导航降级路径(不重新等 load 超时),良性返回成功。
///
/// 注意:navigate 的初始文档 load 与之后的软导航是两件事——navigate 返回时通常已达 Load(初始文档
/// 的 load 先到)。本测试主要验证「不报错、final_url 反映软导航后的 URL(若软导航在 navigate 返回
/// 前发生)」+ 不卡死。软导航的「不重新等 load」降级路径在 run_settle 内(若软导航先于 load 到达)。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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 至少达 DOMContentLoadedDOM 已构建);多数情况达 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 另起**跨进程子 sessionOOPIF**,走 `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)'`。
//! 跑完核对无残留 chromeBuilder 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 多源触发 OOPIFcdp.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 armsite-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,OOPIFpage 的跨进程子帧)不自动 attach;修后
// handle_attached 对 page/iframe 子 session 级联 setAutoAttach → OOPIF 自动 attach →
// spawn_oopif_arm_loop 入 oopif_managers。实测 0→1。
assert!(
oopif_n >= 1,
"跨站 iframea.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 内页内容应缝入 observeiframe 节点下出现 InnerLink/INNERMARKER;实得:\n{}",
obs.yaml
);
// ②b 缝合结构:内页内容必须是 `iframe` 节点的**子节点**(跨帧 `f1e<n>` ref,而非游离顶层。
// 渲染按缩进表达父子;断言内页 ref 用了非 0 帧序前缀(`f1`+)= 真嵌在子帧里。
assert!(
obs.yaml.contains("[ref=f1e"),
"OOPIF 内页应以子帧 reff1e<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 下生效)。
//! 跑完核对任务管理器无残留 chromeBuilder 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 = "需本机 chromeheadless 启动后恰好一个受控 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 后应恰好一个受控 pagecreateTarget),无命令行 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 W4bcookie storage_state 捕获/恢复 端到端集成**`#[ignore]`,本机/打包 chrome)。
//!
//! 验证 W4bcookie 捕获/恢复机制):
//! - **往返保真**restore 一条全字段 cookie(含 sameSite + 持久 expires)→
//! capture 读回 → 名/值/域/sameSite/expires 全保真(**核心验收:cookie 跨 capture/restore 往返保真**)。
//! - **登录态可灌**restore 的 cookie 真写进 cookie storecapture 读回即证「灌入生效」,
//! 且 navigate 到该域后仍在——浏览器接受了它为该域的 cookie,跨导航存活 = 登录态可经 restore 灌入)。
//! - **默认 context 零回归**capture/restore 走默认 context(不传 browserContextId),机制仍工作。
//!
//! **W4b 不验 localStorage/IndexedDBW4c)也不验磁盘 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)'
//! 跑完核对任务管理器无残留 chromeBuilder 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 = "需本机/打包 chromeset 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");
// 持久 cookieexpires 是一个真实的未来时间戳(非 -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 = "需本机/打包 chromeset 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 W4clocalStorage storage_state 捕获/恢复 端到端集成**`#[ignore]`,本机/打包 chrome)。
//!
//! 验证 W4clocalStorageorigin-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 不验 IndexedDBbest-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)'
//! 跑完核对任务管理器无残留 chromeBuilder 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 Anavigate example.com → seed localStorage → captureorigin + 键值保真)。
/// engine B(新引擎,独立 profile):navigate example.com → restore A 的快照 → 页面 getItem 读回值生效。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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 的 localStorageorigin + 键值保真)。
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(独立 profilelocalStorage 必为空白起点)──────────────────
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-boundorigin 匹配 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-boundrestore 不跨 origin 误写**。state 里只有 example.com 的 localStorage;当前页面停在
/// about:blank(无 example.com origin)→ restore 是 no-op(不把 example.com 的键写进无关页面)。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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;
// 当前页面在另一个 originfixture 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 = "需本机/打包 chromeset 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 W4dstorage_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
//! → 引擎启动注入 cookienavigate 前即灌)
//! → navigate example.com(带上恢复的 cookie)→ restore_local_storageorigin-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)'
//! 跑完核对任务管理器无残留 chromeBuilder 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-01Chrome 钳到 ~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 = "需本机/打包 chromeset 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());
// ── 会话 Bload 加密 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_stateJSON 形态)。
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-boundB 现已在 example.comrestore_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 = "需本机/打包 chromeset 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 = "需本机/打包 chromeset 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.encdata_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");
}
// ── 伙伴 Bload 同一共享 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)接受为基线。
//! 跑完核对任务管理器无残留 chromeBuilder 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 = "需本机/打包 chromeset NOMIFUN_CHROME_BINARY 后 --run-ignored all"]
async fn observe_inject_contract_iframe() {
// 1) resolve chromeenv 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; // 保活 chromedrop 即清理)。
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 sessioncreateTarget 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 到固定 fixturefile://,含 h1/button/textbox/iframearia 形态稳定)。
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 走
// objectIdopts 走 by-value;返回 by-valueresult.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) 取 .fullaria 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);
}
// ═══════════════════════════════════════════════════════════════════════════
// 任务 6CdpBackend::observe 全链集成测试(`#[ignore]`,本机/打包 chrome)。
// 复用 tests/common 的 build_backend_for_fixture helper(勿再复制契约母本)。
// 被测对象是 engine.observe():逐帧 incrementalAriaSnapshot → 缝合 → 脱敏 → 代际翻新 ref 表。
// ═══════════════════════════════════════════════════════════════════════════
use nomi_browser_engine::{BrowserEngine, ObserveOpts};
/// 同进程 iframe 缝合:navigate iframe.htmlsrcdoc iframe 是同进程,应能缝)→ observe →
/// 快照 yaml + 断言含父帧 ref f0e*;若同进程子帧缝上则含子内容(缩进的 Inner 链接)。
#[tokio::test]
#[ignore = "需本机/打包 chromeset 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);
// 父帧 reff0e*)必现。
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 = "需本机/打包 chromeset 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 = "需本机/打包 chromeset 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 = "需本机/打包 chromeset 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
);
// D5Critical):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 必须成功(被串行化)");
}
@@ -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")
@@ -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>)
@@ -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>
@@ -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]