Files
胡飞 4fbdb84cbd fix(windows): 后台子进程统一隐藏控制台,修掉启动闪黑框
Windows 上应用启动会闪一下控制台窗口,根因是启动期识别 WSL 发行版时直接
spawn 了控制台程序 wsl.exe 且未设置 CREATE_NO_WINDOW,系统为它新建了控制台
窗口(stdout 已重定向到管道也一样)。该路径由 #182 引入。

- terminal:WSL 识别改走 process_util::configure_background_child
- html-preview:Windows 分支拉起浏览器前设置 CREATE_NO_WINDOW,cmd /C start
  不再闪窗;纯逻辑 crate 内联 helper,不额外引入 tokio
- workspace_explorer:容器后端 docker exec 收敛到 process-util
- main:设置页安装 skill 的 npx/node 启动器同样处理
- 补 4 条结构 contract 测试钉住这些 spawn 点,防止回归
- AGENTS.md 沉淀该约定:GUI 进程 spawn 控制台程序必须隐藏控制台

验证:cargo test -p html-preview、cargo test -p terminal --lib
wsl_distributions、cargo test -p workspace_explorer --lib backend、
cargo check -p main --tests、cargo test -p main --bin navop mcp_skill、
cargo metadata --locked --offline 全部通过;改动文件无新增 clippy 告警。
Windows 实机「不再闪窗」需另行确认(macOS 上 cfg(windows) 不参与编译)。
2026-09-19 11:31:36 +08:00

232 lines
8.3 KiB
Rust

