diff --git a/docs/testing.md b/docs/testing.md index 9869009..6d1fb80 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,6 +16,8 @@ Skill Deck 需要在 Windows、macOS 和 Linux 上处理文件、启动进程并 测试名称应与实际运行方式一致。Tauri 模拟运行时(`MockRuntime`)、前端模拟实现(mock)和本地测试服务器不会启动原生 WebView,使用这些能力的测试不属于桌面应用验收。具体限制见 [Tauri 测试说明](https://v2.tauri.app/develop/tests/)。 +Rust 单元测试使用 `MockRuntime` 构建 Tauri 应用时,`generate_context!` 必须启用 `test = true`。测试上下文继续读取应用配置,但不生成 Info.plist 等只属于真实应用运行时的内容,避免 macOS 测试二进制与应用入口重复嵌入同名符号。 + ## 选择和编写测试 1. 先确定需要防止的错误、预期结果和需要区分的失败类型,再选择测试类型。 diff --git a/scripts/__tests__/rust-test-policy.test.mjs b/scripts/__tests__/rust-test-policy.test.mjs new file mode 100644 index 0000000..5db249b --- /dev/null +++ b/scripts/__tests__/rust-test-policy.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const rustSourceRoot = fileURLToPath(new URL("../../src-tauri/src/", import.meta.url)); +const applicationEntryUrl = new URL("../../src-tauri/src/lib.rs", import.meta.url); + +async function rustSourceFiles(root) { + const entries = await readdir(root, { withFileTypes: true }); + const files = await Promise.all(entries.map(async (entry) => { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) return rustSourceFiles(entryPath); + return entry.isFile() && entry.name.endsWith(".rs") ? [entryPath] : []; + })); + return files.flat(); +} + +test("Tauri contexts distinguish test fixtures from the application runtime", async () => { + const sourceFiles = await rustSourceFiles(rustSourceRoot); + const ordinaryContexts = []; + let contextCalls = 0; + let testContexts = 0; + for (const sourceFile of sourceFiles) { + const source = await readFile(sourceFile, "utf8"); + contextCalls += source.match(/tauri::generate_context!\(/g)?.length ?? 0; + if (source.includes("tauri::generate_context!()")) { + ordinaryContexts.push(path.relative(rustSourceRoot, sourceFile)); + } + testContexts += source.match(/tauri::generate_context!\(test\s*=\s*true\)/g)?.length ?? 0; + } + + assert.deepEqual(ordinaryContexts, ["lib.rs"]); + assert.ok(testContexts > 0); + assert.equal(contextCalls, ordinaryContexts.length + testContexts); + const applicationEntry = await readFile(applicationEntryUrl, "utf8"); + assert.match(applicationEntry, /\.run\(tauri::generate_context!\(\)\)/); +}); diff --git a/src-tauri/src/runtime/application_updater.rs b/src-tauri/src/runtime/application_updater.rs index 2dee5e1..4aa909e 100644 --- a/src-tauri/src/runtime/application_updater.rs +++ b/src-tauri/src/runtime/application_updater.rs @@ -186,7 +186,8 @@ mod tests { use crate::models::{NetworkProxySettings, ProxyMode}; fn test_app(manifest_urls: &[url::Url]) -> tauri::App { - let mut context = tauri::generate_context!(); + // 测试上下文不嵌入 macOS Info.plist,避免与应用入口重复定义符号。 + let mut context = tauri::generate_context!(test = true); let updater_config = context .config_mut() .plugins diff --git a/src-tauri/src/runtime/http_transport.rs b/src-tauri/src/runtime/http_transport.rs index 5759201..461743f 100644 --- a/src-tauri/src/runtime/http_transport.rs +++ b/src-tauri/src/runtime/http_transport.rs @@ -247,9 +247,13 @@ impl HttpTransport { response .chunk() .await - .map_err(|_| HttpTransportError::Request { + .map_err(|error| HttpTransportError::Request { stage: "response_body", - reason: "transport", + reason: if error.is_timeout() { + "timeout" + } else { + "transport" + }, })? { if body.len().saturating_add(chunk.len()) > max_body_bytes { @@ -321,7 +325,7 @@ fn request_timeout() -> HttpTransportError { #[cfg(test)] mod tests { - use std::io::Write; + use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, Once}; @@ -559,34 +563,80 @@ mod tests { #[tokio::test] async fn total_timeout_includes_response_body_reading() { let listener = TcpListener::bind("127.0.0.1:0").expect("origin listener"); + listener + .set_nonblocking(true) + .expect("configure origin listener"); let target = format!( "http://{}/slow", listener.local_addr().expect("origin addr") ); let worker = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("origin request"); + let accept_deadline = std::time::Instant::now() + Duration::from_secs(2); + let (mut stream, _) = loop { + match listener.accept() { + Ok(connection) => break connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < accept_deadline, + "origin request was not received before the deadline" + ); + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept origin request: {error}"), + } + }; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("configure origin request timeout"); + let mut request = BufReader::new(stream.try_clone().expect("clone origin stream")); + loop { + let mut line = String::new(); + let bytes_read = request.read_line(&mut line).expect("read origin request"); + assert_ne!(bytes_read, 0, "origin request ended before headers"); + if line == "\r\n" { + break; + } + } stream .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\no") .expect("partial response"); - thread::sleep(Duration::from_millis(250)); - let _ = stream.write_all(b"kay"); + stream.flush().expect("flush partial response"); + thread::sleep(Duration::from_millis(500)); + if stream.write_all(b"kay").is_ok() { + let _ = stream.flush(); + let _ = stream.shutdown(std::net::Shutdown::Write); + } }); let started = std::time::Instant::now(); let result = direct_client() - .get(HttpGetRequest::new(target, Duration::from_millis(40), 1024)) + .get(HttpGetRequest::new( + target, + Duration::from_millis(200), + 1024, + )) .await; let elapsed = started.elapsed(); worker.join().expect("origin worker"); - assert!(elapsed < Duration::from_millis(200)); - assert!(matches!( - result, - Err(super::HttpTransportError::Request { - reason: "timeout", - .. - }) - )); + assert!(elapsed < Duration::from_millis(400)); + let error = match result { + Ok(response) => panic!( + "request unexpectedly succeeded with status {}", + response.status + ), + Err(error) => error, + }; + assert!( + matches!( + error, + super::HttpTransportError::Request { + reason: "timeout", + .. + } + ), + "unexpected timeout error: {error:?}" + ); } #[tokio::test]