Files
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

283 lines
15 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **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"
);
}