use futures::FutureExt;
use html_preview::{
HtmlPreviewAction, HtmlPreviewAssetResolver, HtmlPreviewDocument, HtmlPreviewTransformOutput,
clear_html_preview_transform_provider, normalize_html_document, register_extension_asset_root,
resolve_extension_asset_url, set_html_preview_transform_provider, system_browser_open_command,
transform_html_preview_document, write_browser_html_preview_document_to_dir,
write_html_preview_document_to_dir,
};
#[test]
fn normalizes_body_fragment_into_complete_document() {
let normalized = normalize_html_document("<h1>Hello</h1><script src=\"app.js\"></script>");
assert!(normalized.starts_with("<!doctype html>"));
assert!(normalized.contains("<html"));
assert!(normalized.contains("<head>"));
assert!(normalized.contains("<body><h1>Hello</h1><script src=\"app.js\"></script></body>"));
assert!(normalized.ends_with("</html>"));
}
#[test]
fn preserves_existing_complete_document() {
let source = "<!doctype html><html><head><title>A</title></head><body>B</body></html>";
assert_eq!(source, normalize_html_document(source));
}
#[test]
fn completes_missing_basic_tags_without_dropping_head_content() {
let source = "<title>A</title></head><body><main>Partial";
let normalized = normalize_html_document(source);
assert!(normalized.contains("<head><title>A</title></head>"));
assert!(normalized.contains("<body><main>Partial</main></body>"));
}
#[test]
fn preview_document_runs_transforms_before_normalization() {
let transform = HtmlPreviewTransformOutput {
html: "<section>Changed</section>".to_string(),
assets: vec![],
};
let document = HtmlPreviewDocument::from_transform("html", transform);
assert_eq!("html", document.language());
assert!(document.source_html().contains("Changed"));
assert!(
document
.render_html()
.contains("<body><section>Changed</section></body>")
);
}
#[test]
fn transform_provider_builds_document_before_normalization() {
clear_html_preview_transform_provider();
set_html_preview_transform_provider(|language, html| {
assert_eq!("html", language);
assert_eq!("<main>Original", html);
async move {
Ok(Some(HtmlPreviewTransformOutput {
html: "<section>Changed</section>".to_string(),
assets: vec![html_preview::HtmlPreviewAsset {
path: "app.css".to_string(),
url: "onet-extension://com.example.preview/app.css".to_string(),
}],
}))
}
.boxed()
});
let document =
futures::executor::block_on(transform_html_preview_document("html", "<main>Original"))
.unwrap();
assert_eq!("html", document.language());
assert_eq!("<main>Original", document.source_html());
assert!(
document
.render_html()
.contains("<body><section>Changed</section></body>")
);
assert_eq!(1, document.assets().len());
clear_html_preview_transform_provider();
}
#[test]
fn applying_transform_preserves_original_source_html() {
let mut document = HtmlPreviewDocument::new("html", "<main>Original");
document.apply_transform(HtmlPreviewTransformOutput {
html: "<section>Changed</section>".to_string(),
assets: vec![],
});
assert_eq!("<main>Original", document.source_html());
assert!(
document
.render_html()
.contains("<body><section>Changed</section></body>")
);
}
#[test]
fn asset_resolver_rewrites_extension_asset_urls_and_rejects_escape() {
let resolver =
HtmlPreviewAssetResolver::new("com.example.preview", "/extensions/com.example.preview");
assert_eq!(
"onet-extension://com.example.preview/assets/app.css",
resolver.resolve("assets/app.css").unwrap()
);
assert!(resolver.resolve("../secret.css").is_none());
assert!(resolver.resolve("https://example.com/app.css").is_none());
}
#[test]
fn registered_extension_asset_roots_resolve_safe_protocol_urls() {
let assets_dir = tempfile::TempDir::new().unwrap();
let extension_id = "com.example.preview.registered-root";
register_extension_asset_root(extension_id, assets_dir.path());
assert_eq!(
assets_dir.path().join("app.css"),
resolve_extension_asset_url(&format!("onet-extension://{extension_id}/app.css")).unwrap()
);
assert!(
resolve_extension_asset_url(&format!("onet-extension://{extension_id}/../secret"))
.is_none()
);
assert!(resolve_extension_asset_url(&format!("https://{extension_id}/app.css")).is_none());
}
#[test]
fn writes_complete_html_preview_document_without_overwriting_existing_file() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("onetcli-html-preview.html"), "existing").unwrap();
let document = HtmlPreviewDocument::new("html", "<main>Download</main>");
let path = write_html_preview_document_to_dir(&document, dir.path()).unwrap();
assert_eq!(dir.path().join("onetcli-html-preview-1.html"), path);
let saved = std::fs::read_to_string(path).unwrap();
assert!(saved.starts_with("<!doctype html>"));
assert!(saved.contains("<body><main>Download</main></body>"));
assert_eq!(
"existing",
std::fs::read_to_string(dir.path().join("onetcli-html-preview.html")).unwrap()
);
}
#[test]
fn writes_browser_ready_html_with_extension_assets_rewritten_to_file_urls() {
let assets_dir = tempfile::TempDir::new().unwrap();
std::fs::write(assets_dir.path().join("app.css"), "body{}").unwrap();
std::fs::write(assets_dir.path().join("chart.js"), "window.loaded=true").unwrap();
register_extension_asset_root("com.example.preview", assets_dir.path());
let document = HtmlPreviewDocument::from_transform(
"html",
HtmlPreviewTransformOutput {
html: r#"<link rel="stylesheet" href="onet-extension://com.example.preview/app.css"><script src="onet-extension://com.example.preview/chart.js"></script>"#.to_string(),
assets: vec![
html_preview::HtmlPreviewAsset {
path: "app.css".to_string(),
url: "onet-extension://com.example.preview/app.css".to_string(),
},
html_preview::HtmlPreviewAsset {
path: "chart.js".to_string(),
url: "onet-extension://com.example.preview/chart.js".to_string(),
},
],
},
);
let output_dir = tempfile::TempDir::new().unwrap();
let path = write_browser_html_preview_document_to_dir(&document, output_dir.path()).unwrap();
let saved = std::fs::read_to_string(path).unwrap();
assert!(!saved.contains("onet-extension://"));
assert!(saved.contains("href=\"file://"));
assert!(saved.contains("src=\"file://"));
assert!(saved.contains("app.css"));
assert!(saved.contains("chart.js"));
}
#[test]
fn system_browser_open_command_targets_platform_default_opener() {
let command = system_browser_open_command(std::path::Path::new("/tmp/onetcli-preview.html"));
if cfg!(target_os = "macos") {
assert_eq!("open", command.program);
assert_eq!(vec!["/tmp/onetcli-preview.html"], command.args);
} else if cfg!(target_os = "windows") {
assert_eq!("cmd", command.program);
assert_eq!(
vec!["/C", "start", "", "/tmp/onetcli-preview.html"],
command.args
);
} else {
assert_eq!("xdg-open", command.program);
assert_eq!(vec!["/tmp/onetcli-preview.html"], command.args);
}
}
#[test]
fn action_ids_are_stable_and_ordered_for_html_toolbar() {
assert_eq!(
vec![
"html-preview",
"html-source",
"html-copy",
"html-download",
"html-open-window"
],
HtmlPreviewAction::toolbar_ids()
);
}
/// 结构契约:拉起系统浏览器必须隐藏控制台窗口。
///
/// Windows 分支通过 `cmd /C start` 打开预览,直接 spawn 会让系统新建一个控制台
/// 窗口(表现为闪一下 cmd 黑框)。
#[test]
fn browser_launch_hides_the_console_window() {
let source = include_str!("../src/browser.rs");
assert!(source.contains("hide_background_console(&mut process)"));
assert!(source.contains("command.creation_flags(CREATE_NO_WINDOW)"));
}