diff --git a/docs/testing.md b/docs/testing.md index 9869009..2f8fe12 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 应用时,测试夹具只提供被测行为需要的配置和资源。优先使用 Tauri 的测试上下文;确需通过 `generate_context!` 读取应用配置时,使用测试模式,避免测试二进制生成只属于真实应用运行时的资源。 + ## 选择和编写测试 1. 先确定需要防止的错误、预期结果和需要区分的失败类型,再选择测试类型。 diff --git a/src-tauri/src/environment/wsl/operations/source_acquisition.rs b/src-tauri/src/environment/wsl/operations/source_acquisition.rs index e538a54..2ec2601 100644 --- a/src-tauri/src/environment/wsl/operations/source_acquisition.rs +++ b/src-tauri/src/environment/wsl/operations/source_acquisition.rs @@ -348,12 +348,12 @@ mod tests { use std::collections::BTreeMap; #[cfg(unix)] use std::fs; - #[cfg(unix)] + #[cfg(target_os = "linux")] use std::os::unix::fs::PermissionsExt; #[cfg(unix)] use std::process::Command; - #[cfg(unix)] + #[cfg(target_os = "linux")] fn git(cwd: &std::path::Path, args: &[&str]) -> String { let output = Command::new("git") .current_dir(cwd) @@ -409,7 +409,7 @@ mod tests { assert_eq!(workspace.deferred_source_cleanup_count(), 1); } - #[cfg(unix)] + #[cfg(target_os = "linux")] #[test] fn git_acquisition_reports_cloned_head_even_if_source_advances_after_clone() { let temp = tempfile::tempdir().expect("temp"); @@ -718,7 +718,7 @@ mod tests { assert!(destination.join("keep").is_file()); } - #[cfg(unix)] + #[cfg(target_os = "linux")] #[test] fn failed_git_acquisition_removes_its_managed_temporary_root() { let managed_root = std::path::PathBuf::from(format!( @@ -744,7 +744,7 @@ mod tests { assert!(!managed_root.exists()); } - #[cfg(unix)] + #[cfg(target_os = "linux")] #[test] fn git_timeout_starts_when_the_clone_process_starts() { let temp = tempfile::tempdir().expect("fake Git temp dir"); @@ -788,7 +788,7 @@ mod tests { assert!(!managed_root.exists()); } - #[cfg(unix)] + #[cfg(target_os = "linux")] #[test] fn git_probe_uses_a_stable_diagnostic_locale() { let temp = tempfile::tempdir().expect("fake Git temp dir"); diff --git a/src-tauri/src/runtime/application_updater.rs b/src-tauri/src/runtime/application_updater.rs index 2dee5e1..bd1b8a3 100644 --- a/src-tauri/src/runtime/application_updater.rs +++ b/src-tauri/src/runtime/application_updater.rs @@ -180,21 +180,23 @@ mod tests { use std::sync::Mutex; use std::thread; - use tauri::test::{mock_builder, MockRuntime}; + use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; use super::*; use crate::models::{NetworkProxySettings, ProxyMode}; + const TEST_UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQ1NkM0MjI5RDUzMzU2MkMKUldRc1ZqUFZLVUpzMVRUYmF2dW0rcjJYbm5pWFp6cEplQWNDc09xb1pnb0ozejJmQUtqd2JjNTUK"; + fn test_app(manifest_urls: &[url::Url]) -> tauri::App { - let mut context = tauri::generate_context!(); - let updater_config = context - .config_mut() - .plugins - .0 - .get_mut("updater") - .expect("updater plugin config"); - updater_config["endpoints"] = serde_json::json!(manifest_urls); - updater_config["dangerousInsecureTransportProtocol"] = serde_json::json!(true); + let mut context = mock_context(noop_assets()); + context.config_mut().plugins.0.insert( + "updater".to_string(), + serde_json::json!({ + "pubkey": TEST_UPDATER_PUBKEY, + "endpoints": manifest_urls, + "dangerousInsecureTransportProtocol": true + }), + ); mock_builder() .plugin(tauri_plugin_updater::Builder::new().build()) .build(context) diff --git a/src-tauri/src/runtime/http_transport.rs b/src-tauri/src/runtime/http_transport.rs index 5759201..845d566 100644 --- a/src-tauri/src/runtime/http_transport.rs +++ b/src-tauri/src/runtime/http_transport.rs @@ -321,8 +321,8 @@ fn request_timeout() -> HttpTransportError { #[cfg(test)] mod tests { - use std::io::Write; - use std::net::TcpListener; + use std::io::{BufRead, BufReader, Write}; + use std::net::{SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, Once}; use std::thread; @@ -341,6 +341,26 @@ mod tests { struct PreCancelLogger; + struct StalledResponse { + target: String, + address: SocketAddr, + headers_sent: tokio::sync::oneshot::Receiver<()>, + release: std::sync::mpsc::Sender<()>, + worker: thread::JoinHandle<()>, + } + + impl StalledResponse { + fn finish(self) { + let _ = self.release.send(()); + if let Ok(mut stream) = + TcpStream::connect_timeout(&self.address, Duration::from_secs(1)) + { + let _ = stream.write_all(b"GET /cleanup HTTP/1.1\r\nHost: localhost\r\n\r\n"); + } + self.worker.join().expect("origin worker"); + } + } + impl log::Log for PreCancelLogger { fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { metadata.level() <= log::Level::Warn @@ -381,6 +401,42 @@ mod tests { ))) } + fn stalled_response() -> StalledResponse { + let listener = TcpListener::bind("127.0.0.1:0").expect("origin listener"); + let address = listener.local_addr().expect("origin addr"); + let target = format!("http://{address}/stalled"); + let (headers_sent_tx, headers_sent_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let worker = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("origin request"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("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: 1\r\n\r\n") + .expect("response headers"); + stream.flush().expect("flush response headers"); + let _ = headers_sent_tx.send(()); + let _ = release_rx.recv_timeout(Duration::from_secs(2)); + }); + StalledResponse { + target, + address, + headers_sent: headers_sent_rx, + release: release_tx, + worker, + } + } + #[test] fn requests_receive_unique_operation_ids_and_allow_workflow_correlation() { let first = HttpGetRequest::new("https://example.com/first", Duration::from_secs(1), 1024); @@ -558,35 +614,63 @@ mod tests { #[tokio::test] async fn total_timeout_includes_response_body_reading() { - let listener = TcpListener::bind("127.0.0.1:0").expect("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"); - 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"); + let mut stalled = stalled_response(); + let client = direct_client(); + let target = stalled.target.clone(); + let request = tokio::spawn(async move { + client + .get(HttpGetRequest::new( + target, + Duration::from_millis(500), + 1024, + )) + .await }); - let started = std::time::Instant::now(); - - let result = direct_client() - .get(HttpGetRequest::new(target, Duration::from_millis(40), 1024)) - .await; - let elapsed = started.elapsed(); + let headers_sent = + tokio::time::timeout(Duration::from_secs(2), &mut stalled.headers_sent).await; + let request_result = tokio::time::timeout(Duration::from_secs(2), request).await; + stalled.finish(); + headers_sent + .expect("response headers sent before deadline") + .expect("response headers signal"); + let result = request_result + .expect("request completed before test deadline") + .expect("request task"); + assert!( + matches!( + result, + Err(super::HttpTransportError::Request { + reason: "timeout", + .. + }) + ), + "request should time out while reading the response body" + ); + } - worker.join().expect("origin worker"); - assert!(elapsed < Duration::from_millis(200)); - assert!(matches!( - result, - Err(super::HttpTransportError::Request { - reason: "timeout", - .. - }) - )); + #[tokio::test] + async fn status_only_returns_after_headers_without_reading_the_body() { + let mut stalled = stalled_response(); + + let headers_sent = tokio::time::timeout(Duration::from_secs(2), &mut stalled.headers_sent); + let result = tokio::time::timeout( + Duration::from_secs(2), + direct_client().get(HttpGetRequest::status_only( + stalled.target.clone(), + Duration::from_secs(10), + )), + ) + .await; + let headers_sent = headers_sent.await; + stalled.finish(); + headers_sent + .expect("response headers sent before deadline") + .expect("response headers signal"); + let response = result + .expect("status-only request returned after response headers") + .expect("status-only response"); + assert_eq!(response.status, reqwest::StatusCode::OK); + assert!(response.body.is_empty()); } #[tokio::test] diff --git a/src-tauri/src/runtime/network_connection.rs b/src-tauri/src/runtime/network_connection.rs index cb791dd..de285e9 100644 --- a/src-tauri/src/runtime/network_connection.rs +++ b/src-tauri/src/runtime/network_connection.rs @@ -306,31 +306,6 @@ mod tests { assert_eq!(result.reason_code.as_deref(), Some("request_failed")); } - #[tokio::test] - async fn http_probe_succeeds_without_buffering_a_large_response_body() { - let proxy = tiny_http::Server::http("127.0.0.1:0").expect("proxy server"); - let proxy_url = format!("http://{}", proxy.server_addr()); - let server_thread = thread::spawn(move || { - proxy - .recv_timeout(Duration::from_secs(2)) - .expect("proxy receive") - .expect("proxy request before deadline") - .respond(tiny_http::Response::from_data(vec![b'x'; 2 * 1024 * 1024])) - .expect("proxy response"); - }); - let settings = Arc::new(ProxySettingsStore::new(NetworkProxySettings { - mode: ProxyMode::Custom, - custom_proxy_url: Some(proxy_url), - ..NetworkProxySettings::default() - })); - - let result = test_http_target(&HttpTransport::new(settings), "http://skills.sh/").await; - - server_thread.join().expect("proxy server thread"); - assert_eq!(result.status, ProxyConnectionStatus::Succeeded); - assert_eq!(result.reason_code, None); - } - #[tokio::test] async fn wsl_adapter_maps_an_unavailable_distribution_without_running_wsl() { let wsl = WslRuntime::new_with_support(false, false);