From a487b507eb49b8447f543c571886f631a19d6104 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Sat, 29 Aug 2026 00:22:38 +0000 Subject: [PATCH 01/24] feat(xcresult): resolve test files from declarations, not failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `.xcresult` records where a failure was *raised*, never where a test is declared — a passing test's summary is 638 bytes with no path at all. So the file we report is inferred from the failure, and a failure raised inside a helper hands the test to whoever owns the helper. Behind `--use-experimental-xcresult-test-locations` (env `TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS`), ask a language server instead: `documentSymbol` over the checkout names the type containing each method, which is the `Suite`/`case` pair an xcresult identifier already gives us. `sourcekit-lsp` and `clangd` ship in the Command Line Tools as well as Xcode, so this is the same shape as what we already do — shell out to an Xcode tool, parse structured output — not a new class of dependency. The flag also changes which calls we make. The declaration path issues `get test-results tests` and `get test-results summary`, and never `get object --legacy`, so the unbounded per-test summary fetch — 6 GB of JSON and a 48 GB peak footprint on one timed-out test — is not reachable from it. Ids do not move: `nodeIdentifierURL` on the modern API is the legacy record's `identifierURL` under another name, and an integration test pins both paths to the same ids and timestamps. A test with no declaration to find (Quick, `+testInvocations`) falls back to the modern API's own `sourceLocation`, vetted against the same vendored-path rules as the failure-summary path — the two fail in disjoint situations. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + cli/src/context.rs | 124 ++++--- cli/src/upload_command.rs | 15 + constants/src/lib.rs | 3 + context/src/junit/parser.rs | 10 +- xcresult/CONTRIBUTING.md | 66 ++++ xcresult/Cargo.toml | 1 + xcresult/src/lib.rs | 2 + xcresult/src/lsp.rs | 316 ++++++++++++++++ xcresult/src/main.rs | 47 ++- xcresult/src/test_locations.rs | 654 +++++++++++++++++++++++++++++++++ xcresult/src/xcresult.rs | 372 +++++++++++++++++-- xcresult/src/xcrun.rs | 45 ++- xcresult/tests/xcresult.rs | 221 +++++++++++ 14 files changed, 1772 insertions(+), 105 deletions(-) create mode 100644 xcresult/src/lsp.rs create mode 100644 xcresult/src/test_locations.rs diff --git a/Cargo.lock b/Cargo.lock index 75b576d9..bf9745e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7034,6 +7034,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "constants", "context", "flate2", "lazy_static", diff --git a/cli/src/context.rs b/cli/src/context.rs index 4363f3d2..6e62cf24 100644 --- a/cli/src/context.rs +++ b/cli/src/context.rs @@ -40,7 +40,7 @@ use proto::test_context::test_run::{ use regex::Regex; use tempfile::TempDir; #[cfg(target_os = "macos")] -use xcresult::xcresult::XCResult; +use xcresult::{test_locations::Limits, xcresult::XCResult}; use crate::error_report::InterruptingError; use crate::{ @@ -137,6 +137,8 @@ pub fn gather_initial_test_context( pr_number, #[cfg(target_os = "macos")] use_experimental_failure_summary, + #[cfg(target_os = "macos")] + use_experimental_xcresult_test_locations, .. } = upload_args; @@ -151,6 +153,15 @@ pub fn gather_initial_test_context( )?; tracing::debug!("Found repo state: {:?}", repo); + #[cfg(target_os = "macos")] + let xcresult_options = XCResultOptions { + repo: &repo.repo, + org_url_slug: &org_url_slug, + repo_root: &repo.repo_root, + use_experimental_failure_summary, + use_experimental_test_locations: use_experimental_xcresult_test_locations, + }; + let (junit_path_wrappers, bep_result, junit_path_wrappers_temp_dir) = coalesce_junit_path_wrappers( junit_paths, @@ -158,11 +169,7 @@ pub fn gather_initial_test_context( #[cfg(target_os = "macos")] xcresult_path, #[cfg(target_os = "macos")] - &repo.repo, - #[cfg(target_os = "macos")] - org_url_slug.clone(), - #[cfg(target_os = "macos")] - use_experimental_failure_summary, + &xcresult_options, test_reports, allow_empty_test_results, )?; @@ -620,13 +627,22 @@ fn parse_as_bep(dir: String) -> anyhow::Result { result } +/// What the xcresult conversion needs beyond the bundle, kept together so an option added +/// later does not thread another `#[cfg]`-gated parameter through three signatures. +#[cfg(target_os = "macos")] +struct XCResultOptions<'a> { + repo: &'a RepoUrlParts, + org_url_slug: &'a str, + repo_root: &'a str, + use_experimental_failure_summary: bool, + use_experimental_test_locations: bool, +} + fn coalesce_junit_path_wrappers( junit_paths: Vec, bazel_bep_path: Option, #[cfg(target_os = "macos")] xcresult_path: Option, - #[cfg(target_os = "macos")] repo: &RepoUrlParts, - #[cfg(target_os = "macos")] org_url_slug: String, - #[cfg(target_os = "macos")] use_experimental_failure_summary: bool, + #[cfg(target_os = "macos")] xcresult_options: &XCResultOptions, test_reports: Vec, allow_empty_test_results: bool, ) -> anyhow::Result<( @@ -673,13 +689,7 @@ fn coalesce_junit_path_wrappers( #[cfg(target_os = "macos")] if xcresult_path.is_some() { let temp_dir = tempfile::tempdir()?; - let temp_paths = handle_xcresult( - &temp_dir, - xcresult_path, - repo, - &org_url_slug, - use_experimental_failure_summary, - )?; + let temp_paths = handle_xcresult(&temp_dir, xcresult_path, xcresult_options)?; _junit_path_wrappers_temp_dir = Some(temp_dir); junit_path_wrappers = [junit_path_wrappers.as_slice(), temp_paths.as_slice()].concat(); if junit_path_wrappers.is_empty() { @@ -711,11 +721,7 @@ fn coalesce_junit_path_wrappers( #[cfg(target_os = "macos")] &test_report, #[cfg(target_os = "macos")] - repo, - #[cfg(target_os = "macos")] - &org_url_slug, - #[cfg(target_os = "macos")] - use_experimental_failure_summary, + xcresult_options, ) { #[cfg(target_os = "macos")] { @@ -741,20 +747,12 @@ fn coalesce_junit_path_wrappers( fn parse_as_xcresult( #[cfg(target_os = "macos")] test_report: &String, - #[cfg(target_os = "macos")] repo: &RepoUrlParts, - #[cfg(target_os = "macos")] org_url_slug: &String, - #[cfg(target_os = "macos")] use_experimental_failure_summary: bool, + #[cfg(target_os = "macos")] xcresult_options: &XCResultOptions, ) -> Option { #[cfg(target_os = "macos")] { let temp_dir = tempfile::tempdir().ok()?; - let temp_paths = handle_xcresult( - &temp_dir, - Some(test_report.clone()), - repo, - &org_url_slug, - use_experimental_failure_summary, - ); + let temp_paths = handle_xcresult(&temp_dir, Some(test_report.clone()), xcresult_options); if temp_paths.is_ok() { return Some(temp_dir); } else { @@ -874,18 +872,28 @@ pub async fn gather_upload_id_context( fn handle_xcresult( junit_temp_dir: &tempfile::TempDir, xcresult_path: Option, - repo: &RepoUrlParts, - org_url_slug: &String, - use_experimental_failure_summary: bool, + options: &XCResultOptions, ) -> Result, anyhow::Error> { let mut temp_paths = Vec::new(); if let Some(xcresult_path) = xcresult_path { - let xcresult = XCResult::new( - xcresult_path, - org_url_slug.clone(), - repo.repo_full_name(), - use_experimental_failure_summary, - )?; + let org_url_slug = options.org_url_slug.to_string(); + let repo_full_name = options.repo.repo_full_name(); + let xcresult = if options.use_experimental_test_locations { + XCResult::new_with_declaration_locations( + xcresult_path, + org_url_slug, + repo_full_name, + options.repo_root, + Limits::default(), + )? + } else { + XCResult::new( + xcresult_path, + org_url_slug, + repo_full_name, + options.use_experimental_failure_summary, + )? + }; let junits = xcresult.generate_junits(); if junits.is_empty() { return Err(anyhow::anyhow!( @@ -997,6 +1005,8 @@ mod tests { #[cfg(target_os = "macos")] use context::repo::RepoUrlParts; + #[cfg(target_os = "macos")] + use crate::context::XCResultOptions; use crate::context::{coalesce_junit_path_wrappers, gather_initial_test_context}; use crate::upload_command::UploadArgs; @@ -1060,17 +1070,21 @@ mod tests { owner: "trunk-io".to_string(), name: "analytics-cli".to_string(), }; + #[cfg(target_os = "macos")] + let xcresult_options = XCResultOptions { + repo: &repo, + org_url_slug: "test", + repo_root: "test", + use_experimental_failure_summary: false, + use_experimental_test_locations: false, + }; let result_err = coalesce_junit_path_wrappers( vec!["test".into()], Some("test".into()), #[cfg(target_os = "macos")] Some("test".into()), #[cfg(target_os = "macos")] - &repo, - #[cfg(target_os = "macos")] - "test".into(), - #[cfg(target_os = "macos")] - false, + &xcresult_options, Vec::new(), false, ); @@ -1081,11 +1095,7 @@ mod tests { #[cfg(target_os = "macos")] Some("test".into()), #[cfg(target_os = "macos")] - &repo, - #[cfg(target_os = "macos")] - "test".into(), - #[cfg(target_os = "macos")] - false, + &xcresult_options, Vec::new(), true, ); @@ -1106,17 +1116,21 @@ mod tests { owner: "trunk-io".to_string(), name: "analytics-cli".to_string(), }; + #[cfg(target_os = "macos")] + let xcresult_options = XCResultOptions { + repo: &repo, + org_url_slug: "test", + repo_root: "test", + use_experimental_failure_summary: false, + use_experimental_test_locations: false, + }; let result_ok = coalesce_junit_path_wrappers( Vec::new(), None, #[cfg(target_os = "macos")] None, #[cfg(target_os = "macos")] - &repo, - #[cfg(target_os = "macos")] - "test".into(), - #[cfg(target_os = "macos")] - false, + &xcresult_options, vec!["test".into()], true, ); diff --git a/cli/src/upload_command.rs b/cli/src/upload_command.rs index ca6bbb28..5413e778 100644 --- a/cli/src/upload_command.rs +++ b/cli/src/upload_command.rs @@ -280,6 +280,21 @@ pub struct UploadArgs { hide = true )] pub use_experimental_failure_summary: bool, + #[cfg(target_os = "macos")] + #[arg( + long, + env = constants::TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS_ENV, + help = "Flag to take an xcresult test's file from where a language server says it is declared, rather than from the failure that surfaced it. Reads the bundle with no legacy `xcresulttool get object` calls.", + action = ArgAction::Set, + required = false, + require_equals = true, + num_args = 0..=1, + default_value = "false", + default_missing_value = "true", + hide = true, + conflicts_with = "use_experimental_failure_summary" + )] + pub use_experimental_xcresult_test_locations: bool, #[arg( long, env = constants::TRUNK_VALIDATION_REPORT_ENV, diff --git a/constants/src/lib.rs b/constants/src/lib.rs index a2afd054..787a1c6d 100644 --- a/constants/src/lib.rs +++ b/constants/src/lib.rs @@ -57,6 +57,8 @@ pub const TRUNK_VALIDATION_REPORT_ENV: &str = "TRUNK_VALIDATION_REPORT"; pub const TRUNK_SHOW_FAILURE_MESSAGES_ENV: &str = "TRUNK_SHOW_FAILURE_MESSAGES"; pub const TRUNK_HIDE_TEST_COLLECTION_LINKS_ENV: &str = "TRUNK_HIDE_TEST_COLLECTION_LINKS"; pub const TRUNK_DEBUG_ENV: &str = "TRUNK_DEBUG"; +pub const TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS_ENV: &str = + "TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS"; // RSpec-only: when set to "true", aborts the RSpec run if quarantine lookup fails. // Handled in rspec-trunk-flaky-tests/lib/trunk_spec_helper.rb, not the CLI. pub const TRUNK_QUARANTINE_QUERY_FAILURE_EXIT_ENV: &str = "TRUNK_QUARANTINE_QUERY_FAILURE_EXIT"; @@ -92,6 +94,7 @@ pub const TRUNK_ENVS_TO_CAPTURE: &[&str] = &[ TRUNK_SHOW_FAILURE_MESSAGES_ENV, TRUNK_HIDE_TEST_COLLECTION_LINKS_ENV, TRUNK_DEBUG_ENV, + TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS_ENV, ]; pub const ENVS_TO_GET: &[&str] = &[ diff --git a/context/src/junit/parser.rs b/context/src/junit/parser.rs index 6eb93e79..be7c777d 100644 --- a/context/src/junit/parser.rs +++ b/context/src/junit/parser.rs @@ -2198,14 +2198,8 @@ failures: .filter_map(|run| run.test_output.as_ref()) .map(|out| out.message.as_str()) .collect(); - assert!( - messages - .contains(&"/^Pacific\\b/ hour 11 from \"11:00:03 A.M.\" should be even") - ); - assert!( - messages - .contains(&"/^Pacific\\b/ hour 11 from \"11:00:23 A.M.\" should be even") - ); + assert!(messages.contains(&"/^Pacific\\b/ hour 11 from \"11:00:03 A.M.\" should be even")); + assert!(messages.contains(&"/^Pacific\\b/ hour 11 from \"11:00:23 A.M.\" should be even")); assert!(messages.contains(&"Test timeout of 10000ms exceeded.")); } } diff --git a/xcresult/CONTRIBUTING.md b/xcresult/CONTRIBUTING.md index 42484e7a..ee9f1fdd 100644 --- a/xcresult/CONTRIBUTING.md +++ b/xcresult/CONTRIBUTING.md @@ -41,6 +41,50 @@ cargo run --bin xcresult-to-junit -- \ - `--repo-url`: Repository URL, e.g. `https://github.com/trunk-io/analytics-cli` (optional) - `--output-file-path`: JUnit XML output file path (optional, defaults to stdout) - `--use-experimental-failure-summary`: Use experimental failure summary parsing (optional boolean flag) +- `--use-experimental-xcresult-test-locations`: Take each test's file from where it is declared rather than from a failure (optional boolean flag, also settable via `TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS`) +- `--repo-root`: Checkout to resolve declarations in, defaults to the working directory (clap `requires` the flag above) + +## Experimental: test locations from declarations + +`--use-experimental-xcresult-test-locations` replaces the file-attribution half of this +crate, and is the same flag on `trunk-analytics-cli upload` (hidden, and equally +xcresult-only — it does nothing for JUnit or Bazel BEP uploads). + +**What it changes.** Everything else here answers "where was this failure raised", because +that is all an `.xcresult` records: there is no per-test declaration site anywhere in the +bundle, and a passing test's summary carries no path at all. This flag asks a language +server instead — `sourcekit-lsp` for Swift, `clangd` for Objective-C, both of which ship +in the Command Line Tools as well as Xcode — for `textDocument/documentSymbol` over the +checkout's own sources, and joins the `(suite, case)` it gets back to the xcresult +identifier. So a failure raised inside a helper is attributed to the test's file rather +than the helper's, a crash with no call stack is attributed at all, and a **passing** test +gets a file for the first time. + +**What it also changes, and is easy to miss.** The declaration path makes exactly one +`xcresulttool` call for results (`get test-results tests`) plus one for the run start time +(`get test-results summary`). It never issues `get object --legacy`, so the unbounded +per-test summary fetch — measured at 6 GB of JSON and a 48 GB peak footprint for a single +timed-out test — is not reachable from it. + +**Where it is worse.** Tests registered at runtime (Quick's `class_addMethod`, +`+testInvocations`) have no declaration to find; the two approaches fail in disjoint +situations. Such a test falls back to the modern API's own `sourceLocation`, vetted against +the same vendored-path rules as everything in `src/file_attribution.rs`. + +**Incompatible with `--use-experimental-failure-summary`,** which tunes a code path this +one does not run, so clap rejects the pair rather than letting one silently win. One wart +comes with that: clap treats an env-supplied value as present regardless of what it says, +so `TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS=false` **and** +`--use-experimental-failure-summary` is a hard conflict error. Unset the variable to roll +back rather than setting it to `false`. + +**Ids.** `generate_id` prefers `nodeIdentifierURL` here, which is the legacy record's +`identifierURL` under another name, so ids do not move between the two paths. + +**Cost.** Roughly 13 ms per file parsed. The scan is ranked so files named after a suite go +first and stops as soon as every test resolves; `Limits` caps files parsed, total wall +clock, and per-request time, and a server that stops answering is killed rather than waited +on. ## JSON Schema Generation @@ -95,6 +139,10 @@ The generated types are used in: - `src/xcresult.rs` - Main conversion logic - `src/xcresult_legacy.rs` - Legacy format handling +The declaration path adds `src/lsp.rs` (a minimal JSON-RPC client) and +`src/test_locations.rs` (the `(suite, case) -> file:line` index), neither of which reads a +generated schema. + ## Testing Tests are located in `tests/xcresult.rs` and use sample xcresult bundles from `tests/data/`. To run tests: @@ -108,3 +156,21 @@ cargo test -p xcresult ``` Note: Almost all tests are macOS-specific (marked with `#[cfg(target_os = "macos")]`) as they require `xcrun` to be available. + +The split is deliberate, because the parts that need macOS are narrower than they look: + +- `src/test_locations.rs` unit-tests the symbol mapping, inheritance walk and source scan + against canned `documentSymbol` responses. +- `src/xcresult.rs` unit-tests the attribution join against a canned `Tests` value and a + seeded `TestLocationIndex` (`TestLocationIndex::declaring`, test-only). This is where "a + **passing** test gets a file" and "a failure raised in a helper or a dependency is still + attributed to the test's file" are proven — neither of which needs `xcrun`. +- `tests/xcresult.rs` holds the five macOS tests that actually drive `sourcekit-lsp` and + `clangd` over the checked-in packages in `tests/fixture-src/`. They pass a scenario's + package directory as the repo root, which is why they assert the file a test is _written + in_ rather than the absolute path baked into the bundle at capture time. + +One gap in the fixtures: **no scenario has a passing test**, so the end-to-end evidence for +attributing one is the unit test above rather than a real bundle. Adding a passing test to a +package means regenerating its bundle (`regenerate.sh`) and re-reviewing its expected JUnit, +both of which need macOS + Xcode. diff --git a/xcresult/Cargo.toml b/xcresult/Cargo.toml index 2cf90e27..b29668eb 100644 --- a/xcresult/Cargo.toml +++ b/xcresult/Cargo.toml @@ -15,6 +15,7 @@ path = "src/lib.rs" anyhow = "1.0.89" chrono = "0.4.38" clap = { version = "4.4.18", features = ["derive", "env"] } +constants = { path = "../constants" } context = { path = "../context", features = ["bindings"] } lazy_static = "1.5.0" tracing-subscriber = "0.3.19" diff --git a/xcresult/src/lib.rs b/xcresult/src/lib.rs index 5b5a30aa..1bc015eb 100644 --- a/xcresult/src/lib.rs +++ b/xcresult/src/lib.rs @@ -1,4 +1,6 @@ pub mod file_attribution; +pub mod lsp; +pub mod test_locations; pub mod types; pub mod xcresult; pub mod xcresult_legacy; diff --git a/xcresult/src/lsp.rs b/xcresult/src/lsp.rs new file mode 100644 index 00000000..3dc18596 --- /dev/null +++ b/xcresult/src/lsp.rs @@ -0,0 +1,316 @@ +//! Just enough of the Language Server Protocol to ask a server what a file declares. +//! +//! Once a request times out the stream cannot be resynchronised — a late reply would be +//! read as the answer to the *next* request — so the process is killed and later calls +//! refused, rather than an upload waiting on a server that stopped answering. + +use std::{ + io::{BufRead, BufReader, Read, Write}, + path::Path, + process::{Child, ChildStdin, Command, Stdio}, + sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}, + thread, + time::{Duration, Instant}, +}; + +use serde_json::{Value, json}; + +pub struct LanguageServer { + process: Child, + stdin: ChildStdin, + incoming: Receiver, + next_id: i64, + broken: bool, +} + +impl LanguageServer { + /// Start `program` and complete the LSP handshake against workspace `root`. + pub fn start( + program: &Path, + args: &[&str], + root: &Path, + timeout: Duration, + ) -> anyhow::Result { + let mut process = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let stdin = process + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("language server has no stdin"))?; + let stdout = process + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("language server has no stdout"))?; + + let (sender, incoming) = channel(); + thread::spawn(move || read_messages(stdout, &sender)); + + let mut server = Self { + process, + stdin, + incoming, + next_id: 1, + broken: false, + }; + server.request( + "initialize", + json!({ + "processId": std::process::id(), + "rootUri": file_uri(root), + "capabilities": { + "textDocument": { + "documentSymbol": { "hierarchicalDocumentSymbolSupport": true } + } + } + }), + timeout, + ); + server.notify("initialized", json!({})); + if server.broken { + return Err(anyhow::anyhow!( + "language server did not complete initialize" + )); + } + Ok(server) + } + + /// The symbols `file_path` declares. The text is sent rather than left for the + /// server to read, so this answers even where build settings do not resolve. + pub fn document_symbols( + &mut self, + file_path: &Path, + language_id: &str, + text: &str, + timeout: Duration, + ) -> Option { + let uri = file_uri(file_path); + self.notify( + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": uri, + "languageId": language_id, + "version": 1, + "text": text + } + }), + ); + let symbols = self.request( + "textDocument/documentSymbol", + json!({ "textDocument": { "uri": uri } }), + timeout, + ); + self.notify( + "textDocument/didClose", + json!({ "textDocument": { "uri": uri } }), + ); + symbols + } + + pub fn is_broken(&self) -> bool { + self.broken + } + + fn request(&mut self, method: &str, params: Value, timeout: Duration) -> Option { + if self.broken { + return None; + } + let id = self.next_id; + self.next_id += 1; + self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + + let deadline = Instant::now() + timeout; + loop { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return self.abandon(method, "timed out"); + }; + let message = match self.incoming.recv_timeout(remaining) { + Ok(message) => message, + Err(RecvTimeoutError::Timeout) => return self.abandon(method, "timed out"), + Err(RecvTimeoutError::Disconnected) => return self.abandon(method, "exited"), + }; + if message.get("id").and_then(Value::as_i64) == Some(id) { + if let Some(error) = message.get("error") { + tracing::debug!("language server refused {}: {}", method, error); + return None; + } + return message.get("result").cloned(); + } + // sourcekit-lsp registers capabilities and asks for configuration during + // startup; a peer that never replies leaves those pending for its lifetime. + if let (Some(id), Some(_)) = (message.get("id"), message.get("method")) { + let id = id.clone(); + self.send(json!({ "jsonrpc": "2.0", "id": id, "result": Value::Null })); + } + } + } + + fn notify(&mut self, method: &str, params: Value) { + if self.broken { + return; + } + self.send(json!({ "jsonrpc": "2.0", "method": method, "params": params })); + } + + fn send(&mut self, message: Value) { + let body = message.to_string(); + let framed = format!("Content-Length: {}\r\n\r\n{}", body.len(), body); + if self.stdin.write_all(framed.as_bytes()).is_err() || self.stdin.flush().is_err() { + self.abandon("write", "closed its input"); + } + } + + fn abandon(&mut self, method: &str, reason: &str) -> Option { + if !self.broken { + tracing::warn!( + "language server {} during {}; abandoning it", + reason, + method + ); + self.broken = true; + let _ = self.process.kill(); + } + None + } +} + +impl Drop for LanguageServer { + fn drop(&mut self) { + let _ = self.process.kill(); + let _ = self.process.wait(); + } +} + +fn read_messages(stdout: R, sender: &Sender) { + let mut reader = BufReader::new(stdout); + loop { + let mut content_length = None; + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + let line = line.trim_end(); + if line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') + && name.trim().eq_ignore_ascii_case("content-length") + { + content_length = value.trim().parse::().ok(); + } + } + let Some(content_length) = content_length else { + return; + }; + let mut body = vec![0_u8; content_length]; + if reader.read_exact(&mut body).is_err() { + return; + } + let Ok(message) = serde_json::from_slice::(&body) else { + return; + }; + if sender.send(message).is_err() { + return; + } + } +} + +/// A `file://` URI. A server that cannot parse the URI answers with no symbols rather +/// than an error, so a path with a space fails silently unless it is encoded here. +fn file_uri(path: &Path) -> String { + let mut uri = String::from("file://"); + for byte in path.to_string_lossy().bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { + uri.push(char::from(byte)); + } + _ => uri.push_str(&format!("%{byte:02X}")), + } + } + uri +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::plain("/repo/Tests/Test.swift", "file:///repo/Tests/Test.swift")] + #[case::space("/repo/Tests/My Test.swift", "file:///repo/Tests/My%20Test.swift")] + #[case::hash_is_a_uri_fragment("/repo/a#b.swift", "file:///repo/a%23b.swift")] + fn a_path_becomes_a_percent_encoded_uri(#[case] path: &str, #[case] expected: &str) { + assert_eq!(file_uri(Path::new(path)), expected); + } + + fn framed(bodies: &[&str]) -> String { + bodies + .iter() + .map(|body| format!("Content-Length: {}\r\n\r\n{}", body.len(), body)) + .collect() + } + + #[test] + fn framed_messages_are_read_back_in_order() { + let (sender, receiver) = channel(); + read_messages( + Cursor::new(framed(&[ + r#"{"id":1,"result":[]}"#, + r#"{"id":2,"result":7}"#, + ])), + &sender, + ); + drop(sender); + let received = receiver.iter().collect::>(); + assert_eq!(received.len(), 2); + assert_eq!(received[1]["result"], json!(7)); + } + + // The frame carries a byte count, so a non-ASCII body split by character count + // drifts one message at a time and then hangs on the next read. + #[test] + fn a_multibyte_body_is_framed_by_bytes() { + let (sender, receiver) = channel(); + read_messages( + Cursor::new(framed(&[r#"{"id":1,"result":"café"}"#])), + &sender, + ); + drop(sender); + assert_eq!( + receiver + .iter() + .next() + .map(|message| message["result"].clone()), + Some(json!("café")) + ); + } + + #[test] + fn a_lowercased_header_is_still_a_content_length() { + let (sender, receiver) = channel(); + let body = r#"{"id":1,"result":[]}"#; + read_messages( + Cursor::new(format!("content-length: {}\r\n\r\n{}", body.len(), body)), + &sender, + ); + drop(sender); + assert_eq!(receiver.iter().count(), 1); + } + + #[test] + fn a_truncated_message_ends_the_stream_instead_of_blocking() { + let (sender, receiver) = channel(); + read_messages(Cursor::new("Content-Length: 40\r\n\r\n{\"id\":1}"), &sender); + drop(sender); + assert_eq!(receiver.iter().count(), 0); + } +} diff --git a/xcresult/src/main.rs b/xcresult/src/main.rs index 743fa487..358528de 100644 --- a/xcresult/src/main.rs +++ b/xcresult/src/main.rs @@ -3,7 +3,7 @@ use std::{fs, io, path::PathBuf}; use clap::Parser; use context::repo::RepoUrlParts; use tracing_subscriber::prelude::*; -use xcresult::xcresult::XCResult; +use xcresult::{test_locations::Limits, xcresult::XCResult}; #[derive(Debug, Parser)] pub struct Cli { @@ -21,6 +21,25 @@ pub struct Cli { pub output_file_path: Option, #[arg(long, required = false)] pub use_experimental_failure_summary: bool, + #[arg( + long, + env = constants::TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS_ENV, + help = "Take each test's file from where a language server says it is declared in --repo-root, rather than from the failure that surfaced it. Reads the bundle with no legacy `xcresulttool get object` calls.", + action = clap::ArgAction::Set, + required = false, + require_equals = true, + num_args = 0..=1, + default_value = "false", + default_missing_value = "true", + conflicts_with = "use_experimental_failure_summary" + )] + pub use_experimental_xcresult_test_locations: bool, + #[arg( + long, + help = "Checkout to resolve test declarations in, defaults to the working directory.", + requires = "use_experimental_xcresult_test_locations" + )] + pub repo_root: Option, } fn main() -> anyhow::Result<()> { @@ -34,16 +53,30 @@ fn main() -> anyhow::Result<()> { repo_url, output_file_path, use_experimental_failure_summary, + use_experimental_xcresult_test_locations, + repo_root, } = Cli::parse(); let repo_url_parts = repo_url .and_then(|repo_url| RepoUrlParts::from_url(&repo_url).ok()) .unwrap_or_default(); - let xcresult = XCResult::new( - path, - org_url_slug.unwrap_or_default(), - repo_url_parts.repo_full_name(), - use_experimental_failure_summary, - )?; + let org_url_slug = org_url_slug.unwrap_or_default(); + let repo_full_name = repo_url_parts.repo_full_name(); + let xcresult = if use_experimental_xcresult_test_locations { + XCResult::new_with_declaration_locations( + path, + org_url_slug, + repo_full_name, + repo_root.unwrap_or_else(|| PathBuf::from(".")), + Limits::default(), + )? + } else { + XCResult::new( + path, + org_url_slug, + repo_full_name, + use_experimental_failure_summary, + )? + }; let mut junits = xcresult.generate_junits(); let junit_count_and_first_junit = (junits.len(), junits.pop()); let junit = if let (1, Some(junit)) = junit_count_and_first_junit { diff --git a/xcresult/src/test_locations.rs b/xcresult/src/test_locations.rs new file mode 100644 index 00000000..b5ebe96f --- /dev/null +++ b/xcresult/src/test_locations.rs @@ -0,0 +1,654 @@ +//! Where a test is *declared*, rather than where a failure surfaced — all an `.xcresult` +//! records (see [`crate::file_attribution`]). `documentSymbol` names the type containing +//! each method and, unlike `workspace/symbol`, needs no index and no build. + +use std::{ + collections::{HashMap, HashSet}, + fs, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use lazy_static::lazy_static; +use serde::Deserialize; + +use crate::{file_attribution::ReportedPath, lsp::LanguageServer, xcrun::xcrun_find}; + +/// LSP `SymbolKind`s that can declare a test: Method, Constructor, Function. +const METHOD_KINDS: [u64; 3] = [6, 9, 12]; +/// Kinds that can contain one: Class, Interface (an Objective-C category), Struct. +const CONTAINER_KINDS: [u64; 3] = [5, 11, 23]; + +const SWIFT_EXTENSIONS: [&str; 1] = ["swift"]; +const CLANG_EXTENSIONS: [&str; 5] = ["m", "mm", "c", "cc", "cpp"]; + +/// Directories holding something other than the repo's own code. Exact target ownership +/// would need a build log, and reading one costs the legacy call this path exists to avoid. +const SKIPPED_DIRECTORIES: [&str; 9] = [ + ".git", + ".build", + ".swiftpm", + "build", + "checkouts", + "Carthage", + "DerivedData", + "node_modules", + "Pods", +]; + +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub max_files: usize, + pub budget: Duration, + pub request_timeout: Duration, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_files: 2_000, + budget: Duration::from_secs(60), + request_timeout: Duration::from_secs(30), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TestKey { + suite: Option, + case: String, +} + +impl TestKey { + /// `Outer/Inner/case()` — only the innermost suite declares the method, and a top-level + /// swift-testing test has none. + pub fn from_node_identifier(node_identifier: &str) -> Self { + let mut components = node_identifier.rsplit('/'); + let case = components.next().unwrap_or_default(); + Self { + suite: components + .next() + .map(|suite| container_name(suite).to_string()), + case: normalized_case(case), + } + } +} + +#[derive(Debug, Clone)] +pub struct DeclarationSite { + pub file: ReportedPath, + pub line: Option, +} + +#[derive(Debug, Default)] +pub struct TestLocationIndex { + declarations: HashMap, + supertypes: HashMap, +} + +impl TestLocationIndex { + pub fn resolve(repo_root: &Path, keys: &[TestKey], limits: Limits) -> Self { + let suites = keys + .iter() + .filter_map(|key| key.suite.as_deref()) + .collect::>(); + let sources = scan_sources(repo_root, &suites, limits.max_files); + let (swift, clang) = sources + .into_iter() + .partition::, _>(|path| has_extension(path, &SWIFT_EXTENSIONS)); + + let mut resolver = Resolver { + index: Self::default(), + unresolved: keys.to_vec(), + deadline: Instant::now() + limits.budget, + limits, + }; + resolver.parse(&swift, &SOURCEKIT_LSP, repo_root); + resolver.parse(&clang, &CLANGD, repo_root); + if !resolver.unresolved.is_empty() { + tracing::debug!( + "{} of {} test(s) have no declaration in the checkout", + resolver.unresolved.len(), + keys.len() + ); + } + resolver.index + } + + /// A test can be declared on a base class and run under a subclass. + pub fn lookup(&self, key: &TestKey) -> Option<&DeclarationSite> { + let mut suite = key.suite.clone(); + let mut seen = HashSet::new(); + while let Some(current) = suite { + if !seen.insert(current.clone()) { + break; + } + let inherited = TestKey { + suite: Some(current.clone()), + case: key.case.clone(), + }; + if let Some(site) = self.declarations.get(&inherited) { + return Some(site); + } + suite = self.supertypes.get(¤t).cloned(); + } + self.declarations.get(&TestKey { + suite: None, + case: key.case.clone(), + }) + } + + pub fn is_empty(&self) -> bool { + self.declarations.is_empty() + } + + /// Seed an index without a language server, so the code downstream of it can be tested + /// off macOS. + #[cfg(test)] + pub(crate) fn declaring(mut self, node_identifier: &str, file: &str) -> Self { + self.declarations.insert( + TestKey::from_node_identifier(node_identifier), + DeclarationSite { + file: ReportedPath::new(file), + line: None, + }, + ); + self + } + + fn collect( + &mut self, + symbols: &[DocumentSymbol], + file: &Path, + text: &str, + container: Option<&str>, + ) { + for symbol in symbols { + if CONTAINER_KINDS.contains(&symbol.kind) { + let name = container_name(&symbol.name); + if let Some(supertype) = superclass(text, &symbol.range) + && supertype != name + { + self.supertypes.entry(name.to_string()).or_insert(supertype); + } + } + if METHOD_KINDS.contains(&symbol.kind) { + let key = TestKey { + suite: container.map(|name| container_name(name).to_string()), + case: normalized_case(&symbol.name), + }; + self.declarations + .entry(key) + .or_insert_with(|| DeclarationSite { + file: ReportedPath::new(&file.to_string_lossy()), + line: symbol.declaration_line(), + }); + } + self.collect(&symbol.children, file, text, Some(&symbol.name)); + } + } +} + +struct ServerKind { + program: &'static str, + args: &'static [&'static str], + language_id: &'static str, +} + +const SOURCEKIT_LSP: ServerKind = ServerKind { + program: "sourcekit-lsp", + args: &[], + language_id: "swift", +}; + +/// Background indexing is the work `documentSymbol` exists to avoid. +const CLANGD: ServerKind = ServerKind { + program: "clangd", + args: &["--background-index=false"], + language_id: "objective-c", +}; + +struct Resolver { + index: TestLocationIndex, + unresolved: Vec, + deadline: Instant, + limits: Limits, +} + +impl Resolver { + fn parse(&mut self, files: &[PathBuf], kind: &ServerKind, root: &Path) { + if files.is_empty() || self.unresolved.is_empty() { + return; + } + let Some(program) = xcrun_find(kind.program) else { + tracing::warn!( + "{} not found; {} source file(s) left unparsed", + kind.program, + files.len() + ); + return; + }; + let mut server = + match LanguageServer::start(&program, kind.args, root, self.limits.request_timeout) { + Ok(server) => server, + Err(e) => { + tracing::warn!("failed to start {}: {}", kind.program, e); + return; + } + }; + + let mut parsed = 0; + for file in files { + if self.unresolved.is_empty() || server.is_broken() { + break; + } + if Instant::now() >= self.deadline { + tracing::warn!( + "{}: out of time after {} file(s), {} left unparsed", + kind.program, + parsed, + files.len() - parsed + ); + break; + } + let Ok(text) = fs::read_to_string(file) else { + continue; + }; + let Some(response) = + server.document_symbols(file, kind.language_id, &text, self.limits.request_timeout) + else { + continue; + }; + parsed += 1; + match serde_json::from_value::>(response) { + Ok(symbols) => self.index.collect(&symbols, file, &text, None), + Err(e) => tracing::debug!("unusable symbols for {}: {}", file.display(), e), + } + let index = &self.index; + self.unresolved.retain(|key| index.lookup(key).is_none()); + } + tracing::debug!( + "{}: parsed {} of {} file(s)", + kind.program, + parsed, + files.len() + ); + } +} + +#[derive(Debug, Deserialize)] +struct DocumentSymbol { + name: String, + kind: u64, + range: Range, + #[serde(rename = "selectionRange")] + selection_range: Option, + #[serde(default)] + children: Vec, +} + +impl DocumentSymbol { + /// LSP counts lines from zero; everything downstream counts from one. + fn declaration_line(&self) -> Option { + let range = self.selection_range.as_ref().unwrap_or(&self.range); + u32::try_from(range.start.line) + .ok() + .map(|line| line.saturating_add(1)) + } +} + +#[derive(Debug, Deserialize)] +struct Range { + start: Position, + end: Position, +} + +#[derive(Debug, Deserialize)] +struct Position { + line: u64, +} + +lazy_static! { + // `\b` sits inside the alternation: before `@interface` it would demand a word + // character ahead of the `@` and never match a declaration starting a line. + static ref SUPERCLASS: regex::Regex = + regex::Regex::new(r"(?:\bclass|@interface)\s+\w+\s*:\s*([A-Za-z_]\w*)").unwrap(); +} + +/// Enough to carry an inheritance clause, so a large class body is never searched. +const DECLARATION_HEAD_LINES: usize = 5; + +fn superclass(text: &str, range: &Range) -> Option { + let span = (range.end.line.saturating_sub(range.start.line) as usize).saturating_add(1); + let head = text + .lines() + .skip(range.start.line as usize) + .take(span.min(DECLARATION_HEAD_LINES)) + .collect::>() + .join("\n"); + SUPERCLASS + .captures(&head) + .and_then(|captures| captures.get(1)) + .map(|name| name.as_str().to_string()) +} + +/// Applied to identifier and symbol alike: Swift spells it `testExample()`, Objective-C +/// `-testExample`. +fn normalized_case(name: &str) -> String { + name.trim_start_matches(['+', '-']) + .trim_end_matches(['(', ')']) + .to_string() +} + +/// An Objective-C category comes back as `Suite(Category)`; identifiers name the class. +fn container_name(name: &str) -> &str { + name.split('(').next().unwrap_or(name) +} + +fn has_extension(path: &Path, extensions: &[&str]) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extensions.contains(&extension)) + .unwrap_or(false) +} + +/// Files most likely to pay off first: a suite is overwhelmingly declared in a file named +/// after it, and parsing stops once every test resolves. Symlinked directories are skipped. +fn scan_sources(repo_root: &Path, suites: &HashSet<&str>, max_files: usize) -> Vec { + let mut found = Vec::new(); + let mut stack = vec![repo_root.to_path_buf()]; + while let Some(directory) = stack.pop() { + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + if !SKIPPED_DIRECTORIES.contains(&entry.file_name().to_string_lossy().as_ref()) { + stack.push(entry.path()); + } + } else if file_type.is_file() { + let path = entry.path(); + if has_extension(&path, &SWIFT_EXTENSIONS) + || has_extension(&path, &CLANG_EXTENSIONS) + { + found.push(path); + } + } + } + } + found.sort_by_cached_key(|path| (rank(path, suites), path.clone())); + found.truncate(max_files); + found +} + +fn rank(path: &Path, suites: &HashSet<&str>) -> u8 { + let Some(stem) = path + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + else { + return 2; + }; + if suites.contains(stem.as_str()) { + 0 + } else if suites.iter().any(|suite| stem.contains(suite)) { + 1 + } else { + 2 + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serde_json::{Value, json}; + use temp_testdir::TempDir; + + use super::*; + + const SWIFT_FILE: &str = "/repo/Tests/SnapshotReproTests.swift"; + + fn key(suite: Option<&str>, case: &str) -> TestKey { + TestKey { + suite: suite.map(String::from), + case: String::from(case), + } + } + + fn symbols(value: Value) -> Vec { + serde_json::from_value(value).unwrap() + } + + fn method(name: &str, line: u64) -> Value { + json!({ + "name": name, + "kind": 6, + "range": { "start": { "line": line }, "end": { "line": line } }, + "selectionRange": { "start": { "line": line }, "end": { "line": line } } + }) + } + + fn container(name: &str, kind: u64, lines: (u64, u64), children: Vec) -> Value { + json!({ + "name": name, + "kind": kind, + "range": { "start": { "line": lines.0 }, "end": { "line": lines.1 } }, + "children": children + }) + } + + fn indexed(file: &str, text: &str, value: Value) -> TestLocationIndex { + let mut index = TestLocationIndex::default(); + index.collect(&symbols(value), Path::new(file), text, None); + index + } + + #[rstest] + #[case::swift_xctest( + "SnapshotReproTests/testExample()", + Some("SnapshotReproTests"), + "testExample" + )] + #[case::objc_has_no_parens( + "ObjcXCTestTests/testExample", + Some("ObjcXCTestTests"), + "testExample" + )] + #[case::top_level_swift_testing("failingSnapshot()", None, "failingSnapshot")] + #[case::only_the_innermost_suite_declares( + "OuterSuite/InnerSuite/testExample()", + Some("InnerSuite"), + "testExample" + )] + #[case::parameterized_keeps_its_labels( + "SnapshotReproTests/testExample(input:)", + Some("SnapshotReproTests"), + "testExample(input:" + )] + fn an_identifier_names_a_suite_and_a_case( + #[case] node_identifier: &str, + #[case] suite: Option<&str>, + #[case] case: &str, + ) { + assert_eq!( + TestKey::from_node_identifier(node_identifier), + key(suite, case) + ); + } + + // The identifier and the symbol are spelled differently in every language, so what + // matters is that normalizing both lands them on the same key. + #[rstest] + #[case::swift("SnapshotReproTests/testExample()", "testExample()")] + #[case::objc("ObjcXCTestTests/testExample", "-testExample")] + #[case::objc_class_method("ObjcXCTestTests/testExample", "+testExample")] + #[case::parameterized("Suite/testExample(input:)", "testExample(input:)")] + fn an_identifier_and_its_symbol_normalize_alike( + #[case] node_identifier: &str, + #[case] symbol_name: &str, + ) { + assert_eq!( + TestKey::from_node_identifier(node_identifier).case, + normalized_case(symbol_name) + ); + } + + #[test] + fn a_method_is_recorded_against_the_type_declaring_it() { + let index = indexed( + SWIFT_FILE, + "final class SnapshotReproTests: XCTestCase {\n func testExample() {}\n}", + json!([container( + "SnapshotReproTests", + 5, + (0, 2), + vec![method("testExample()", 1)] + )]), + ); + let site = index + .lookup(&key(Some("SnapshotReproTests"), "testExample")) + .expect("the test's own declaration"); + assert_eq!(site.file.as_str(), SWIFT_FILE); + assert_eq!(site.line, Some(2)); + } + + #[test] + fn a_category_records_against_the_class_it_extends() { + let index = indexed( + "/repo/Tests/ObjcXCTestTests+Extra.m", + "@interface ObjcXCTestTests (ExtraTests)\n- (void)testExample;\n@end", + json!([container( + "ObjcXCTestTests(ExtraTests)", + 11, + (0, 2), + vec![method("-testExample", 1)] + )]), + ); + assert!( + index + .lookup(&key(Some("ObjcXCTestTests"), "testExample")) + .is_some() + ); + } + + #[test] + fn a_top_level_test_is_found_without_a_suite() { + let index = indexed( + "/repo/Tests/TopLevel.swift", + "@Test func failingSnapshot() {}", + json!([method("failingSnapshot()", 0)]), + ); + assert!(index.lookup(&key(None, "failingSnapshot")).is_some()); + } + + // The run reports the subclass, but only the base class file declares the method. + #[test] + fn a_test_inherited_from_a_base_class_resolves_to_the_base_class_file() { + let mut index = indexed( + "/repo/Tests/BaseTests.swift", + "class BaseTests: XCTestCase {\n func testInherited() {}\n}", + json!([container( + "BaseTests", + 5, + (0, 2), + vec![method("testInherited()", 1)] + )]), + ); + index.collect( + &symbols(json!([container("SubclassTests", 5, (0, 0), vec![])])), + Path::new("/repo/Tests/SubclassTests.swift"), + "final class SubclassTests: BaseTests {}", + None, + ); + assert_eq!( + index + .lookup(&key(Some("SubclassTests"), "testInherited")) + .map(|site| site.file.as_str().to_owned()), + Some(String::from("/repo/Tests/BaseTests.swift")) + ); + } + + #[test] + fn an_unrelated_suite_does_not_borrow_another_suites_case() { + let index = indexed( + SWIFT_FILE, + "final class SnapshotReproTests: XCTestCase {\n func testExample() {}\n}", + json!([container( + "SnapshotReproTests", + 5, + (0, 2), + vec![method("testExample()", 1)] + )]), + ); + assert!( + index + .lookup(&key(Some("OtherTests"), "testExample")) + .is_none() + ); + } + + // A cycle would otherwise be walked forever; `typealias`ed bases produce one. + #[test] + fn a_cyclic_superclass_chain_terminates() { + let mut index = TestLocationIndex::default(); + index + .supertypes + .insert(String::from("A"), String::from("B")); + index + .supertypes + .insert(String::from("B"), String::from("A")); + assert!(index.lookup(&key(Some("A"), "testExample")).is_none()); + } + + #[rstest] + #[case::swift("final class SubclassTests: BaseTests {", Some("BaseTests"))] + #[case::objc("@interface SubclassTests : BaseTests", Some("BaseTests"))] + #[case::no_inheritance_clause("struct PlainTests {", None)] + fn a_declaration_head_yields_its_supertype(#[case] text: &str, #[case] expected: Option<&str>) { + let range = Range { + start: Position { line: 0 }, + end: Position { line: 0 }, + }; + assert_eq!(superclass(text, &range).as_deref(), expected); + } + + #[test] + fn the_scan_ranks_suite_named_files_first_and_skips_vendored_directories() { + let root = TempDir::default(); + for relative in [ + "Sources/Alpha.swift", + "Tests/SnapshotReproTests.swift", + "Tests/SnapshotReproTestsHelper.swift", + "Pods/Vendored.swift", + ".build/checkouts/Dep/Dep.swift", + ] { + let path = root.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, "").unwrap(); + } + let suites = HashSet::from(["SnapshotReproTests"]); + let scanned = scan_sources(root.as_ref(), &suites, 10) + .iter() + .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) + .collect::>(); + assert_eq!( + scanned, + vec![ + "SnapshotReproTests.swift", + "SnapshotReproTestsHelper.swift", + "Alpha.swift" + ] + ); + } + + #[test] + fn the_scan_stops_at_the_file_cap() { + let root = TempDir::default(); + for name in ["A.swift", "B.swift", "C.swift"] { + fs::write(root.join(name), "").unwrap(); + } + assert_eq!(scan_sources(root.as_ref(), &HashSet::new(), 2).len(), 2); + } +} diff --git a/xcresult/src/xcresult.rs b/xcresult/src/xcresult.rs index 18335f1e..585f1561 100644 --- a/xcresult/src/xcresult.rs +++ b/xcresult/src/xcresult.rs @@ -5,19 +5,32 @@ use std::{fs, path::Path, time::Duration}; use chrono::{DateTime, Utc}; use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestRerun, TestSuite}; +use crate::file_attribution::ReportedPath; +use crate::test_locations::{Limits, TestKey, TestLocationIndex}; use crate::types::{ SWIFT_DEFAULT_TEST_SUITE_NAME, schema::{TestNode, TestNodeType, TestResult, Tests}, }; use crate::xcresult_legacy::XCResultTestLegacy; -use crate::xcrun::{xcresulttool_get_object, xcresulttool_get_test_results_tests}; +use crate::xcrun::{ + xcresulttool_get_object, xcresulttool_get_test_results_summary, + xcresulttool_get_test_results_tests, +}; + +/// Where a test's file comes from — where a failure surfaced, or where the test is +/// declared — which also decides which `xcresulttool` calls the bundle is read with. +#[derive(Debug)] +pub enum FileAttribution { + FailureSummaries(HashMap), + Declarations(TestLocationIndex), +} -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct XCResult { tests: Tests, org_url_slug: String, repo_full_name: String, - legacy_xcresult_tests: HashMap, + attribution: FileAttribution, test_run_started_at: Option>, } @@ -95,7 +108,58 @@ impl XCResult { }; Ok(XCResult { tests: xcresulttool_get_test_results_tests(&absolute_path)?, - legacy_xcresult_tests, + attribution: FileAttribution::FailureSummaries(legacy_xcresult_tests), + org_url_slug, + repo_full_name, + test_run_started_at, + }) + } + + /// Read the bundle without a single `get object --legacy` call, taking each test's file + /// from where it is declared in `repo_root`. Beyond attribution, that is what the flag + /// buys: the legacy per-test summary fetch is unbounded (48 GB peak on one timed-out + /// test) and nothing here can reach that object. + pub fn new_with_declaration_locations, U: AsRef>( + path: T, + org_url_slug: String, + repo_full_name: String, + repo_root: U, + limits: Limits, + ) -> anyhow::Result { + let absolute_path = fs::canonicalize(path.as_ref()).map_err(|e| { + anyhow::anyhow!( + "failed to get absolute path for {}: {}", + path.as_ref().display(), + e + ) + })?; + let tests = xcresulttool_get_test_results_tests(&absolute_path)?; + + let test_run_started_at = match xcresulttool_get_test_results_summary(&absolute_path) { + Ok(summary) => summary.start_time.and_then(|start_time| { + // This float's ULP exceeds a microsecond at epoch magnitudes, so anything + // below the millisecond the legacy date string also carries is noise. + DateTime::from_timestamp_millis((start_time * 1e3).round() as i64) + }), + Err(e) => { + tracing::warn!("Failed to get test run start time from xcresult: {}", e); + None + } + }; + + let mut keys = Vec::new(); + collect_test_keys(&tests.test_nodes, &mut keys); + let index = TestLocationIndex::resolve(repo_root.as_ref(), &keys, limits); + if index.is_empty() { + tracing::warn!( + "no test declarations found under {}; falling back to failure locations", + repo_root.as_ref().display() + ); + } + + Ok(XCResult { + tests, + attribution: FileAttribution::Declarations(index), org_url_slug, repo_full_name, test_run_started_at, @@ -259,13 +323,11 @@ impl XCResult { test_case.set_timestamp(started_at); } - if let Some(node_identifier) = &xcresult_test_case.node_identifier { - let id = self.generate_id(node_identifier); + if let Some(id) = self.generate_id(xcresult_test_case) { test_case.extra.insert("id".into(), id.into()); - let file = self.find_test_case_file(node_identifier); - if let Some(file) = file { - test_case.extra.insert("file".into(), file.into()); - } + } + if let Some(file) = self.find_test_case_file(xcresult_test_case) { + test_case.extra.insert("file".into(), file.into()); } Some(test_case) @@ -326,35 +388,277 @@ impl XCResult { .and_then(|secs| Duration::try_from_secs_f64(secs).ok()) } - fn generate_id>(&self, raw_id: T) -> String { - let identifier_url = self - .legacy_xcresult_tests - .get(raw_id.as_ref()) - .map(|test| &test.identifier_url) - .map(|identifier_url| identifier_url.as_str()); - // join the org and repo name to the raw id and generate uuid v5 from it - uuid::Uuid::new_v5( - &uuid::Uuid::NAMESPACE_URL, - format!( - "{}#{}#{}", - &self.org_url_slug, - &self.repo_full_name, - identifier_url.unwrap_or(raw_id.as_ref()) + fn generate_id(&self, test_case: &TestNode) -> Option { + let node_identifier = test_case.node_identifier.as_deref()?; + let identifier_url = match &self.attribution { + FileAttribution::FailureSummaries(tests) => tests + .get(node_identifier) + .map(|test| test.identifier_url.as_str()), + // The legacy `identifierURL` under another name, so ids match across paths. + FileAttribution::Declarations(_) => test_case.node_identifier_url.as_deref(), + }; + Some( + uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + format!( + "{}#{}#{}", + &self.org_url_slug, + &self.repo_full_name, + identifier_url.unwrap_or(node_identifier) + ) + .as_bytes(), ) - .as_bytes(), + .to_string(), ) - .to_string() } - fn find_test_case_file>(&self, raw_id: T) -> Option { - if let Some(file) = self - .legacy_xcresult_tests - .get(raw_id.as_ref()) - .map(|test| &test.file) - .and_then(|file| file.as_ref()) + fn find_test_case_file(&self, test_case: &TestNode) -> Option { + let node_identifier = test_case.node_identifier.as_deref()?; + match &self.attribution { + FileAttribution::FailureSummaries(tests) => tests + .get(node_identifier) + .and_then(|test| test.file.clone()), + FileAttribution::Declarations(index) => { + if let Some(site) = index.lookup(&TestKey::from_node_identifier(node_identifier)) { + tracing::debug!( + "{} is declared at {}:{}", + node_identifier, + site.file.as_str(), + site.line.unwrap_or_default() + ); + return Some(site.file.as_str().to_owned()); + } + // A runtime-registered test (Quick, `+testInvocations`) has no declaration + // to find, so fall back to where the failure surfaced. + first_source_location(test_case) + .map(|path| ReportedPath::new(&path)) + .filter(|path| !path.is_vendored_dependency()) + .map(ReportedPath::into_string) + } + } + } +} + +fn collect_test_keys(test_nodes: &[TestNode], keys: &mut Vec) { + for test_node in test_nodes { + if matches!(test_node.node_type, TestNodeType::TestCase) + && let Some(node_identifier) = &test_node.node_identifier { - return Some(file.to_owned()); + keys.push(TestKey::from_node_identifier(node_identifier)); } - None + collect_test_keys(&test_node.children, keys); + } +} + +fn first_source_location(test_node: &TestNode) -> Option { + if let Some(source_location) = &test_node.source_location { + return Some(source_location.file_path.clone()); + } + test_node.children.iter().find_map(first_source_location) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serde_json::{Value, json}; + + use super::*; + + const TEST_FILE: &str = "/repo/Tests/SnapshotReproTests.swift"; + const HELPER_FILE: &str = "/repo/Tests/FailureHelper.swift"; + const DEPENDENCY_FILE: &str = "/repo/DerivedData/SourcePackages/checkouts/Dep/Assert.swift"; + + fn suite(name: &str, children: Vec) -> Value { + json!({ "nodeType": "Test Suite", "name": name, "children": children }) + } + + fn case(name: &str, node_identifier: &str, result: &str, children: Vec) -> Value { + json!({ + "nodeType": "Test Case", + "name": name, + "nodeIdentifier": node_identifier, + "result": result, + "children": children + }) + } + + /// The node a failure hangs its location off, which is where the failure was *raised*. + fn raised_at(file: &str) -> Value { + json!({ + "nodeType": "Source Code Reference", + "name": file, + "sourceLocation": { "filePath": file, "lineNumber": 9 } + }) + } + + fn bundle(name: &str, children: Vec) -> Tests { + serde_json::from_value(json!({ + "testPlanConfigurations": [], + "devices": [], + "testNodes": [{ + "nodeType": "Test Plan", + "name": "ExamplePlan", + "children": [{ "nodeType": "Unit test bundle", "name": name, "children": children }] + }] + })) + .unwrap() + } + + fn report(tests: Tests, attribution: FileAttribution) -> Report { + let xcresult = XCResult { + tests, + org_url_slug: String::from("trunk"), + repo_full_name: String::from("github.com/trunk-io/analytics-cli"), + attribution, + test_run_started_at: None, + }; + let mut reports = xcresult.generate_junits(); + assert_eq!(reports.len(), 1); + reports.pop().unwrap() + } + + fn extra(test_case: &TestCase, key: &str) -> Option { + test_case + .extra + .iter() + .find(|(name, _)| name.as_str() == key) + .map(|(_, value)| value.as_str().to_owned()) + } + + fn suites_and_cases(report: &Report) -> Vec<(String, Vec)> { + report + .test_suites + .iter() + .map(|test_suite| { + ( + test_suite.name.as_str().to_owned(), + test_suite + .test_cases + .iter() + .map(|test_case| test_case.name.as_str().to_owned()) + .collect(), + ) + }) + .collect() + } + + fn file_of(report: &Report, name: &str) -> Option { + report + .test_suites + .iter() + .flat_map(|test_suite| test_suite.test_cases.iter()) + .find(|test_case| test_case.name.as_str() == name) + .and_then(|test_case| extra(test_case, "file")) + } + + fn declarations() -> FileAttribution { + FileAttribution::Declarations( + TestLocationIndex::default().declaring("SnapshotReproTests/testExample()", TEST_FILE), + ) + } + + // The capability the failure-summary paths cannot have at all: a test that never failed + // has no summary, so there is nothing for them to read a path out of. + #[rstest] + #[case::passed("Passed")] + #[case::skipped("Skipped")] + #[case::expected_failure("Expected Failure")] + fn a_test_case_that_did_not_fail_still_gets_its_declaration_file(#[case] result: &str) { + let tests = bundle( + "ExampleTests", + vec![suite( + "SnapshotReproTests", + vec![case( + "testExample()", + "SnapshotReproTests/testExample()", + result, + vec![], + )], + )], + ); + assert_eq!( + file_of(&report(tests, declarations()), "testExample()").as_deref(), + Some(TEST_FILE) + ); + } + + #[test] + fn a_passing_test_case_has_no_file_from_failure_summaries() { + let tests = bundle( + "ExampleTests", + vec![suite( + "SnapshotReproTests", + vec![case( + "testExample()", + "SnapshotReproTests/testExample()", + "Passed", + vec![], + )], + )], + ); + assert_eq!( + file_of( + &report(tests, FileAttribution::FailureSummaries(HashMap::new())), + "testExample()" + ), + None + ); + } + + // Whether the helper is in the repo or vendored, the raised-at location is not the test. + #[rstest] + #[case::in_repo_helper(HELPER_FILE)] + #[case::vendored_dependency(DEPENDENCY_FILE)] + fn a_failure_raised_elsewhere_is_still_attributed_to_the_test_file(#[case] raised_in: &str) { + let tests = bundle( + "ExampleTests", + vec![suite( + "SnapshotReproTests", + vec![case( + "testExample()", + "SnapshotReproTests/testExample()", + "Failed", + vec![raised_at(raised_in)], + )], + )], + ); + assert_eq!( + file_of(&report(tests, declarations()), "testExample()").as_deref(), + Some(TEST_FILE) + ); + } + + // With no declaration to find — a runtime-registered test — the raised-at location is + // all there is, and a vendored one must still be refused rather than reported. + #[rstest] + #[case::in_repo_helper_is_better_than_nothing(HELPER_FILE, Some(HELPER_FILE))] + #[case::vendored_dependency_is_refused(DEPENDENCY_FILE, None)] + fn an_unresolved_test_falls_back_to_the_raised_at_location( + #[case] raised_in: &str, + #[case] expected: Option<&str>, + ) { + let tests = bundle( + "ExampleTests", + vec![suite( + "QuickSpec", + vec![case( + "a calculator, fails on purpose()", + "QuickSpec/a calculator, fails on purpose()", + "Failed", + vec![raised_at(raised_in)], + )], + )], + ); + assert_eq!( + file_of( + &report( + tests, + FileAttribution::Declarations(TestLocationIndex::default()) + ), + "a calculator, fails on purpose()" + ) + .as_deref(), + expected + ); } } diff --git a/xcresult/src/xcrun.rs b/xcresult/src/xcrun.rs index 80286a22..9498c84e 100644 --- a/xcresult/src/xcrun.rs +++ b/xcresult/src/xcrun.rs @@ -1,12 +1,55 @@ -use std::{ffi::OsStr, process::Command}; +use std::{ffi::OsStr, path::PathBuf, process::Command}; use lazy_static::lazy_static; +use serde::Deserialize; use crate::{ types::legacy_schema::{ActionTestPlanRunSummaries, ActionsInvocationRecord}, types::schema::Tests, }; +#[derive(Debug, Deserialize)] +pub struct TestResultsSummary { + /// Seconds since the Unix epoch. + #[serde(rename = "startTime")] + pub start_time: Option, +} + +pub fn xcresulttool_get_test_results_summary>( + path: T, +) -> anyhow::Result { + xcresulttool_min_version_check()?; + + let output = xcrun(&[ + "xcresulttool".as_ref(), + "get".as_ref(), + "test-results".as_ref(), + "summary".as_ref(), + "--path".as_ref(), + path.as_ref(), + ])?; + + serde_json::from_str::(&output) + .map_err(|e| anyhow::anyhow!("failed to parse json from xcresulttool output: {}", e)) +} + +/// `None` when `name` ships in neither Xcode nor the Command Line Tools. +pub fn xcrun_find(name: &str) -> Option { + if !cfg!(target_os = "macos") { + return None; + } + let output = Command::new("xcrun").args(["--find", name]).output().ok()?; + if !output.status.success() { + return None; + } + let path = PathBuf::from(String::from_utf8(output.stdout).ok()?.trim()); + if path.as_os_str().is_empty() { + None + } else { + Some(path) + } +} + pub fn xcresulttool_get_test_results_tests>(path: T) -> anyhow::Result { xcresulttool_min_version_check()?; diff --git a/xcresult/tests/xcresult.rs b/xcresult/tests/xcresult.rs index 35397b5d..7e822337 100644 --- a/xcresult/tests/xcresult.rs +++ b/xcresult/tests/xcresult.rs @@ -6,6 +6,8 @@ use lazy_static::lazy_static; use rstest::rstest; use tar::Archive; use temp_testdir::TempDir; +#[cfg(target_os = "macos")] +use xcresult::test_locations::Limits; use xcresult::xcresult::XCResult; fn unpack_archive_to_temp_dir>(archive_file_path: T) -> TempDir { @@ -639,3 +641,222 @@ fn test_xcresult_with_variant_id_generation() { ); } } + +// The declaration path (`--use-experimental-xcresult-test-locations`) resolves each test +// against the checkout rather than a failure, so its expectation is the file the test is +// *written in* — which for these two fixtures is exactly the file the failure-summary +// paths cannot name, because the failure is raised in a helper. +#[cfg(target_os = "macos")] +fn declaration_files, U: AsRef>( + bundle_path: T, + repo_root: U, +) -> std::collections::HashMap { + let xcresult = XCResult::new_with_declaration_locations( + bundle_path.as_ref().to_str().unwrap(), + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + repo_root.as_ref(), + Limits::default(), + ) + .expect("the declaration path reads the bundle"); + + let mut junits = xcresult.generate_junits(); + assert_eq!(junits.len(), 1); + junits + .pop() + .unwrap() + .test_suites + .iter() + .flat_map(|test_suite| test_suite.test_cases.iter()) + .map(|test_case| { + let file = test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "file") + .map(|(_, value)| value.as_str().to_owned()) + .unwrap_or_default(); + (test_case.name.as_str().to_owned(), file) + }) + .collect() +} + +// Every file this bundle's failure summary offers is under `SourcePackages/checkouts/`. +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_prefer_the_tests_own_file_over_a_vendored_dependency() { + let files = declaration_files( + TEMP_DIR_TEST_DEPENDENCY_RAISES_FAILURE + .as_ref() + .join("DependencyRaisesFailure.xcresult"), + "tests/fixture-src/dependency-raises-failure", + ); + let file = files + .get("failsInsideDependency()") + .expect("the fixture's only test"); + assert!( + file.ends_with("DependencyRaisesFailureTests.swift"), + "expected the test's own file, got {file}" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_prefer_the_tests_own_file_over_an_in_repo_helper() { + let files = declaration_files( + TEMP_DIR_TEST_IN_REPO_HELPER_RAISES_FAILURE + .as_ref() + .join("InRepoHelperRaisesFailure.xcresult"), + "tests/fixture-src/in-repo-helper-raises-failure", + ); + let file = files + .get("failsInsideHelper()") + .expect("the fixture's only test"); + assert!( + file.ends_with("InRepoHelperRaisesFailureTests.swift"), + "expected the test's own file, got {file}" + ); +} + +// The case no failure summary can serve: one test crashes with zero call-stack frames and +// the other is failed by a trait after its own frame is gone, so both failure-summary paths +// report no file at all — `data/test-crash-in-dependency.junit.xml` has none. +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_attribute_tests_no_failure_summary_can() { + let files = declaration_files( + TEMP_DIR_TEST_CRASH_IN_DEPENDENCY + .as_ref() + .join("CrashInDependency.xcresult"), + "tests/fixture-src/crash-in-dependency", + ); + for (name, expected) in [ + ( + "testCrashesInsideDependency()", + "CrashInDependencyTests.swift", + ), + ( + "failsAfterItsOwnFrameIsGone()", + "TeardownFailureTests.swift", + ), + ] { + let file = files + .get(name) + .unwrap_or_else(|| panic!("{name} is missing from the report")); + assert!( + file.ends_with(expected), + "expected {name} to resolve to {expected}, got {file}" + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_resolve_an_objc_test_through_clangd() { + let files = declaration_files( + TEMP_DIR_TEST_OBJC_XCTEST + .as_ref() + .join("ObjcXCTest.xcresult"), + "tests/fixture-src/objc-xctest", + ); + let file = files + .get("testFailsInsideSharedHelper") + .expect("the fixture's only test"); + assert!( + file.ends_with("ObjcXCTestTests.m"), + "expected the test's own file, got {file}" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_find_a_top_level_swift_testing_function() { + let files = declaration_files( + TEMP_DIR_TEST_TOPLEVEL_SWIFT_TESTING + .as_ref() + .join("ToplevelSwiftTesting.xcresult"), + "tests/fixture-src/toplevel-swift-testing", + ); + let file = files + .get("failsInsideHelperWithoutASuite()") + .expect("the fixture's only test"); + assert!( + file.ends_with("ToplevelSwiftTestingTests.swift"), + "expected the test's own file, got {file}" + ); +} + +// Two things the declaration path reads that only a real bundle can confirm, both of which +// fail silently rather than loudly if the assumption is wrong. +// +// `nodeIdentifierURL` is meant to be the legacy record's `identifierURL` under another name, +// and ids are derived from it — if it is absent from the modern API, ids fall back to +// `nodeIdentifier` and every xcresult test case in the product gets a new identity. +// `get test-results summary`'s `startTime` is read as seconds since the Unix epoch; if it is +// an Apple reference-date offset instead, every timestamp lands three decades off. +// +// Both are checked as equivalence against the path already in production, on a bundle whose +// repo root is empty so no language server runs and nothing else can move. +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_keep_ids_and_timestamps_identical_to_the_legacy_path() { + fn ids_and_timestamps(xcresult: &XCResult) -> Vec<(String, String, String)> { + let mut junits = xcresult.generate_junits(); + assert_eq!(junits.len(), 1); + junits + .pop() + .unwrap() + .test_suites + .iter() + .flat_map(|test_suite| test_suite.test_cases.iter()) + .map(|test_case| { + let id = test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "id") + .map(|(_, value)| value.as_str().to_owned()) + .unwrap_or_default(); + ( + test_case.name.as_str().to_owned(), + id, + test_case + .timestamp + .map(|timestamp| timestamp.to_string()) + .unwrap_or_default(), + ) + }) + .collect() + } + + let path = TEMP_DIR_TEST_TIMESTAMP.as_ref().join("test1.xcresult"); + let path_str = path.to_str().unwrap(); + let empty_checkout = TempDir::default(); + + let legacy = XCResult::new( + path_str, + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + false, + ) + .unwrap(); + let declarations = XCResult::new_with_declaration_locations( + path_str, + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + empty_checkout.as_ref(), + Limits::default(), + ) + .unwrap(); + + let expected = ids_and_timestamps(&legacy); + assert!( + !expected.is_empty(), + "the fixture must have test cases for this to prove anything" + ); + assert!( + expected + .iter() + .all(|(_, id, timestamp)| !id.is_empty() && !timestamp.is_empty()), + "the fixture must carry ids and timestamps on the legacy path" + ); + pretty_assertions::assert_eq!(ids_and_timestamps(&declarations), expected); +} From dfbe20be55a2bbc40ff6def4764ad525ffcd120a Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Sat, 29 Aug 2026 00:23:12 +0000 Subject: [PATCH 02/24] fix(xcresult): stop dropping nested test suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A suite nested inside another suite, and every test it declared, was discarded: the traversal took only a suite's direct `Test Case` children, so a `Test Suite` child was never visited. A swift-testing bundle with 7 tests emitted 4 testcases, with the outer suite left as an empty `` — the tests were simply missing from the upload, silently. JUnit has no nested ``, so a nested suite is flattened into one of its own under a dot-qualified name (`Bundle.Outer.Inner`), which is the convention the bundle prefix already used. The change is additive: an outer suite with no direct cases still emits its empty `` exactly as before, and the inner ones now appear alongside it. This is on the shared traversal, so it applies to the default path, not only to `--use-experimental-xcresult-test-locations`. No checked-in fixture bundle appears to contain a nested suite (none of the expected JUnit files has an empty ``, and the bundle blobs are Apple's compressed encoding, so this could not be confirmed off macOS). If the macOS suite reports a snapshot diff, that is a fixture that did have the bug — the added testcases are the fix working. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/CONTRIBUTING.md | 30 +++++--- xcresult/src/xcresult.rs | 159 +++++++++++++++++++++++++++++++++++---- 2 files changed, 163 insertions(+), 26 deletions(-) diff --git a/xcresult/CONTRIBUTING.md b/xcresult/CONTRIBUTING.md index ee9f1fdd..f46c7a04 100644 --- a/xcresult/CONTRIBUTING.md +++ b/xcresult/CONTRIBUTING.md @@ -4,11 +4,16 @@ The `xcresult` crate exists to handle converting between xcresult and JUnit form ## Purpose -This crate serves two main purposes: +This crate serves three main purposes: 1. **Format Conversion**: Converts xcresult bundles (produced by Xcode test runs) into JUnit XML format for compatibility with various CI/CD systems and test reporting tools. -2. **Conditional File Path Specification**: While there are other xcresult parses, this crate handles specifying file paths in the JUnit output, which are conditionally present based on whether a failure (not error) has occurred. File paths are only included in the JUnit output when a test case has failed, as they are extracted from failure summaries in the xcresult bundle. This also handles generating stable identfiers because, by default, one of the values we generate IDs from is the file path. Without this crate, we wouldn't be able to safely map files to tests nor have codeowners support for xcresult. +2. **Suite flattening**: JUnit has no nested ``, so a suite nested inside another + becomes its own with a dot-qualified name (`Bundle.Outer.Inner`). This is not cosmetic — + emitting only the outer suite silently drops every test the inner ones declare, which is + what used to happen to nested swift-testing suites. + +3. **Conditional File Path Specification**: While there are other xcresult parses, this crate handles specifying file paths in the JUnit output, which are conditionally present based on whether a failure (not error) has occurred. File paths are only included in the JUnit output when a test case has failed, as they are extracted from failure summaries in the xcresult bundle. This also handles generating stable identfiers because, by default, one of the values we generate IDs from is the file path. Without this crate, we wouldn't be able to safely map files to tests nor have codeowners support for xcresult. ## Running the Binary @@ -161,16 +166,21 @@ The split is deliberate, because the parts that need macOS are narrower than the - `src/test_locations.rs` unit-tests the symbol mapping, inheritance walk and source scan against canned `documentSymbol` responses. -- `src/xcresult.rs` unit-tests the attribution join against a canned `Tests` value and a - seeded `TestLocationIndex` (`TestLocationIndex::declaring`, test-only). This is where "a - **passing** test gets a file" and "a failure raised in a helper or a dependency is still - attributed to the test's file" are proven — neither of which needs `xcrun`. +- `src/xcresult.rs` unit-tests suite flattening and the attribution join against a canned + `Tests` value and a seeded `TestLocationIndex` (`TestLocationIndex::declaring`, test-only). + This is where "a **passing** test gets a file", "a failure raised in a helper or a + dependency is still attributed to the test's file", and "no nested suite is dropped" are + proven — none of which needs `xcrun`. - `tests/xcresult.rs` holds the five macOS tests that actually drive `sourcekit-lsp` and `clangd` over the checked-in packages in `tests/fixture-src/`. They pass a scenario's package directory as the repo root, which is why they assert the file a test is _written in_ rather than the absolute path baked into the bundle at capture time. -One gap in the fixtures: **no scenario has a passing test**, so the end-to-end evidence for -attributing one is the unit test above rather than a real bundle. Adding a passing test to a -package means regenerating its bundle (`regenerate.sh`) and re-reviewing its expected JUnit, -both of which need macOS + Xcode. +Two gaps in the fixtures, both needing macOS + Xcode to close: + +- **No scenario has a passing test**, so the end-to-end evidence for attributing one is the + unit test above rather than a real bundle. Adding a passing test to a package means + regenerating its bundle (`regenerate.sh`) and re-reviewing its expected JUnit. +- **No scenario has a nested suite**, so the flattening fix is unit-tested only. If running + the macOS suite after this change produces a snapshot diff, that is a fixture that _did_ + have the bug — the new testcases are the fix working, not a regression. diff --git a/xcresult/src/xcresult.rs b/xcresult/src/xcresult.rs index 585f1561..1a9433c1 100644 --- a/xcresult/src/xcresult.rs +++ b/xcresult/src/xcresult.rs @@ -199,12 +199,7 @@ impl XCResult { ) } else if matches!(test_bundle_or_test_suite.node_type, TestNodeType::TestSuite) { let test_suite = test_bundle_or_test_suite; - vec![ - self.xcresult_test_suite_to_junit_test_suite( - test_suite, - Option::<&str>::None, - ), - ] + self.xcresult_test_suite_to_junit_test_suites(test_suite, None) } else { vec![] } @@ -217,11 +212,12 @@ impl XCResult { test_nodes: &[TestNode], bundle_name: Option, ) -> Vec { + let qualifier = bundle_name.as_ref().map(|bn| bn.as_ref()); let mut test_suites = test_nodes .iter() .filter(|tn| matches!(tn.node_type, TestNodeType::TestSuite)) - .map(|test_suite| { - self.xcresult_test_suite_to_junit_test_suite(test_suite, bundle_name.as_ref()) + .flat_map(|test_suite| { + self.xcresult_test_suite_to_junit_test_suites(test_suite, qualifier) }) .collect::>(); // test cases can be at the top level @@ -242,20 +238,31 @@ impl XCResult { test_suites } - fn xcresult_test_suite_to_junit_test_suite>( + /// A suite and, flattened after it, every suite nested inside it — JUnit has no nested + /// ``, and emitting only the outer one drops the tests the inner ones declare. + fn xcresult_test_suite_to_junit_test_suites( &self, xcresult_test_suite: &TestNode, - bundle_name: Option, - ) -> TestSuite { - let name = bundle_name - .as_ref() - .map(|bn| format!("{}.{}", bn.as_ref(), xcresult_test_suite.name)) + qualifier: Option<&str>, + ) -> Vec { + let name = qualifier + .map(|qualifier| format!("{}.{}", qualifier, xcresult_test_suite.name)) .unwrap_or_else(|| String::from(&xcresult_test_suite.name)); - let mut test_suite = TestSuite::new(name); + let mut test_suite = TestSuite::new(name.clone()); test_suite.add_test_cases( self.xcresult_test_cases_to_junit_test_cases(xcresult_test_suite.children.as_slice()), ); - test_suite + let mut test_suites = vec![test_suite]; + test_suites.extend( + xcresult_test_suite + .children + .iter() + .filter(|tn| matches!(tn.node_type, TestNodeType::TestSuite)) + .flat_map(|nested| { + self.xcresult_test_suite_to_junit_test_suites(nested, Some(&name)) + }), + ); + test_suites } fn xcresult_test_cases_to_junit_test_cases(&self, test_nodes: &[TestNode]) -> Vec { @@ -557,6 +564,126 @@ mod tests { ) } + // The shape that used to lose tests: an outer suite whose children are suites rather + // than cases. Every one of the six cases below has to survive, at a name that says + // where it came from. + #[test] + fn nested_suites_are_flattened_rather_than_dropped() { + let tests = bundle( + "ExampleTests", + vec![ + suite( + "OuterSuite", + vec![ + suite( + "InnerSuite", + vec![ + case( + "innerOne()", + "OuterSuite/InnerSuite/innerOne()", + "Passed", + vec![], + ), + case( + "innerTwo()", + "OuterSuite/InnerSuite/innerTwo()", + "Failed", + vec![], + ), + suite( + "DeeperSuite", + vec![case( + "deepOne()", + "OuterSuite/InnerSuite/DeeperSuite/deepOne()", + "Passed", + vec![], + )], + ), + ], + ), + suite( + "OtherInner", + vec![case( + "otherOne()", + "OuterSuite/OtherInner/otherOne()", + "Passed", + vec![], + )], + ), + ], + ), + suite( + "SiblingSuite", + vec![case( + "siblingOne()", + "SiblingSuite/siblingOne()", + "Passed", + vec![], + )], + ), + case("danglingOne()", "danglingOne()", "Passed", vec![]), + ], + ); + + assert_eq!( + suites_and_cases(&report( + tests, + FileAttribution::FailureSummaries(HashMap::new()) + )), + vec![ + (String::from("ExampleTests.OuterSuite"), vec![]), + ( + String::from("ExampleTests.OuterSuite.InnerSuite"), + vec![String::from("innerOne()"), String::from("innerTwo()")] + ), + ( + String::from("ExampleTests.OuterSuite.InnerSuite.DeeperSuite"), + vec![String::from("deepOne()")] + ), + ( + String::from("ExampleTests.OuterSuite.OtherInner"), + vec![String::from("otherOne()")] + ), + ( + String::from("ExampleTests.SiblingSuite"), + vec![String::from("siblingOne()")] + ), + ( + String::from("ExampleTests"), + vec![String::from("danglingOne()")] + ), + ] + ); + } + + #[test] + fn a_nested_test_case_is_attributed_and_identified_like_any_other() { + let tests = bundle( + "ExampleTests", + vec![suite( + "OuterSuite", + vec![suite( + "SnapshotReproTests", + vec![case( + "testExample()", + "OuterSuite/SnapshotReproTests/testExample()", + "Passed", + vec![], + )], + )], + )], + ); + let report = report(tests, declarations()); + let test_case = report + .test_suites + .iter() + .flat_map(|test_suite| test_suite.test_cases.iter()) + .find(|test_case| test_case.name.as_str() == "testExample()") + .expect("the nested case is emitted"); + assert_eq!(extra(test_case, "file").as_deref(), Some(TEST_FILE)); + assert!(extra(test_case, "id").is_some()); + } + // The capability the failure-summary paths cannot have at all: a test that never failed // has no summary, so there is nothing for them to read a path out of. #[rstest] From 8b2e6ab8f2a99ea33d644d8129709078252ef697 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Fri, 28 Aug 2026 18:12:45 -0700 Subject: [PATCH 03/24] test(xcresult): cover a nested suite and passing tests with a real bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both shapes the two preceding commits changed were proven only by unit test against a canned `Tests` value, because no captured bundle had either: none of the scenarios has a suite nested in a suite, and none has a test that passed. `nested-and-passing` captures both at once. Its inner `@Suite` is declared in a different file from the suite containing it, so resolving it needs a per-test declaration rather than the enclosing suite's file, and three of its four tests pass, so no failure summary names a file for them at all. Run against the pre-fix traversal the bundle emits `tests="2" failures="0"` — the inner suite is never visited, so its two tests are dropped and a run with a failing test reports no failures. That is the symptom the flattening fix was worth making, and it now has a bundle behind it. The shape is structural rather than a failure, which is the one thing `verify-failure-summaries.py` cannot express, so `regenerate.sh` checks this scenario with a sibling `verify-test-structure.py` that asserts the nested suite, the pass/fail split, and the presence of the `nodeIdentifierURL` the ids derive from. The declaration-path tests now assert each test's status alongside its file, so a fixture that drifted to all-failing could no longer keep the passing case green while proving nothing, and the crash scenario's test is named for the crash it covers rather than only for the reason no failure summary can serve it. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/CONTRIBUTING.md | 17 ++-- .../data/test-nested-and-passing.junit.xml | 18 ++++ .../test-nested-and-passing.xcresult.tar.gz | Bin 0 -> 29857 bytes xcresult/tests/fixture-src/README.md | 21 ++-- .../nested-and-passing/Package.swift | 10 ++ .../NestedAndPassingTests/InnerSuite.swift | 18 ++++ .../NestedAndPassingTests.swift | 14 +++ xcresult/tests/fixture-src/regenerate.sh | 8 +- .../fixture-src/verify-test-structure.py | 92 ++++++++++++++++++ xcresult/tests/xcresult.rs | 92 ++++++++++++++++-- 10 files changed, 263 insertions(+), 27 deletions(-) create mode 100644 xcresult/tests/data/test-nested-and-passing.junit.xml create mode 100644 xcresult/tests/data/test-nested-and-passing.xcresult.tar.gz create mode 100644 xcresult/tests/fixture-src/nested-and-passing/Package.swift create mode 100644 xcresult/tests/fixture-src/nested-and-passing/Tests/NestedAndPassingTests/InnerSuite.swift create mode 100644 xcresult/tests/fixture-src/nested-and-passing/Tests/NestedAndPassingTests/NestedAndPassingTests.swift create mode 100755 xcresult/tests/fixture-src/verify-test-structure.py diff --git a/xcresult/CONTRIBUTING.md b/xcresult/CONTRIBUTING.md index f46c7a04..d1cf69ca 100644 --- a/xcresult/CONTRIBUTING.md +++ b/xcresult/CONTRIBUTING.md @@ -171,16 +171,17 @@ The split is deliberate, because the parts that need macOS are narrower than the This is where "a **passing** test gets a file", "a failure raised in a helper or a dependency is still attributed to the test's file", and "no nested suite is dropped" are proven — none of which needs `xcrun`. -- `tests/xcresult.rs` holds the five macOS tests that actually drive `sourcekit-lsp` and +- `tests/xcresult.rs` holds the macOS tests that actually drive `sourcekit-lsp` and `clangd` over the checked-in packages in `tests/fixture-src/`. They pass a scenario's package directory as the repo root, which is why they assert the file a test is _written in_ rather than the absolute path baked into the bundle at capture time. -Two gaps in the fixtures, both needing macOS + Xcode to close: +Both shapes that once had no fixture — a passing test and a nested suite — are covered by +`nested-and-passing`, captured from a package whose inner `@Suite` lives in a different +file from the suite containing it. It is the only scenario whose shape is structural +rather than a failure, so `regenerate.sh` verifies it with `verify-test-structure.py`. -- **No scenario has a passing test**, so the end-to-end evidence for attributing one is the - unit test above rather than a real bundle. Adding a passing test to a package means - regenerating its bundle (`regenerate.sh`) and re-reviewing its expected JUnit. -- **No scenario has a nested suite**, so the flattening fix is unit-tested only. If running - the macOS suite after this change produces a snapshot diff, that is a fixture that _did_ - have the bug — the new testcases are the fix working, not a regression. +Against that bundle the pre-fix traversal emits `tests="2" failures="0"`: the inner suite +is never visited, so its two tests are dropped and the run reports no failures despite +having one. Its three passing tests get a file only on the declaration path, since a test +that did not fail has no failure summary to name one. diff --git a/xcresult/tests/data/test-nested-and-passing.junit.xml b/xcresult/tests/data/test-nested-and-passing.junit.xml new file mode 100644 index 00000000..9e251dee --- /dev/null +++ b/xcresult/tests/data/test-nested-and-passing.junit.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/xcresult/tests/data/test-nested-and-passing.xcresult.tar.gz b/xcresult/tests/data/test-nested-and-passing.xcresult.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..d573da16fb8f5acb673d42c09e00844def87199d GIT binary patch literal 29857 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 R_=EpJ_+L>H&8Pr$1OOub2kQU; literal 0 HcmV?d00001 diff --git a/xcresult/tests/fixture-src/README.md b/xcresult/tests/fixture-src/README.md index 8838bd97..dc33c9ed 100644 --- a/xcresult/tests/fixture-src/README.md +++ b/xcresult/tests/fixture-src/README.md @@ -62,15 +62,18 @@ table below, and only then save the new output over `../data/test-*.junit.xml`. ## What each scenario must exhibit `verify-failure-summaries.py` enforces the "captured shape" column and fails the -regeneration if a bundle stops reproducing it. - -| Scenario | Captured shape | Expected `file` (experimental) | Expected `file` (legacy) | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ------------------------ | -| `dependency-raises-failure` | A swift-testing test calls a dependency helper that records the issue at its own `#filePath`. `fileName`, the source code context's location and the innermost frame are all under `SourcePackages/checkouts/`; only the test's own frame names the test file. | the test's own file | _(none)_ | -| `in-repo-helper-raises-failure` | Same, with the helper in the test target. Nothing rejects the helper's path, so the test's own frame has to win on its own merits. | the test's own file | the helper's file | -| `crash-in-dependency` | Two tests that never reach their own frame: one `fatalError`s inside the dependency (Xcode records a summary with no file at all), and one is failed by the dependency's `TestScoping` trait after its body returned (every file source is a checkout path). | _(none)_ | _(none)_ | -| `objc-xctest` | An Objective-C `XCTestCase` whose failure is raised by `XCTFail` in a shared category in another file, so the frame is symbolicated as `-[ObjcXCTestTests testFailsInsideSharedHelper]`. | the test's own file | _(none)_ | -| `toplevel-swift-testing` | A top-level `@Test func` with no suite, failed by a helper in another file, so the frame is the bare function name. | the test's own file | the helper's file | +regeneration if a bundle stops reproducing it. `nested-and-passing` is checked by +`verify-test-structure.py` instead: its shape is the result tree itself, which no +failure summary describes. + +| Scenario | Captured shape | Expected `file` (experimental) | Expected `file` (legacy) | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------- | +| `dependency-raises-failure` | A swift-testing test calls a dependency helper that records the issue at its own `#filePath`. `fileName`, the source code context's location and the innermost frame are all under `SourcePackages/checkouts/`; only the test's own frame names the test file. | the test's own file | _(none)_ | +| `in-repo-helper-raises-failure` | Same, with the helper in the test target. Nothing rejects the helper's path, so the test's own frame has to win on its own merits. | the test's own file | the helper's file | +| `crash-in-dependency` | Two tests that never reach their own frame: one `fatalError`s inside the dependency (Xcode records a summary with no file at all), and one is failed by the dependency's `TestScoping` trait after its body returned (every file source is a checkout path). | _(none)_ | _(none)_ | +| `objc-xctest` | An Objective-C `XCTestCase` whose failure is raised by `XCTFail` in a shared category in another file, so the frame is symbolicated as `-[ObjcXCTestTests testFailsInsideSharedHelper]`. | the test's own file | _(none)_ | +| `toplevel-swift-testing` | A top-level `@Test func` with no suite, failed by a helper in another file, so the frame is the bare function name. | the test's own file | the helper's file | +| `nested-and-passing` | A `@Suite` nested inside another `@Suite`, declared in a separate file, plus three passing tests. Before the flattening fix the inner suite was never visited, so its two tests — and its failure — vanished from the JUnit. Passing tests have no failure summary, so only a declaration can name their file. | the file each test is declared in | the failing test's own file _(none for the passing three)_ | The two columns differ because only the experimental path reads the failure summary's call stack, which is the only source that can identify the test's own diff --git a/xcresult/tests/fixture-src/nested-and-passing/Package.swift b/xcresult/tests/fixture-src/nested-and-passing/Package.swift new file mode 100644 index 00000000..afa461bf --- /dev/null +++ b/xcresult/tests/fixture-src/nested-and-passing/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "NestedAndPassing", + platforms: [.macOS(.v13)], + targets: [ + .testTarget(name: "NestedAndPassingTests") + ] +) diff --git a/xcresult/tests/fixture-src/nested-and-passing/Tests/NestedAndPassingTests/InnerSuite.swift b/xcresult/tests/fixture-src/nested-and-passing/Tests/NestedAndPassingTests/InnerSuite.swift new file mode 100644 index 00000000..552fc60d --- /dev/null +++ b/xcresult/tests/fixture-src/nested-and-passing/Tests/NestedAndPassingTests/InnerSuite.swift @@ -0,0 +1,18 @@ +import Testing + +/// Declared in its own file, and nested inside `OuterSuite`, so a suite that is a +/// child of another suite has to be visited and its file resolved independently. +extension OuterSuite { + @Suite + struct InnerSuite { + @Test + func innerPasses() { + #expect(true) + } + + @Test + func innerFails() { + #expect(Bool(false), "deliberate failure inside a nested suite") + } + } +} diff --git a/xcresult/tests/fixture-src/nested-and-passing/Tests/NestedAndPassingTests/NestedAndPassingTests.swift b/xcresult/tests/fixture-src/nested-and-passing/Tests/NestedAndPassingTests/NestedAndPassingTests.swift new file mode 100644 index 00000000..66602c35 --- /dev/null +++ b/xcresult/tests/fixture-src/nested-and-passing/Tests/NestedAndPassingTests/NestedAndPassingTests.swift @@ -0,0 +1,14 @@ +import Testing + +@Suite +struct OuterSuite { + @Test + func outerPasses() { + #expect(1 + 1 == 2) + } +} + +@Test +func topLevelPasses() { + #expect(true) +} diff --git a/xcresult/tests/fixture-src/regenerate.sh b/xcresult/tests/fixture-src/regenerate.sh index b7a24583..7ce42cc9 100755 --- a/xcresult/tests/fixture-src/regenerate.sh +++ b/xcresult/tests/fixture-src/regenerate.sh @@ -24,6 +24,7 @@ ALL_SCENARIOS=( crash-in-dependency objc-xctest toplevel-swift-testing + nested-and-passing ) # scenario -> the package name, which is both the xcodebuild scheme prefix and the @@ -35,6 +36,7 @@ package_name() { crash-in-dependency) echo CrashInDependency ;; objc-xctest) echo ObjcXCTest ;; toplevel-swift-testing) echo ToplevelSwiftTesting ;; + nested-and-passing) echo NestedAndPassing ;; *) echo "unknown scenario: $1" >&2 exit 1 @@ -113,7 +115,11 @@ regenerate() { return 1 } - "${FIXTURE_SRC_DIR}/verify-failure-summaries.py" "${scenario}" "${dump}" + if [[ ${scenario} == nested-and-passing ]]; then + "${FIXTURE_SRC_DIR}/verify-test-structure.py" "${scenario}" "${bundle}" + else + "${FIXTURE_SRC_DIR}/verify-failure-summaries.py" "${scenario}" "${dump}" + fi tar -czf "${DATA_DIR}/test-${scenario}.xcresult.tar.gz" \ -C "${scenario_work_dir}" "${package}.xcresult" diff --git a/xcresult/tests/fixture-src/verify-test-structure.py b/xcresult/tests/fixture-src/verify-test-structure.py new file mode 100755 index 00000000..7bf2dcf6 --- /dev/null +++ b/xcresult/tests/fixture-src/verify-test-structure.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Assert that a captured .xcresult exhibits the *structure* it was captured for. + +The sibling `verify-failure-summaries.py` covers scenarios whose shape is a failure +raised somewhere other than the test. Neither a suite nested in a suite nor a test +that simply passed is visible in a failure summary at all. + + ./verify-test-structure.py +""" + +import json +import subprocess +import sys + +# `nested_suite` is an (outer, inner) pair; without one the flattening is unexercised. +SCENARIOS = { + "nested-and-passing": { + "nested_suite": ("OuterSuite", "InnerSuite"), + "passing": [ + "OuterSuite/outerPasses()", + "OuterSuite/InnerSuite/innerPasses()", + "topLevelPasses()", + ], + "failing": ["OuterSuite/InnerSuite/innerFails()"], + }, +} + + +def walk(node, parent, suites, cases): + kind = node.get("nodeType") + if kind == "Test Suite": + suites.append((parent, node.get("name"))) + parent = node.get("name") + elif kind == "Test Case": + cases.append(node) + for child in node.get("children", []) or []: + walk(child, parent, suites, cases) + + +def main(): + scenario, bundle = sys.argv[1], sys.argv[2] + expected = SCENARIOS[scenario] + tests = json.loads( + subprocess.run( + ["xcrun", "xcresulttool", "get", "test-results", "tests", "--path", bundle], + capture_output=True, + text=True, + check=True, + ).stdout + ) + + suites, cases = [], [] + for node in tests.get("testNodes", []): + walk(node, None, suites, cases) + by_identifier = {case.get("nodeIdentifier"): case for case in cases} + + failures = [] + if expected["nested_suite"] not in suites: + failures.append( + f"expected a nested suite {expected['nested_suite']}, found {suites}" + ) + + for identifier in expected["passing"] + expected["failing"]: + case = by_identifier.get(identifier) + if case is None: + failures.append( + f"{identifier}: not in the bundle (found {sorted(by_identifier)})" + ) + continue + failed = case.get("result") == "Failed" + if identifier in expected["failing"] and not failed: + failures.append(f"{identifier}: expected it to have failed") + if identifier in expected["passing"] and failed: + failures.append(f"{identifier}: expected it to have passed") + # Falling back to `nodeIdentifier` would move every test case's id in the product. + if not case.get("nodeIdentifierURL"): + failures.append(f"{identifier}: no nodeIdentifierURL to derive an id from") + + if failures: + print(f"{scenario}: bundle does not exhibit its structure", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + sys.exit(1) + + print( + f"{scenario}: verified a nested suite and " + f"{len(expected['passing'])} passing test(s)" + ) + + +if __name__ == "__main__": + main() diff --git a/xcresult/tests/xcresult.rs b/xcresult/tests/xcresult.rs index 7e822337..1fe7bef1 100644 --- a/xcresult/tests/xcresult.rs +++ b/xcresult/tests/xcresult.rs @@ -46,6 +46,8 @@ lazy_static! { unpack_archive_to_temp_dir("tests/data/test-objc-xctest.xcresult.tar.gz"); static ref TEMP_DIR_TEST_TOPLEVEL_SWIFT_TESTING: TempDir = unpack_archive_to_temp_dir("tests/data/test-toplevel-swift-testing.xcresult.tar.gz"); + static ref TEMP_DIR_TEST_NESTED_AND_PASSING: TempDir = + unpack_archive_to_temp_dir("tests/data/test-nested-and-passing.xcresult.tar.gz"); static ref TEMP_DIR_TEST_TIMESTAMP: TempDir = unpack_archive_to_temp_dir("tests/data/test-timestamp.xcresult.tar.gz"); static ref TEMP_DIR_TEST_VARIANT: TempDir = @@ -647,10 +649,10 @@ fn test_xcresult_with_variant_id_generation() { // *written in* — which for these two fixtures is exactly the file the failure-summary // paths cannot name, because the failure is raised in a helper. #[cfg(target_os = "macos")] -fn declaration_files, U: AsRef>( +fn declaration_report, U: AsRef>( bundle_path: T, repo_root: U, -) -> std::collections::HashMap { +) -> quick_junit::Report { let xcresult = XCResult::new_with_declaration_locations( bundle_path.as_ref().to_str().unwrap(), ORG_URL_SLUG.clone(), @@ -662,9 +664,15 @@ fn declaration_files, U: AsRef>( let mut junits = xcresult.generate_junits(); assert_eq!(junits.len(), 1); - junits - .pop() - .unwrap() + junits.pop().unwrap() +} + +#[cfg(target_os = "macos")] +fn declaration_files, U: AsRef>( + bundle_path: T, + repo_root: U, +) -> std::collections::HashMap { + declaration_report(bundle_path, repo_root) .test_suites .iter() .flat_map(|test_suite| test_suite.test_cases.iter()) @@ -717,12 +725,12 @@ fn test_declaration_locations_prefer_the_tests_own_file_over_an_in_repo_helper() ); } -// The case no failure summary can serve: one test crashes with zero call-stack frames and -// the other is failed by a trait after its own frame is gone, so both failure-summary paths -// report no file at all — `data/test-crash-in-dependency.junit.xml` has none. +// The case no failure summary can serve: one test crashes inside a dependency with zero +// call-stack frames, the other is failed by a trait after its own frame is gone, so both +// failure-summary paths report no file — `data/test-crash-in-dependency.junit.xml` has none. #[cfg(target_os = "macos")] #[test] -fn test_declaration_locations_attribute_tests_no_failure_summary_can() { +fn test_declaration_locations_give_a_crashed_test_its_file() { let files = declaration_files( TEMP_DIR_TEST_CRASH_IN_DEPENDENCY .as_ref() @@ -860,3 +868,69 @@ fn test_declaration_locations_keep_ids_and_timestamps_identical_to_the_legacy_pa ); pretty_assertions::assert_eq!(ids_and_timestamps(&declarations), expected); } + +// Before the fix this bundle emitted tests="2" failures="0" — the inner suite's two tests +// and its failure all vanished. +#[cfg(target_os = "macos")] +#[rstest] +#[case::experimental_failure_summary(true)] +#[case::legacy_fallback(false)] +fn test_a_nested_suite_is_flattened_rather_than_dropped( + #[case] use_experimental_failure_summary: bool, +) { + assert_junit( + TEMP_DIR_TEST_NESTED_AND_PASSING + .as_ref() + .join("NestedAndPassing.xcresult"), + use_experimental_failure_summary, + include_str!("data/test-nested-and-passing.junit.xml"), + ); +} + +// Three of these four passed, so no failure summary names a file for any of them. The +// status is asserted too, or the fixture could drift to all-failing and still pass here. +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_give_a_passing_test_its_file() { + let report = declaration_report( + TEMP_DIR_TEST_NESTED_AND_PASSING + .as_ref() + .join("NestedAndPassing.xcresult"), + "tests/fixture-src/nested-and-passing", + ); + let cases: std::collections::HashMap = report + .test_suites + .iter() + .flat_map(|test_suite| test_suite.test_cases.iter()) + .map(|test_case| (test_case.name.as_str().to_owned(), test_case)) + .collect(); + + for (name, passed, expected) in [ + ("outerPasses()", true, "NestedAndPassingTests.swift"), + ("topLevelPasses()", true, "NestedAndPassingTests.swift"), + ("innerPasses()", true, "InnerSuite.swift"), + ("innerFails()", false, "InnerSuite.swift"), + ] { + let test_case = cases + .get(name) + .unwrap_or_else(|| panic!("{name} is missing from the report")); + assert_eq!( + matches!( + test_case.status, + quick_junit::TestCaseStatus::Success { .. } + ), + passed, + "{name} did not have the status the fixture was captured for" + ); + let file = test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "file") + .map(|(_, value)| value.as_str()) + .unwrap_or_else(|| panic!("{name} got no file from its declaration")); + assert!( + file.ends_with(expected), + "expected {name} to be declared in {expected}, got {file}" + ); + } +} From f9776f7d0351c88f3a6f0023a80148045d1c4c68 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Sat, 29 Aug 2026 13:07:06 -0700 Subject: [PATCH 04/24] test(xcresult): run every bundle through the declaration path as a regression net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag was proven on the five fixtures built to exercise it, which says nothing about the bundles it was not designed around — and those are most of them. Rather than snapshot each bundle a second time, which would mean checking in a near-duplicate of every expected JUnit differing only where the flag is supposed to differ, this asserts the invariant directly: for every bundle the suite reads, the declaration path and the default path agree on suite, name, id, status and timestamp, and the reported file is never a vendored path. `file` is the one thing allowed to move, and it is the one thing not compared. It is not a vacuous check. Reverting the `startTime` rounding fails all twelve cases, so the millisecond bug this suite only caught on a single fixture would now be caught on every one of them. Each case unpacks its own copy of its bundle. `xcresulttool` migrates a bundle in place on first read, and pointing a second concurrent reader at a freshly unpacked one races to create its `database.sqlite3`: Error: "database.sqlite3" couldn't be moved to "test4.xcresult" because an item with the same name already exists. Sharing the existing fixtures would have made every bundle a two-reader race and turned `test_complex_xcresult_with_valid_path` intermittent. The CLI's own xcresult upload test is parameterised over the flag too, so the path is covered end-to-end through argument parsing and the upload rather than only at the crate boundary. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + cli/Cargo.toml | 1 + cli/tests/common/command_builder.rs | 10 +++ cli/tests/upload.rs | 10 ++- xcresult/tests/xcresult.rs | 131 ++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf9745e8..a8d7412d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6156,6 +6156,7 @@ dependencies = [ "quick-junit", "regex", "reqwest", + "rstest", "sentry", "sentry-tracing", "serde_json", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 22230162..e25e519c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -71,6 +71,7 @@ more-asserts = "0.3.1" predicates = "3.0.3" pretty_assertions = "0.6" prost-wkt-types = { version = "0.5.1", features = ["vendored-protox"] } +rstest = "0.26.1" [build-dependencies] vergen = { version = "8.3.1", features = [ diff --git a/cli/tests/common/command_builder.rs b/cli/tests/common/command_builder.rs index f8cbb955..2000b896 100644 --- a/cli/tests/common/command_builder.rs +++ b/cli/tests/common/command_builder.rs @@ -452,6 +452,7 @@ pub struct CommandBuilder<'a> { command_type: CommandType, current_dir: &'a Path, paths_state: Option, + extra_args: Vec, } impl<'b> CommandBuilder<'b> { @@ -463,6 +464,7 @@ impl<'b> CommandBuilder<'b> { }, current_dir, paths_state: None, + extra_args: Vec::new(), } } @@ -475,6 +477,7 @@ impl<'b> CommandBuilder<'b> { }, current_dir, paths_state: None, + extra_args: Vec::new(), } } @@ -486,6 +489,7 @@ impl<'b> CommandBuilder<'b> { }, current_dir, paths_state: None, + extra_args: Vec::new(), } } @@ -494,6 +498,11 @@ impl<'b> CommandBuilder<'b> { self } + pub fn extra_args(&mut self, args: &[&str]) -> &mut Self { + self.extra_args = args.iter().map(|arg| String::from(*arg)).collect(); + self + } + pub fn xcresult_path(&mut self, new_paths: &str) -> &mut Self { self.paths_state = Some(PathsState::XCResultPath(String::from(new_paths))); self @@ -634,6 +643,7 @@ impl<'b> CommandBuilder<'b> { .into_iter() .chain(paths_args) .chain(self.command_type.build_args()) + .chain(self.extra_args.clone()) .collect() } diff --git a/cli/tests/upload.rs b/cli/tests/upload.rs index d4088329..a5b9a5a0 100644 --- a/cli/tests/upload.rs +++ b/cli/tests/upload.rs @@ -802,9 +802,14 @@ async fn upload_bundle_without_canonical_test_collection_metadata_keeps_bundle_g assert_eq!(bundle_meta.bundle_upload_id_v2, "test-bundle-upload-id-v2"); } -#[tokio::test(flavor = "multi_thread")] +// The declaration path is exercised end-to-end here too, so a bundle that uploads cleanly +// today cannot start failing behind the flag without this noticing. #[cfg(target_os = "macos")] -async fn upload_bundle_using_xcresult() { +#[rstest::rstest] +#[case::default_path(&[])] +#[case::declaration_locations(&["--use-experimental-xcresult-test-locations=true"])] +#[tokio::test(flavor = "multi_thread")] +async fn upload_bundle_using_xcresult(#[case] extra_args: &[&str]) { let temp_dir = tempdir().unwrap(); generate_mock_git_repo(&temp_dir); unpack_archive_to_dir( @@ -816,6 +821,7 @@ async fn upload_bundle_using_xcresult() { let assert = CommandBuilder::upload(temp_dir.path(), state.host.clone()) .xcresult_path("test1.xcresult") + .extra_args(extra_args) .command() .assert() .success() diff --git a/xcresult/tests/xcresult.rs b/xcresult/tests/xcresult.rs index 1fe7bef1..23543bc3 100644 --- a/xcresult/tests/xcresult.rs +++ b/xcresult/tests/xcresult.rs @@ -934,3 +934,134 @@ fn test_declaration_locations_give_a_passing_test_its_file() { ); } } + +// The flag is meant to move the `file` attribute and leave everything else alone, so every +// bundle the suite reads is run both ways and compared on all of it but that. Each case +// unpacks its own copy: `xcresulttool` migrates a bundle in place on first read, and two +// concurrent readers of one freshly unpacked bundle race to create its `database.sqlite3`. +#[cfg(target_os = "macos")] +#[rstest] +#[case::simple("tests/data/test1.xcresult.tar.gz", "test1.xcresult", None)] +#[case::complex("tests/data/test4.xcresult.tar.gz", "test4.xcresult", None)] +#[case::expected_failures( + "tests/data/test-ExpectedFailures.xcresult.tar.gz", + "test-ExpectedFailures.xcresult", + None +)] +#[case::swift_mix( + "tests/data/test-swift-mix.xcresult.tar.gz", + "test-swift-mix.xcresult", + None +)] +#[case::swift_without_test_suites( + "tests/data/test-swift-without-test-suites.xcresult.tar.gz", + "test-swift-without-test-suites.xcresult", + None +)] +#[case::swift_snapshot_testing( + "tests/data/test-swift-snapshot-testing.xcresult.tar.gz", + "SnapshotRepro.xcresult", + None +)] +#[case::dependency_raises_failure( + "tests/data/test-dependency-raises-failure.xcresult.tar.gz", + "DependencyRaisesFailure.xcresult", + Some("tests/fixture-src/dependency-raises-failure") +)] +#[case::in_repo_helper_raises_failure( + "tests/data/test-in-repo-helper-raises-failure.xcresult.tar.gz", + "InRepoHelperRaisesFailure.xcresult", + Some("tests/fixture-src/in-repo-helper-raises-failure") +)] +#[case::crash_in_dependency( + "tests/data/test-crash-in-dependency.xcresult.tar.gz", + "CrashInDependency.xcresult", + Some("tests/fixture-src/crash-in-dependency") +)] +#[case::objc_xctest( + "tests/data/test-objc-xctest.xcresult.tar.gz", + "ObjcXCTest.xcresult", + Some("tests/fixture-src/objc-xctest") +)] +#[case::toplevel_swift_testing( + "tests/data/test-toplevel-swift-testing.xcresult.tar.gz", + "ToplevelSwiftTesting.xcresult", + Some("tests/fixture-src/toplevel-swift-testing") +)] +#[case::nested_and_passing( + "tests/data/test-nested-and-passing.xcresult.tar.gz", + "NestedAndPassing.xcresult", + Some("tests/fixture-src/nested-and-passing") +)] +fn test_the_declaration_flag_moves_the_file_and_nothing_else( + #[case] archive: &str, + #[case] bundle: &str, + #[case] repo_root: Option<&str>, +) { + fn shape(xcresult: &XCResult) -> Vec { + let mut junits = xcresult.generate_junits(); + assert_eq!(junits.len(), 1); + let junit = junits.pop().unwrap(); + let mut rows = Vec::new(); + for test_suite in &junit.test_suites { + for test_case in &test_suite.test_cases { + let id = test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "id") + .map(|(_, value)| value.as_str().to_owned()) + .unwrap_or_default(); + rows.push(format!( + "{} | {} | {} | {:?} | {}", + test_suite.name.as_str(), + test_case.name.as_str(), + id, + test_case.status, + test_case + .timestamp + .map(|timestamp| timestamp.to_string()) + .unwrap_or_default(), + )); + } + } + rows + } + + let temp_dir = unpack_archive_to_temp_dir(archive); + let bundle_path = temp_dir.as_ref().join(bundle); + let path_str = bundle_path.to_str().unwrap(); + let empty_checkout = TempDir::default(); + let root: &Path = match repo_root { + Some(repo_root) => Path::new(repo_root), + None => empty_checkout.as_ref(), + }; + + let default = XCResult::new( + path_str, + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + false, + ) + .expect("the default path reads the bundle"); + let declarations = XCResult::new_with_declaration_locations( + path_str, + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + root, + Limits::default(), + ) + .expect("the declaration path reads the bundle"); + + let expected = shape(&default); + assert!(!expected.is_empty(), "the bundle must have test cases"); + pretty_assertions::assert_eq!(shape(&declarations), expected); + + for file in declaration_files(&bundle_path, root).values() { + assert!( + !["/.build/", "/checkouts/", "/DerivedData/"] + .iter() + .any(|segment| file.contains(segment)), + "the declaration path reported a vendored file: {file}" + ); + } +} From 780afb284c6d2a024cc18020226027063bba7bc2 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Sat, 29 Aug 2026 13:42:16 -0700 Subject: [PATCH 05/24] fix(xcresult): read a copy of the bundle instead of migrating the caller's `xcresulttool` migrates a bundle that predates `database.sqlite3` in place the first time it is read. Two things follow, neither of them ours to do: - an upload writes into a build artifact it was only asked to read, and - the read fails outright when that directory is not writable: Error: "database.sqlite3" couldn't be moved because you don't have permission to access "test4.xcresult". which is `exit 64` and no JUnit at all, on the read-only artifact mounts CI systems hand out. It is also why two readers of one bundle race, which is what made `test_complex_xcresult_with_valid_path` fail once a second test read the same fixture. Both constructors now copy the bundle into a `TempDir` and read that, so the caller's directory is never written to and never needs to be writable. This is on the shared path, so the default one is fixed too, not just the flag. The copy is unconditional rather than keyed on whether a migration would happen: sniffing the format to save a copy trades a correctness guarantee for work we already do in well under a second on a 64 MB bundle. The declaration path's fallback is instrumented while here. It is meant to catch runtime-registered tests by reading the modern API's `sourceLocation`, but that field is emitted in none of the bundles in `tests/data/`, so it never fires and such a test gets no file at all. `generate_junits` now logs how many files came from a declaration, from the fallback, and from neither, so whether that holds against real-world bundles is answerable rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + xcresult/CONTRIBUTING.md | 31 +++++++++++++-- xcresult/Cargo.toml | 1 + xcresult/src/xcresult.rs | 75 +++++++++++++++++++++++++++++++++--- xcresult/tests/xcresult.rs | 79 +++++++++++++++++++++++++++++++++++++- 5 files changed, 176 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8d7412d..55c9eaf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7052,6 +7052,7 @@ dependencies = [ "syn 2.0.110", "tar", "temp_testdir", + "tempfile", "tracing", "tracing-subscriber", "typify", diff --git a/xcresult/CONTRIBUTING.md b/xcresult/CONTRIBUTING.md index d1cf69ca..b15e47c6 100644 --- a/xcresult/CONTRIBUTING.md +++ b/xcresult/CONTRIBUTING.md @@ -71,10 +71,26 @@ gets a file for the first time. per-test summary fetch — measured at 6 GB of JSON and a 48 GB peak footprint for a single timed-out test — is not reachable from it. -**Where it is worse.** Tests registered at runtime (Quick's `class_addMethod`, -`+testInvocations`) have no declaration to find; the two approaches fail in disjoint -situations. Such a test falls back to the modern API's own `sourceLocation`, vetted against -the same vendored-path rules as everything in `src/file_attribution.rs`. +**Where it is worse, and it is not a superset.** Tests registered at runtime (Quick's +`class_addMethod`, `+testInvocations`) have no declaration to find; the two approaches fail +in disjoint situations. Such a test falls back to the modern API's own `sourceLocation`, +vetted against the same vendored-path rules as everything in `src/file_attribution.rs`. + +That fallback is thinner than it looks: `sourceLocation` is in the modern schema +(`TestNode.sourceLocation`) but is emitted in **none** of the bundles in `tests/data/`, so +in practice it never fires and such a test gets no file at all — where the failure-summary +path, which reads the call stack this path never fetches, would name one. `generate_junits` +logs the split (`N from a declaration, N from the fallback, N unresolved`) so it is visible +whether the fallback ever fires against real bundles. + +The other way it can be worse is being confidently wrong rather than silent: the index is +built from a checkout scan, not the build log, so two same-named suites in different modules +collide. `declarations` is a `HashMap`, so the loser is simply +overwritten and nothing records that there was a choice. For codeowners a wrong file is +worse than no file, and the failure-summary path cannot make that mistake because it reads +the frame that actually ran. `nodeIdentifierURL` carries the target name +(`test://com.apple.xcode////`) and is already parsed for ids, +so ranking candidates by target is the obvious way to close this if it starts to matter. **Incompatible with `--use-experimental-failure-summary`,** which tunes a code path this one does not run, so clap rejects the pair rather than letting one silently win. One wart @@ -83,6 +99,13 @@ so `TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS=false` **and** `--use-experimental-failure-summary` is a hard conflict error. Unset the variable to roll back rather than setting it to `false`. +**Every read goes through a copy.** `xcresulttool` migrates a pre-`database.sqlite3` bundle +in place the first time it is read, which writes into a directory the uploader was only +asked to read, fails outright with `exit 64` when that directory is not writable, and makes +two concurrent readers of one bundle race. Both constructors copy the bundle into a +`TempDir` first and read that, so the caller's bundle is never touched. This is on the +shared path, so it applies to the default one too. + **Ids.** `generate_id` prefers `nodeIdentifierURL` here, which is the legacy record's `identifierURL` under another name, so ids do not move between the two paths. diff --git a/xcresult/Cargo.toml b/xcresult/Cargo.toml index b29668eb..45fa5459 100644 --- a/xcresult/Cargo.toml +++ b/xcresult/Cargo.toml @@ -24,6 +24,7 @@ quick-junit = "0.5.0" regex = "1.11.0" serde = { version = "1.0.215", default-features = false } serde_json = "1.0.133" +tempfile = "3.2.0" tracing = "0.1.41" uuid = { version = "1.10.0", features = ["v5"] } diff --git a/xcresult/src/xcresult.rs b/xcresult/src/xcresult.rs index 1a9433c1..ce5cea74 100644 --- a/xcresult/src/xcresult.rs +++ b/xcresult/src/xcresult.rs @@ -1,9 +1,11 @@ use std::collections::HashMap; use std::str; -use std::{fs, path::Path, time::Duration}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::{fs, path::Path, path::PathBuf, time::Duration}; use chrono::{DateTime, Utc}; use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestRerun, TestSuite}; +use tempfile::TempDir; use crate::file_attribution::ReportedPath; use crate::test_locations::{Limits, TestKey, TestLocationIndex}; @@ -25,6 +27,41 @@ pub enum FileAttribution { Declarations(TestLocationIndex), } +/// `xcresulttool` migrates an older bundle in place on first read, writing into a directory +/// we were only asked to read and failing outright when it is not writable. +fn copy_bundle(path: &Path) -> anyhow::Result<(TempDir, PathBuf)> { + fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let destination = to.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir(&entry.path(), &destination)?; + } else { + fs::copy(entry.path(), destination)?; + } + } + Ok(()) + } + + let temp_dir = TempDir::new()?; + let destination = temp_dir.path().join( + path.file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("bundle.xcresult")), + ); + copy_dir(path, &destination) + .map_err(|e| anyhow::anyhow!("failed to copy {} for reading: {}", path.display(), e))?; + Ok((temp_dir, destination)) +} + +/// Makes it visible whether the fallback ever fires; no bundle checked so far has one. +#[derive(Debug, Default)] +struct AttributionCounts { + declared: AtomicUsize, + fell_back: AtomicUsize, + unresolved: AtomicUsize, +} + #[derive(Debug)] pub struct XCResult { tests: Tests, @@ -32,6 +69,8 @@ pub struct XCResult { repo_full_name: String, attribution: FileAttribution, test_run_started_at: Option>, + counts: AttributionCounts, + _bundle_copy: TempDir, } impl XCResult { @@ -48,6 +87,7 @@ impl XCResult { e ) })?; + let (bundle_copy, absolute_path) = copy_bundle(&absolute_path)?; // Call xcresulttool_get_object once and use it for both timestamp extraction and legacy tests let actions_invocation_record = xcresulttool_get_object(&absolute_path); @@ -112,6 +152,8 @@ impl XCResult { org_url_slug, repo_full_name, test_run_started_at, + counts: AttributionCounts::default(), + _bundle_copy: bundle_copy, }) } @@ -133,6 +175,7 @@ impl XCResult { e ) })?; + let (bundle_copy, absolute_path) = copy_bundle(&absolute_path)?; let tests = xcresulttool_get_test_results_tests(&absolute_path)?; let test_run_started_at = match xcresulttool_get_test_results_summary(&absolute_path) { @@ -163,11 +206,14 @@ impl XCResult { org_url_slug, repo_full_name, test_run_started_at, + counts: AttributionCounts::default(), + _bundle_copy: bundle_copy, }) } pub fn generate_junits(&self) -> Vec { - self.tests + let reports: Vec = self + .tests .test_nodes .iter() .filter(|tn| matches!(tn.node_type, TestNodeType::TestPlan)) @@ -178,7 +224,16 @@ impl XCResult { )); report }) - .collect() + .collect(); + if matches!(self.attribution, FileAttribution::Declarations(_)) { + tracing::info!( + "xcresult test files: {} from a declaration, {} from the fallback, {} unresolved", + self.counts.declared.load(Ordering::Relaxed), + self.counts.fell_back.load(Ordering::Relaxed), + self.counts.unresolved.load(Ordering::Relaxed), + ); + } + reports } fn xcresult_test_bundles_and_suites_to_junit_test_suites( @@ -433,14 +488,22 @@ impl XCResult { site.file.as_str(), site.line.unwrap_or_default() ); + self.counts.declared.fetch_add(1, Ordering::Relaxed); return Some(site.file.as_str().to_owned()); } // A runtime-registered test (Quick, `+testInvocations`) has no declaration // to find, so fall back to where the failure surfaced. - first_source_location(test_case) + let fallback = first_source_location(test_case) .map(|path| ReportedPath::new(&path)) .filter(|path| !path.is_vendored_dependency()) - .map(ReportedPath::into_string) + .map(ReportedPath::into_string); + if fallback.is_some() { + self.counts.fell_back.fetch_add(1, Ordering::Relaxed); + } else { + self.counts.unresolved.fetch_add(1, Ordering::Relaxed); + tracing::debug!("no declaration and no source location for {node_identifier}"); + } + fallback } } } @@ -518,6 +581,8 @@ mod tests { repo_full_name: String::from("github.com/trunk-io/analytics-cli"), attribution, test_run_started_at: None, + counts: AttributionCounts::default(), + _bundle_copy: TempDir::new().unwrap(), }; let mut reports = xcresult.generate_junits(); assert_eq!(reports.len(), 1); diff --git a/xcresult/tests/xcresult.rs b/xcresult/tests/xcresult.rs index 23543bc3..9b036e3b 100644 --- a/xcresult/tests/xcresult.rs +++ b/xcresult/tests/xcresult.rs @@ -937,8 +937,7 @@ fn test_declaration_locations_give_a_passing_test_its_file() { // The flag is meant to move the `file` attribute and leave everything else alone, so every // bundle the suite reads is run both ways and compared on all of it but that. Each case -// unpacks its own copy: `xcresulttool` migrates a bundle in place on first read, and two -// concurrent readers of one freshly unpacked bundle race to create its `database.sqlite3`. +// unpacks its own copy so another test cannot perturb the comparison. #[cfg(target_os = "macos")] #[rstest] #[case::simple("tests/data/test1.xcresult.tar.gz", "test1.xcresult", None)] @@ -1065,3 +1064,79 @@ fn test_the_declaration_flag_moves_the_file_and_nothing_else( ); } } + +// Reading used to migrate the bundle in place, which failed when it was not writable. +#[cfg(target_os = "macos")] +#[test] +fn test_reading_a_bundle_neither_writes_to_it_nor_needs_it_writable() { + fn entries(dir: &Path) -> Vec { + let mut found = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + for entry in std::fs::read_dir(¤t).unwrap() { + let path = entry.unwrap().path(); + found.push(path.strip_prefix(dir).unwrap().display().to_string()); + if path.is_dir() { + stack.push(path); + } + } + } + found.sort(); + found + } + + fn set_writable(dir: &Path, writable: bool) { + let mut stack = vec![dir.to_path_buf()]; + let mut all = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + for entry in std::fs::read_dir(¤t).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + stack.push(path.clone()); + } + all.push(path); + } + } + // Directories have to come last on the way down and first on the way back up. + all.sort(); + if !writable { + all.reverse(); + } + for path in all { + let mode = if writable { 0o755 } else { 0o555 }; + std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(mode)) + .unwrap(); + } + } + + let temp_dir = unpack_archive_to_temp_dir("tests/data/test4.xcresult.tar.gz"); + let bundle = temp_dir.as_ref().join("test4.xcresult"); + let before = entries(&bundle); + assert!( + !before + .iter() + .any(|entry| entry.contains("database.sqlite3")), + "the fixture must start un-migrated for this to prove anything" + ); + + set_writable(&bundle, false); + let xcresult = XCResult::new( + bundle.to_str().unwrap(), + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + false, + ); + let read_only_result = xcresult.map(|xcresult| xcresult.generate_junits().len()); + set_writable(&bundle, true); + + assert_eq!( + read_only_result.map_err(|e| e.to_string()), + Ok(1), + "a read-only bundle must still be readable" + ); + pretty_assertions::assert_eq!( + entries(&bundle), + before, + "reading the bundle changed it on disk" + ); +} From b2a5e553b74cfa2d960a08e72c1b80518681c68e Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Mon, 31 Aug 2026 09:52:48 -0700 Subject: [PATCH 06/24] feat(xcresult): break a declaration collision with the target that ran the test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declaration index is built from a checkout scan rather than the build log, because reading a build log costs the legacy `get object` call this path exists to avoid. The trade was that two same-named suites in different modules both declare the same `(suite, case)`, and `declarations` is a `HashMap`, so whichever file the scan reached first won and nothing recorded that there had been a choice. Scan order is arbitrary, so that was a coin flip between two modules' files. It is the one way this path can be confidently wrong where the failure-summary path cannot, since that one reads the frame that actually ran — and for codeowners a wrong file is worse than no file at all. `nodeIdentifierURL` is `test://com.apple.xcode////`, so the target is already in hand from the field the ids are derived from. `record` now prefers a candidate lying under a directory named for that target, which needs no extra `xcresulttool` call and works for a passing test as well as a failing one — unlike anything derived from the failure, which a passing test does not have. Where no candidate is under the target, or the test has no target, the first file scanned still wins, so this is strictly a tie-break and never removes a file that would have been reported before. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/CONTRIBUTING.md | 19 +++-- xcresult/src/test_locations.rs | 143 ++++++++++++++++++++++++++++++--- xcresult/src/xcresult.rs | 8 +- 3 files changed, 151 insertions(+), 19 deletions(-) diff --git a/xcresult/CONTRIBUTING.md b/xcresult/CONTRIBUTING.md index b15e47c6..574a9e3e 100644 --- a/xcresult/CONTRIBUTING.md +++ b/xcresult/CONTRIBUTING.md @@ -83,14 +83,17 @@ path, which reads the call stack this path never fetches, would name one. `gener logs the split (`N from a declaration, N from the fallback, N unresolved`) so it is visible whether the fallback ever fires against real bundles. -The other way it can be worse is being confidently wrong rather than silent: the index is -built from a checkout scan, not the build log, so two same-named suites in different modules -collide. `declarations` is a `HashMap`, so the loser is simply -overwritten and nothing records that there was a choice. For codeowners a wrong file is -worse than no file, and the failure-summary path cannot make that mistake because it reads -the frame that actually ran. `nodeIdentifierURL` carries the target name -(`test://com.apple.xcode////`) and is already parsed for ids, -so ranking candidates by target is the obvious way to close this if it starts to matter. +**Collisions are broken by target.** The index is built from a checkout scan, not the build +log, so two same-named suites in different modules can both declare the same +`(suite, case)`. Scan order decides which is seen first and that order is arbitrary, so +`record` prefers a candidate lying under a directory named for the target that actually ran +the test — `nodeIdentifierURL` is +`test://com.apple.xcode////`, so the target is already in hand +from the field the ids come from, and no extra `xcresulttool` call is needed to get it. +Where no candidate is under the target, or the test has no target, the first file scanned +still wins. This matters because being confidently wrong is worse for codeowners than +reporting nothing, and it is the one way this path can be wrong where the failure-summary +path cannot, since that one reads the frame that actually ran. **Incompatible with `--use-experimental-failure-summary`,** which tunes a code path this one does not run, so clap rejects the pair rather than letting one silently win. One wart diff --git a/xcresult/src/test_locations.rs b/xcresult/src/test_locations.rs index b5ebe96f..8aba929f 100644 --- a/xcresult/src/test_locations.rs +++ b/xcresult/src/test_locations.rs @@ -74,6 +74,44 @@ impl TestKey { } } +impl TestKey { + /// `test://com.apple.xcode////` — the second component is + /// the test bundle, which is the only thing distinguishing two same-named suites. + pub fn target_from_identifier_url(identifier_url: &str) -> Option { + let path = identifier_url.split("://").nth(1)?; + let target = path.split('/').nth(2)?; + (!target.is_empty()).then(|| percent_decoded(target)) + } +} + +fn percent_decoded(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && let Some(byte) = value + .get(index + 1..index + 3) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()) + { + decoded.push(byte); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).unwrap_or_else(|_| value.to_string()) +} + +/// Whether a path lies under a directory named for the target, which is how a candidate is +/// tied to the module that actually ran it. +fn is_in_target(file: &ReportedPath, target: &str) -> bool { + Path::new(file.as_str()) + .components() + .any(|component| component.as_os_str().to_string_lossy() == target) +} + #[derive(Debug, Clone)] pub struct DeclarationSite { pub file: ReportedPath, @@ -84,10 +122,16 @@ pub struct DeclarationSite { pub struct TestLocationIndex { declarations: HashMap, supertypes: HashMap, + targets: HashMap, } impl TestLocationIndex { - pub fn resolve(repo_root: &Path, keys: &[TestKey], limits: Limits) -> Self { + pub fn resolve(repo_root: &Path, keys: &[(TestKey, Option)], limits: Limits) -> Self { + let targets = keys + .iter() + .filter_map(|(key, target)| target.clone().map(|target| (key.clone(), target))) + .collect::>(); + let keys = keys.iter().map(|(key, _)| key.clone()).collect::>(); let suites = keys .iter() .filter_map(|key| key.suite.as_deref()) @@ -98,8 +142,11 @@ impl TestLocationIndex { .partition::, _>(|path| has_extension(path, &SWIFT_EXTENSIONS)); let mut resolver = Resolver { - index: Self::default(), - unresolved: keys.to_vec(), + index: Self { + targets, + ..Self::default() + }, + unresolved: keys.clone(), deadline: Instant::now() + limits.budget, limits, }; @@ -156,6 +203,28 @@ impl TestLocationIndex { self } + fn record(&mut self, key: TestKey, candidate: DeclarationSite) { + match self.declarations.get(&key) { + None => { + self.declarations.insert(key, candidate); + } + Some(existing) => { + if let Some(target) = self.targets.get(&key) + && is_in_target(&candidate.file, target) + && !is_in_target(&existing.file, target) + { + tracing::debug!( + "preferring {} over {} for target {}", + candidate.file.as_str(), + existing.file.as_str(), + target + ); + self.declarations.insert(key, candidate); + } + } + } + } + fn collect( &mut self, symbols: &[DocumentSymbol], @@ -177,12 +246,11 @@ impl TestLocationIndex { suite: container.map(|name| container_name(name).to_string()), case: normalized_case(&symbol.name), }; - self.declarations - .entry(key) - .or_insert_with(|| DeclarationSite { - file: ReportedPath::new(&file.to_string_lossy()), - line: symbol.declaration_line(), - }); + let candidate = DeclarationSite { + file: ReportedPath::new(&file.to_string_lossy()), + line: symbol.declaration_line(), + }; + self.record(key, candidate); } self.collect(&symbol.children, file, text, Some(&symbol.name)); } @@ -651,4 +719,61 @@ mod tests { } assert_eq!(scan_sources(root.as_ref(), &HashSet::new(), 2).len(), 2); } + + #[rstest] + #[case::plain( + "test://com.apple.xcode/InRepoHelper/InRepoHelperTests/Suite/case()", + Some("InRepoHelperTests") + )] + #[case::percent_encoded( + "test://com.apple.xcode/swift%20testing/swift%20testing%20exampleTests/helloWorld()", + Some("swift testing exampleTests") + )] + #[case::too_short("test://com.apple.xcode/OnlyAScheme", None)] + #[case::not_a_url("Suite/case()", None)] + fn an_identifier_url_names_the_target(#[case] url: &str, #[case] expected: Option<&str>) { + assert_eq!( + TestKey::target_from_identifier_url(url).as_deref(), + expected + ); + } + + fn site(file: &str) -> DeclarationSite { + DeclarationSite { + file: ReportedPath::new(file), + line: None, + } + } + + fn recorded(target: Option<&str>, files: [&str; 2]) -> String { + let test_key = key(Some("FooTests"), "testThing"); + let mut index = TestLocationIndex { + targets: target + .map(|target| HashMap::from([(test_key.clone(), String::from(target))])) + .unwrap_or_default(), + ..Default::default() + }; + for file in files { + index.record(test_key.clone(), site(file)); + } + index.lookup(&test_key).unwrap().file.as_str().to_owned() + } + + const IN_TARGET: &str = "/repo/Tests/AppTests/FooTests.swift"; + const OTHER_TARGET: &str = "/repo/Tests/LibTests/FooTests.swift"; + + // Two modules can declare the same `(suite, case)`, and the scan order that decides it is + // arbitrary, so the target the test actually ran under has to break the tie. + #[rstest] + #[case::target_scanned_second(Some("AppTests"), [OTHER_TARGET, IN_TARGET], IN_TARGET)] + #[case::target_scanned_first(Some("AppTests"), [IN_TARGET, OTHER_TARGET], IN_TARGET)] + #[case::no_target_keeps_the_first(None, [OTHER_TARGET, IN_TARGET], OTHER_TARGET)] + #[case::no_candidate_matches(Some("TestsNobodyHas"), [OTHER_TARGET, IN_TARGET], OTHER_TARGET)] + fn a_collision_resolves_to_the_target_that_ran_the_test( + #[case] target: Option<&str>, + #[case] files: [&str; 2], + #[case] expected: &str, + ) { + assert_eq!(recorded(target, files), expected); + } } diff --git a/xcresult/src/xcresult.rs b/xcresult/src/xcresult.rs index ce5cea74..6d32ff9c 100644 --- a/xcresult/src/xcresult.rs +++ b/xcresult/src/xcresult.rs @@ -509,12 +509,16 @@ impl XCResult { } } -fn collect_test_keys(test_nodes: &[TestNode], keys: &mut Vec) { +fn collect_test_keys(test_nodes: &[TestNode], keys: &mut Vec<(TestKey, Option)>) { for test_node in test_nodes { if matches!(test_node.node_type, TestNodeType::TestCase) && let Some(node_identifier) = &test_node.node_identifier { - keys.push(TestKey::from_node_identifier(node_identifier)); + let target = test_node + .node_identifier_url + .as_deref() + .and_then(TestKey::target_from_identifier_url); + keys.push((TestKey::from_node_identifier(node_identifier), target)); } collect_test_keys(&test_node.children, keys); } From e95a0e9611d367b5f4d73f9561a529e174dd40b9 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Thu, 3 Sep 2026 13:16:52 -0700 Subject: [PATCH 07/24] fix(xcresult): attribute an inherited test to the suite that ran it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A test declared on a base class runs again under every concrete subclass, and the supertype chain reported the base class's file for both. The reported file is what codeowners are resolved from, so that handed the subclass's failures to whoever owns the base class — the misattribution `file_attribution` exists to prevent, one level up. The concrete suite chose to run the test, so it is the one reported. That deletes the chain, and with it the inheritance-clause regex: `superclass`, `SUPERCLASS`, `DECLARATION_HEAD_LINES`, the `supertypes` map and its cycle guard. Across three large iOS checkouts 99.7% of the edges it built pointed at `XCTestCase` or `NSObject`, which no declaration can ever resolve to. Where a failure surfaced is no longer a fallback either. It names the file the failure was raised in rather than the one the test is written in, so reporting it resolves the wrong codeowners; no file resolves none, which is recoverable. A test with no declaration to find is runtime-registered (Quick, `+testInvocations`) and now lands in the `unresolved` counter. Crates replace the hand-rolled protocol code: - `lsp-server` and `lsp-types` own the framing, method names and payload shapes. That also fixes a latent bug: the old `file_uri` emitted `file://tests/…` for a relative root, where `tests` parses as the authority and a path component is silently lost. `url::Url::from_file_path` encodes and absolutizes, and only the URI is absolute — reported paths are unchanged. - `ignore` walks the checkout. Its extensions are registered explicitly because the built-in Objective-C type maps `.h`, which no clang server can answer `documentSymbol` for on its own, and `SKIPPED_DIRECTORIES` stays as an override over `.gitignore`. `Limits` is settable per run, because the right values depend on the repo: the clang server answers around 9 files/s against sourcekit-lsp's ~180, so an Objective-C heavy checkout needs the budget and file cap well above the defaults. The budget is spent per server kind rather than across both, which a shared deadline let a large Swift tree exhaust before clangd started, and a server that stops answering is replaced rather than abandoned. Two fixtures cover the shapes involved. The Objective-C one earned its keep immediately: it caught the suite fallback being used as the condition for having resolved a test, which ended the scan at the first file naming a suite and would have collapsed every test to its suite's file. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 94 +++- cli/src/context.rs | 21 +- cli/src/upload_command.rs | 40 ++ constants/src/lib.rs | 11 + xcresult/Cargo.toml | 4 + xcresult/src/lsp.rs | 332 ++++++------- xcresult/src/main.rs | 43 +- xcresult/src/test_locations.rs | 448 +++++++++--------- xcresult/src/xcresult.rs | 53 +-- xcresult/tests/common/mod.rs | 82 ++++ .../data/test-inherited-test.xcresult.tar.gz | Bin 0 -> 34769 bytes .../data/test-objc-category.xcresult.tar.gz | Bin 0 -> 28762 bytes xcresult/tests/declaration_locations.rs | 90 ++++ xcresult/tests/fixture-src/README.md | 27 +- .../fixture-src/inherited-test/Package.swift | 10 + .../Tests/InheritedTestTests/BaseTests.swift | 10 + .../InheritedTestTests/ConcreteTests.swift | 6 + .../fixture-src/objc-category/Package.swift | 10 + .../ObjcCategoryTests+Extra.m | 20 + .../ObjcCategoryTests/ObjcCategoryTests.m | 5 + .../include/ObjcCategoryTestsExtra.h | 8 + xcresult/tests/fixture-src/regenerate.sh | 7 +- .../fixture-src/verify-test-structure.py | 24 +- xcresult/tests/xcresult.rs | 81 +--- 24 files changed, 902 insertions(+), 524 deletions(-) create mode 100644 xcresult/tests/common/mod.rs create mode 100644 xcresult/tests/data/test-inherited-test.xcresult.tar.gz create mode 100644 xcresult/tests/data/test-objc-category.xcresult.tar.gz create mode 100644 xcresult/tests/declaration_locations.rs create mode 100644 xcresult/tests/fixture-src/inherited-test/Package.swift create mode 100644 xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/BaseTests.swift create mode 100644 xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/ConcreteTests.swift create mode 100644 xcresult/tests/fixture-src/objc-category/Package.swift create mode 100644 xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/ObjcCategoryTests+Extra.m create mode 100644 xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/ObjcCategoryTests.m create mode 100644 xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/include/ObjcCategoryTestsExtra.h diff --git a/Cargo.lock b/Cargo.lock index 55c9eaf9..d6d6dc1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1452,6 +1452,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2203,6 +2212,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -2642,6 +2664,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3025,6 +3063,32 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-server" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee25a31f2e571e426eef2896179450cafc7e2f5be00d8a93b1c2d21c0ff7656" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "magnus" version = "0.8.2" @@ -4579,9 +4643,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5103,6 +5167,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -5462,6 +5537,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "0.1.2" @@ -7038,7 +7124,10 @@ dependencies = [ "constants", "context", "flate2", + "ignore", "lazy_static", + "lsp-server", + "lsp-types", "petgraph 0.7.1", "pretty_assertions", "prettyplease", @@ -7056,6 +7145,7 @@ dependencies = [ "tracing", "tracing-subscriber", "typify", + "url", "uuid", ] diff --git a/cli/src/context.rs b/cli/src/context.rs index 6e62cf24..ef5e1f79 100644 --- a/cli/src/context.rs +++ b/cli/src/context.rs @@ -6,7 +6,7 @@ use std::{ env, io::BufReader, path::Path, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use api::{client::ApiClient, message::CreateBundleUploadResponse}; @@ -139,6 +139,14 @@ pub fn gather_initial_test_context( use_experimental_failure_summary, #[cfg(target_os = "macos")] use_experimental_xcresult_test_locations, + #[cfg(target_os = "macos")] + xcresult_test_locations_max_files, + #[cfg(target_os = "macos")] + xcresult_test_locations_budget_secs, + #[cfg(target_os = "macos")] + xcresult_test_locations_request_timeout_secs, + #[cfg(target_os = "macos")] + xcresult_test_locations_retries, .. } = upload_args; @@ -160,6 +168,12 @@ pub fn gather_initial_test_context( repo_root: &repo.repo_root, use_experimental_failure_summary, use_experimental_test_locations: use_experimental_xcresult_test_locations, + limits: Limits { + max_files: xcresult_test_locations_max_files, + budget: Duration::from_secs(xcresult_test_locations_budget_secs), + request_timeout: Duration::from_secs(xcresult_test_locations_request_timeout_secs), + retries: xcresult_test_locations_retries, + }, }; let (junit_path_wrappers, bep_result, junit_path_wrappers_temp_dir) = @@ -636,6 +650,7 @@ struct XCResultOptions<'a> { repo_root: &'a str, use_experimental_failure_summary: bool, use_experimental_test_locations: bool, + limits: Limits, } fn coalesce_junit_path_wrappers( @@ -884,7 +899,7 @@ fn handle_xcresult( org_url_slug, repo_full_name, options.repo_root, - Limits::default(), + options.limits, )? } else { XCResult::new( @@ -1077,6 +1092,7 @@ mod tests { repo_root: "test", use_experimental_failure_summary: false, use_experimental_test_locations: false, + limits: xcresult::test_locations::Limits::default(), }; let result_err = coalesce_junit_path_wrappers( vec!["test".into()], @@ -1123,6 +1139,7 @@ mod tests { repo_root: "test", use_experimental_failure_summary: false, use_experimental_test_locations: false, + limits: xcresult::test_locations::Limits::default(), }; let result_ok = coalesce_junit_path_wrappers( Vec::new(), diff --git a/cli/src/upload_command.rs b/cli/src/upload_command.rs index 5413e778..403e13d3 100644 --- a/cli/src/upload_command.rs +++ b/cli/src/upload_command.rs @@ -295,6 +295,46 @@ pub struct UploadArgs { conflicts_with = "use_experimental_failure_summary" )] pub use_experimental_xcresult_test_locations: bool, + #[cfg(target_os = "macos")] + #[arg( + long, + env = constants::TRUNK_XCRESULT_TEST_LOCATIONS_MAX_FILES_ENV, + help = "Most source files to parse when resolving xcresult test declarations.", + required = false, + default_value_t = xcresult::test_locations::Limits::default().max_files, + hide = true + )] + pub xcresult_test_locations_max_files: usize, + #[cfg(target_os = "macos")] + #[arg( + long, + env = constants::TRUNK_XCRESULT_TEST_LOCATIONS_BUDGET_SECS_ENV, + help = "Seconds to spend per language server when resolving xcresult test declarations. The clang server answers far slower per file than the Swift one, so an Objective-C heavy repo wants this raised.", + required = false, + default_value_t = xcresult::test_locations::Limits::default().budget.as_secs(), + hide = true + )] + pub xcresult_test_locations_budget_secs: u64, + #[cfg(target_os = "macos")] + #[arg( + long, + env = constants::TRUNK_XCRESULT_TEST_LOCATIONS_REQUEST_TIMEOUT_SECS_ENV, + help = "Seconds to wait for a single language server reply before giving up on it.", + required = false, + default_value_t = xcresult::test_locations::Limits::default().request_timeout.as_secs(), + hide = true + )] + pub xcresult_test_locations_request_timeout_secs: u64, + #[cfg(target_os = "macos")] + #[arg( + long, + env = constants::TRUNK_XCRESULT_TEST_LOCATIONS_RETRIES_ENV, + help = "How many times to replace a language server that stops answering with a fresh one.", + required = false, + default_value_t = xcresult::test_locations::Limits::default().retries, + hide = true + )] + pub xcresult_test_locations_retries: usize, #[arg( long, env = constants::TRUNK_VALIDATION_REPORT_ENV, diff --git a/constants/src/lib.rs b/constants/src/lib.rs index 787a1c6d..95b1e836 100644 --- a/constants/src/lib.rs +++ b/constants/src/lib.rs @@ -59,6 +59,17 @@ pub const TRUNK_HIDE_TEST_COLLECTION_LINKS_ENV: &str = "TRUNK_HIDE_TEST_COLLECTI pub const TRUNK_DEBUG_ENV: &str = "TRUNK_DEBUG"; pub const TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS_ENV: &str = "TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS"; +// Tuning for the xcresult declaration path. The clang server answers roughly an order of +// magnitude slower per file than the Swift one, so an Objective-C heavy repo may need the +// budget and the file cap raised well above their defaults. +pub const TRUNK_XCRESULT_TEST_LOCATIONS_MAX_FILES_ENV: &str = + "TRUNK_XCRESULT_TEST_LOCATIONS_MAX_FILES"; +pub const TRUNK_XCRESULT_TEST_LOCATIONS_BUDGET_SECS_ENV: &str = + "TRUNK_XCRESULT_TEST_LOCATIONS_BUDGET_SECS"; +pub const TRUNK_XCRESULT_TEST_LOCATIONS_REQUEST_TIMEOUT_SECS_ENV: &str = + "TRUNK_XCRESULT_TEST_LOCATIONS_REQUEST_TIMEOUT_SECS"; +pub const TRUNK_XCRESULT_TEST_LOCATIONS_RETRIES_ENV: &str = "TRUNK_XCRESULT_TEST_LOCATIONS_RETRIES"; + // RSpec-only: when set to "true", aborts the RSpec run if quarantine lookup fails. // Handled in rspec-trunk-flaky-tests/lib/trunk_spec_helper.rb, not the CLI. pub const TRUNK_QUARANTINE_QUERY_FAILURE_EXIT_ENV: &str = "TRUNK_QUARANTINE_QUERY_FAILURE_EXIT"; diff --git a/xcresult/Cargo.toml b/xcresult/Cargo.toml index 45fa5459..df4bc422 100644 --- a/xcresult/Cargo.toml +++ b/xcresult/Cargo.toml @@ -17,7 +17,10 @@ chrono = "0.4.38" clap = { version = "4.4.18", features = ["derive", "env"] } constants = { path = "../constants" } context = { path = "../context", features = ["bindings"] } +ignore = "0.4.33" lazy_static = "1.5.0" +lsp-server = "0.10.0" +lsp-types = "0.97.0" tracing-subscriber = "0.3.19" petgraph = { version = "0.7.1", default-features = false } quick-junit = "0.5.0" @@ -26,6 +29,7 @@ serde = { version = "1.0.215", default-features = false } serde_json = "1.0.133" tempfile = "3.2.0" tracing = "0.1.41" +url = "2.5.7" uuid = { version = "1.10.0", features = ["v5"] } [dev-dependencies] diff --git a/xcresult/src/lsp.rs b/xcresult/src/lsp.rs index 3dc18596..fffdabb5 100644 --- a/xcresult/src/lsp.rs +++ b/xcresult/src/lsp.rs @@ -1,11 +1,15 @@ //! Just enough of the Language Server Protocol to ask a server what a file declares. //! -//! Once a request times out the stream cannot be resynchronised — a late reply would be -//! read as the answer to the *next* request — so the process is killed and later calls -//! refused, rather than an upload waiting on a server that stopped answering. +//! Framing and JSON-RPC come from [`lsp_server`], and every method name and payload shape +//! from [`lsp_types`], so a request is named by its type rather than by a string literal. +//! +//! A request that times out leaves a reply in flight that would arrive after the next +//! request was sent. Replies are matched by id, so a late one is discarded rather than +//! misread — but the server has also shown it cannot keep up, so it is killed and the +//! caller restarts it instead of waiting on it again. use std::{ - io::{BufRead, BufReader, Read, Write}, + io::{BufReader, Write}, path::Path, process::{Child, ChildStdin, Command, Stdio}, sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}, @@ -13,13 +17,21 @@ use std::{ time::{Duration, Instant}, }; -use serde_json::{Value, json}; +use lsp_server::{Message, Notification, Request, RequestId, Response}; +use lsp_types::{ + ClientCapabilities, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentSymbol, + DocumentSymbolClientCapabilities, DocumentSymbolParams, DocumentSymbolResponse, + InitializeParams, PartialResultParams, TextDocumentClientCapabilities, TextDocumentIdentifier, + TextDocumentItem, Uri, WorkDoneProgressParams, + notification::{DidCloseTextDocument, DidOpenTextDocument, Initialized}, + request::{DocumentSymbolRequest, Initialize}, +}; pub struct LanguageServer { process: Child, stdin: ChildStdin, - incoming: Receiver, - next_id: i64, + incoming: Receiver, + next_id: i32, broken: bool, } @@ -47,7 +59,7 @@ impl LanguageServer { .ok_or_else(|| anyhow::anyhow!("language server has no stdout"))?; let (sender, incoming) = channel(); - thread::spawn(move || read_messages(stdout, &sender)); + thread::spawn(move || read_messages(BufReader::new(stdout), &sender)); let mut server = Self { process, @@ -56,20 +68,27 @@ impl LanguageServer { next_id: 1, broken: false, }; - server.request( - "initialize", - json!({ - "processId": std::process::id(), - "rootUri": file_uri(root), - "capabilities": { - "textDocument": { - "documentSymbol": { "hierarchicalDocumentSymbolSupport": true } - } - } - }), + let root_uri = file_uri(root)?; + server.request::( + #[allow(deprecated)] // `root_uri` is how sourcekit-lsp still finds the workspace. + InitializeParams { + process_id: Some(std::process::id()), + root_uri: Some(root_uri), + capabilities: ClientCapabilities { + text_document: Some(TextDocumentClientCapabilities { + document_symbol: Some(DocumentSymbolClientCapabilities { + hierarchical_document_symbol_support: Some(true), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }, timeout, ); - server.notify("initialized", json!({})); + server.notify::(lsp_types::InitializedParams {}); if server.broken { return Err(anyhow::anyhow!( "language server did not complete initialize" @@ -86,85 +105,116 @@ impl LanguageServer { language_id: &str, text: &str, timeout: Duration, - ) -> Option { - let uri = file_uri(file_path); - self.notify( - "textDocument/didOpen", - json!({ - "textDocument": { - "uri": uri, - "languageId": language_id, - "version": 1, - "text": text - } - }), - ); - let symbols = self.request( - "textDocument/documentSymbol", - json!({ "textDocument": { "uri": uri } }), - timeout, - ); - self.notify( - "textDocument/didClose", - json!({ "textDocument": { "uri": uri } }), - ); - symbols + ) -> Option> { + let uri = file_uri(file_path).ok()?; + self.notify::(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: language_id.to_owned(), + version: 1, + text: text.to_owned(), + }, + }); + let response = self + .request::( + DocumentSymbolParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }, + timeout, + ) + .flatten(); + self.notify::(DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { uri }, + }); + match response { + Some(DocumentSymbolResponse::Nested(symbols)) => Some(symbols), + // Only a server that ignored `hierarchicalDocumentSymbolSupport` answers flat, + // and without nesting there is nothing to tie a method to its type. + Some(DocumentSymbolResponse::Flat(_)) => { + tracing::debug!("{} answered without hierarchy", file_path.display()); + None + } + None => None, + } } pub fn is_broken(&self) -> bool { self.broken } - fn request(&mut self, method: &str, params: Value, timeout: Duration) -> Option { + fn request( + &mut self, + params: R::Params, + timeout: Duration, + ) -> Option { if self.broken { return None; } - let id = self.next_id; + let id = RequestId::from(self.next_id); self.next_id += 1; - self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + let params = serde_json::to_value(params).ok()?; + self.send(Message::Request(Request { + id: id.clone(), + method: R::METHOD.to_owned(), + params, + })); let deadline = Instant::now() + timeout; loop { let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { - return self.abandon(method, "timed out"); + return self.abandon(R::METHOD, "timed out"); }; let message = match self.incoming.recv_timeout(remaining) { Ok(message) => message, - Err(RecvTimeoutError::Timeout) => return self.abandon(method, "timed out"), - Err(RecvTimeoutError::Disconnected) => return self.abandon(method, "exited"), + Err(RecvTimeoutError::Timeout) => return self.abandon(R::METHOD, "timed out"), + Err(RecvTimeoutError::Disconnected) => return self.abandon(R::METHOD, "exited"), }; - if message.get("id").and_then(Value::as_i64) == Some(id) { - if let Some(error) = message.get("error") { - tracing::debug!("language server refused {}: {}", method, error); - return None; + match message { + Message::Response(response) if response.id == id => { + return match response.response_result { + Ok(result) => serde_json::from_value(result).ok(), + Err(error) => { + tracing::debug!("language server refused {}: {:?}", R::METHOD, error); + None + } + }; } - return message.get("result").cloned(); - } - // sourcekit-lsp registers capabilities and asks for configuration during - // startup; a peer that never replies leaves those pending for its lifetime. - if let (Some(id), Some(_)) = (message.get("id"), message.get("method")) { - let id = id.clone(); - self.send(json!({ "jsonrpc": "2.0", "id": id, "result": Value::Null })); + // sourcekit-lsp registers capabilities and asks for configuration during + // startup; a peer that never replies leaves those pending for its lifetime. + Message::Request(request) => { + self.send(Message::Response(Response::new_ok( + request.id, + serde_json::Value::Null, + ))); + } + // A reply to a request we already gave up on, or a diagnostic we ignore. + Message::Response(_) | Message::Notification(_) => {} } } } - fn notify(&mut self, method: &str, params: Value) { + fn notify(&mut self, params: N::Params) { if self.broken { return; } - self.send(json!({ "jsonrpc": "2.0", "method": method, "params": params })); + let Ok(params) = serde_json::to_value(params) else { + return; + }; + self.send(Message::Notification(Notification { + method: N::METHOD.to_owned(), + params, + })); } - fn send(&mut self, message: Value) { - let body = message.to_string(); - let framed = format!("Content-Length: {}\r\n\r\n{}", body.len(), body); - if self.stdin.write_all(framed.as_bytes()).is_err() || self.stdin.flush().is_err() { - self.abandon("write", "closed its input"); + fn send(&mut self, message: Message) { + if message.write(&mut self.stdin).is_err() || self.stdin.flush().is_err() { + self.abandon::<()>("write", "closed its input"); } } - fn abandon(&mut self, method: &str, reason: &str) -> Option { + fn abandon(&mut self, method: &str, reason: &str) -> Option { if !self.broken { tracing::warn!( "language server {} during {}; abandoning it", @@ -185,36 +235,8 @@ impl Drop for LanguageServer { } } -fn read_messages(stdout: R, sender: &Sender) { - let mut reader = BufReader::new(stdout); - loop { - let mut content_length = None; - loop { - let mut line = String::new(); - match reader.read_line(&mut line) { - Ok(0) | Err(_) => return, - Ok(_) => {} - } - let line = line.trim_end(); - if line.is_empty() { - break; - } - if let Some((name, value)) = line.split_once(':') - && name.trim().eq_ignore_ascii_case("content-length") - { - content_length = value.trim().parse::().ok(); - } - } - let Some(content_length) = content_length else { - return; - }; - let mut body = vec![0_u8; content_length]; - if reader.read_exact(&mut body).is_err() { - return; - } - let Ok(message) = serde_json::from_slice::(&body) else { - return; - }; +fn read_messages(mut reader: R, sender: &Sender) { + while let Ok(Some(message)) = Message::read(&mut reader) { if sender.send(message).is_err() { return; } @@ -222,95 +244,57 @@ fn read_messages(stdout: R, sender: &Sender) { } /// A `file://` URI. A server that cannot parse the URI answers with no symbols rather -/// than an error, so a path with a space fails silently unless it is encoded here. -fn file_uri(path: &Path) -> String { - let mut uri = String::from("file://"); - for byte in path.to_string_lossy().bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { - uri.push(char::from(byte)); - } - _ => uri.push_str(&format!("%{byte:02X}")), - } - } - uri +/// than an error, so a path with a space fails silently unless it is encoded — and +/// `lsp_types::Uri` is a bare RFC 3986 parser that will not encode one for us. +/// +/// The path is made absolute first, because `file://` takes an authority: a relative +/// `file://Tests/Foo.swift` parses with `Tests` as the *host* and loses a path component. +/// Only the URI is absolute — the caller keeps reporting the path it was given, which is +/// what codeowners are resolved against. +fn file_uri(path: &Path) -> anyhow::Result { + // Lexical, so a symlinked checkout is not rewritten to somewhere the caller never named. + let absolute = std::path::absolute(path) + .map_err(|e| anyhow::anyhow!("cannot resolve {}: {e}", path.display()))?; + let url = url::Url::from_file_path(&absolute) + .map_err(|_| anyhow::anyhow!("not a usable file path: {}", absolute.display()))?; + url.as_str() + .parse::() + .map_err(|e| anyhow::anyhow!("{} is not a usable URI: {e}", url.as_str())) } #[cfg(test)] mod tests { - use std::io::Cursor; - - use rstest::rstest; - use super::*; - #[rstest] - #[case::plain("/repo/Tests/Test.swift", "file:///repo/Tests/Test.swift")] - #[case::space("/repo/Tests/My Test.swift", "file:///repo/Tests/My%20Test.swift")] - #[case::hash_is_a_uri_fragment("/repo/a#b.swift", "file:///repo/a%23b.swift")] - fn a_path_becomes_a_percent_encoded_uri(#[case] path: &str, #[case] expected: &str) { - assert_eq!(file_uri(Path::new(path)), expected); - } - - fn framed(bodies: &[&str]) -> String { - bodies - .iter() - .map(|body| format!("Content-Length: {}\r\n\r\n{}", body.len(), body)) - .collect() - } - + // A server that cannot parse the URI answers with no symbols rather than an error, so + // both of these fail silently in production if they regress. `lsp_types::Uri` will not + // encode for us and `file://` takes an authority, so neither is free. #[test] - fn framed_messages_are_read_back_in_order() { - let (sender, receiver) = channel(); - read_messages( - Cursor::new(framed(&[ - r#"{"id":1,"result":[]}"#, - r#"{"id":2,"result":7}"#, - ])), - &sender, - ); - drop(sender); - let received = receiver.iter().collect::>(); - assert_eq!(received.len(), 2); - assert_eq!(received[1]["result"], json!(7)); + fn a_path_with_a_space_is_percent_encoded() { + let uri = file_uri(Path::new("/repo/Tests/My Test.swift")).unwrap(); + assert_eq!(uri.as_str(), "file:///repo/Tests/My%20Test.swift"); } - // The frame carries a byte count, so a non-ASCII body split by character count - // drifts one message at a time and then hangs on the next read. #[test] - fn a_multibyte_body_is_framed_by_bytes() { - let (sender, receiver) = channel(); - read_messages( - Cursor::new(framed(&[r#"{"id":1,"result":"café"}"#])), - &sender, - ); - drop(sender); - assert_eq!( - receiver - .iter() - .next() - .map(|message| message["result"].clone()), - Some(json!("café")) - ); + fn a_hash_is_encoded_rather_than_starting_a_fragment() { + let uri = file_uri(Path::new("/repo/Tests/a#b.swift")).unwrap(); + assert_eq!(uri.as_str(), "file:///repo/Tests/a%23b.swift"); } + // A relative path would otherwise parse with its first component as the *host*, + // silently dropping it: `file://Tests/Foo.swift` is host `Tests`, path `/Foo.swift`. #[test] - fn a_lowercased_header_is_still_a_content_length() { - let (sender, receiver) = channel(); - let body = r#"{"id":1,"result":[]}"#; - read_messages( - Cursor::new(format!("content-length: {}\r\n\r\n{}", body.len(), body)), - &sender, + fn a_relative_path_becomes_an_absolute_uri() { + let uri = file_uri(Path::new("Tests/Foo.swift")).unwrap(); + assert!( + uri.as_str().starts_with("file:///"), + "expected an absolute file URI, got {}", + uri.as_str() + ); + assert!( + uri.as_str().ends_with("/Tests/Foo.swift"), + "expected the path to survive, got {}", + uri.as_str() ); - drop(sender); - assert_eq!(receiver.iter().count(), 1); - } - - #[test] - fn a_truncated_message_ends_the_stream_instead_of_blocking() { - let (sender, receiver) = channel(); - read_messages(Cursor::new("Content-Length: 40\r\n\r\n{\"id\":1}"), &sender); - drop(sender); - assert_eq!(receiver.iter().count(), 0); } } diff --git a/xcresult/src/main.rs b/xcresult/src/main.rs index 358528de..47535db6 100644 --- a/xcresult/src/main.rs +++ b/xcresult/src/main.rs @@ -1,4 +1,4 @@ -use std::{fs, io, path::PathBuf}; +use std::{fs, io, path::PathBuf, time::Duration}; use clap::Parser; use context::repo::RepoUrlParts; @@ -40,6 +40,36 @@ pub struct Cli { requires = "use_experimental_xcresult_test_locations" )] pub repo_root: Option, + /// Most source files to parse. Ranked so the likeliest declarations come first, and + /// parsing stops early once every test resolves. + #[arg( + long, + env = constants::TRUNK_XCRESULT_TEST_LOCATIONS_MAX_FILES_ENV, + default_value_t = Limits::default().max_files, + )] + pub xcresult_test_locations_max_files: usize, + /// Seconds to spend per language server. The clang server answers far slower per file + /// than the Swift one, so an Objective-C heavy repo wants this raised. + #[arg( + long, + env = constants::TRUNK_XCRESULT_TEST_LOCATIONS_BUDGET_SECS_ENV, + default_value_t = Limits::default().budget.as_secs(), + )] + pub xcresult_test_locations_budget_secs: u64, + /// Seconds to wait for a single language server reply before giving up on it. + #[arg( + long, + env = constants::TRUNK_XCRESULT_TEST_LOCATIONS_REQUEST_TIMEOUT_SECS_ENV, + default_value_t = Limits::default().request_timeout.as_secs(), + )] + pub xcresult_test_locations_request_timeout_secs: u64, + /// How many times to replace a language server that stops answering with a fresh one. + #[arg( + long, + env = constants::TRUNK_XCRESULT_TEST_LOCATIONS_RETRIES_ENV, + default_value_t = Limits::default().retries, + )] + pub xcresult_test_locations_retries: usize, } fn main() -> anyhow::Result<()> { @@ -55,6 +85,10 @@ fn main() -> anyhow::Result<()> { use_experimental_failure_summary, use_experimental_xcresult_test_locations, repo_root, + xcresult_test_locations_max_files, + xcresult_test_locations_budget_secs, + xcresult_test_locations_request_timeout_secs, + xcresult_test_locations_retries, } = Cli::parse(); let repo_url_parts = repo_url .and_then(|repo_url| RepoUrlParts::from_url(&repo_url).ok()) @@ -67,7 +101,12 @@ fn main() -> anyhow::Result<()> { org_url_slug, repo_full_name, repo_root.unwrap_or_else(|| PathBuf::from(".")), - Limits::default(), + Limits { + max_files: xcresult_test_locations_max_files, + budget: Duration::from_secs(xcresult_test_locations_budget_secs), + request_timeout: Duration::from_secs(xcresult_test_locations_request_timeout_secs), + retries: xcresult_test_locations_retries, + }, )? } else { XCResult::new( diff --git a/xcresult/src/test_locations.rs b/xcresult/src/test_locations.rs index 8aba929f..56758b81 100644 --- a/xcresult/src/test_locations.rs +++ b/xcresult/src/test_locations.rs @@ -9,15 +9,20 @@ use std::{ time::{Duration, Instant}, }; -use lazy_static::lazy_static; -use serde::Deserialize; +use ignore::{WalkBuilder, types::TypesBuilder}; +use lsp_types::{DocumentSymbol, SymbolKind}; use crate::{file_attribution::ReportedPath, lsp::LanguageServer, xcrun::xcrun_find}; -/// LSP `SymbolKind`s that can declare a test: Method, Constructor, Function. -const METHOD_KINDS: [u64; 3] = [6, 9, 12]; -/// Kinds that can contain one: Class, Interface (an Objective-C category), Struct. -const CONTAINER_KINDS: [u64; 3] = [5, 11, 23]; +/// Kinds that can declare a test. +const METHOD_KINDS: [SymbolKind; 3] = [ + SymbolKind::METHOD, + SymbolKind::CONSTRUCTOR, + SymbolKind::FUNCTION, +]; +/// Kinds that can contain one. `INTERFACE` is how an Objective-C category arrives. +const CONTAINER_KINDS: [SymbolKind; 3] = + [SymbolKind::CLASS, SymbolKind::INTERFACE, SymbolKind::STRUCT]; const SWIFT_EXTENSIONS: [&str; 1] = ["swift"]; const CLANG_EXTENSIONS: [&str; 5] = ["m", "mm", "c", "cc", "cpp"]; @@ -36,11 +41,18 @@ const SKIPPED_DIRECTORIES: [&str; 9] = [ "Pods", ]; +/// Every field is settable from the CLI, because the right value depends on the repo: the +/// clang server answers roughly an order of magnitude slower per file than the Swift one, +/// so an Objective-C heavy checkout needs more of all of them than these defaults give. #[derive(Debug, Clone, Copy)] pub struct Limits { pub max_files: usize, + /// Spent per server kind rather than across both, so a large Swift tree cannot leave + /// the clang server with nothing left to parse Objective-C in. pub budget: Duration, pub request_timeout: Duration, + /// How many times a server that stops answering is replaced with a fresh one. + pub retries: usize, } impl Default for Limits { @@ -49,6 +61,7 @@ impl Default for Limits { max_files: 2_000, budget: Duration::from_secs(60), request_timeout: Duration::from_secs(30), + retries: 1, } } } @@ -121,7 +134,8 @@ pub struct DeclarationSite { #[derive(Debug, Default)] pub struct TestLocationIndex { declarations: HashMap, - supertypes: HashMap, + /// Where each suite is declared, for a test that its own suite does not declare. + suites: HashMap, targets: HashMap, } @@ -147,7 +161,6 @@ impl TestLocationIndex { ..Self::default() }, unresolved: keys.clone(), - deadline: Instant::now() + limits.budget, limits, }; resolver.parse(&swift, &SOURCEKIT_LSP, repo_root); @@ -162,27 +175,32 @@ impl TestLocationIndex { resolver.index } - /// A test can be declared on a base class and run under a subclass. + /// The file the test is written in, preferring the declaration of the method itself — + /// a suite split across extensions declares each test in its own file. + /// + /// A test run under a suite that does not declare it is inherited from a base class, + /// and reporting the base class would hand the test to whoever owns *that* file. The + /// concrete suite is the one that chose to run it, so it is the one reported. pub fn lookup(&self, key: &TestKey) -> Option<&DeclarationSite> { - let mut suite = key.suite.clone(); - let mut seen = HashSet::new(); - while let Some(current) = suite { - if !seen.insert(current.clone()) { - break; - } - let inherited = TestKey { - suite: Some(current.clone()), - case: key.case.clone(), - }; - if let Some(site) = self.declarations.get(&inherited) { - return Some(site); - } - suite = self.supertypes.get(¤t).cloned(); + if let Some(site) = self.method_declaration(key) { + return Some(site); + } + match key.suite.as_ref() { + Some(suite) => self.suites.get(suite), + // A top-level swift-testing test has no suite, so the function is all there is. + None => None, } - self.declarations.get(&TestKey { - suite: None, - case: key.case.clone(), - }) + } + + /// Only the method's own declaration, which is what decides whether there is still + /// something worth parsing for. + /// + /// Resolution stops once nothing is left to find, so it cannot be driven by + /// [`Self::lookup`]: a suite is usually declared in the first file ranked for it, and + /// counting that as an answer would end the scan before the file the *method* is + /// declared in was ever read — collapsing every test to its suite's file. + fn method_declaration(&self, key: &TestKey) -> Option<&DeclarationSite> { + self.declarations.get(key) } pub fn is_empty(&self) -> bool { @@ -225,34 +243,29 @@ impl TestLocationIndex { } } - fn collect( - &mut self, - symbols: &[DocumentSymbol], - file: &Path, - text: &str, - container: Option<&str>, - ) { + fn collect(&mut self, symbols: &[DocumentSymbol], file: &Path, container: Option<&str>) { for symbol in symbols { + let site = || DeclarationSite { + file: ReportedPath::new(&file.to_string_lossy()), + line: declaration_line(symbol), + }; if CONTAINER_KINDS.contains(&symbol.kind) { - let name = container_name(&symbol.name); - if let Some(supertype) = superclass(text, &symbol.range) - && supertype != name - { - self.supertypes.entry(name.to_string()).or_insert(supertype); - } + // An Objective-C category is reported against the class it extends, which + // is already declared elsewhere, so the first declaration seen wins. + self.suites + .entry(container_name(&symbol.name).to_string()) + .or_insert_with(site); } if METHOD_KINDS.contains(&symbol.kind) { let key = TestKey { suite: container.map(|name| container_name(name).to_string()), case: normalized_case(&symbol.name), }; - let candidate = DeclarationSite { - file: ReportedPath::new(&file.to_string_lossy()), - line: symbol.declaration_line(), - }; - self.record(key, candidate); + self.record(key, site()); + } + if let Some(children) = symbol.children.as_deref() { + self.collect(children, file, Some(&symbol.name)); } - self.collect(&symbol.children, file, text, Some(&symbol.name)); } } } @@ -279,11 +292,16 @@ const CLANGD: ServerKind = ServerKind { struct Resolver { index: TestLocationIndex, unresolved: Vec, - deadline: Instant, limits: Limits, } impl Resolver { + /// Parse `files` with one `kind` of server, replacing it when it stops answering. + /// + /// A server that times out is killed rather than resynchronised, so the only way to + /// carry on is a fresh one. The file that broke it is skipped instead of retried: it + /// is the reason the last server died, and retrying it would spend the whole budget + /// re-earning the same timeout. fn parse(&mut self, files: &[PathBuf], kind: &ServerKind, root: &Path) { if files.is_empty() || self.unresolved.is_empty() { return; @@ -296,44 +314,78 @@ impl Resolver { ); return; }; - let mut server = - match LanguageServer::start(&program, kind.args, root, self.limits.request_timeout) { - Ok(server) => server, - Err(e) => { - tracing::warn!("failed to start {}: {}", kind.program, e); - return; - } - }; + let deadline = Instant::now() + self.limits.budget; + let mut remaining = files; let mut parsed = 0; - for file in files { - if self.unresolved.is_empty() || server.is_broken() { + for attempt in 0..=self.limits.retries { + if remaining.is_empty() || self.unresolved.is_empty() || Instant::now() >= deadline { break; } - if Instant::now() >= self.deadline { + if attempt > 0 { tracing::warn!( - "{}: out of time after {} file(s), {} left unparsed", + "{}: restarting it, {} file(s) left to parse", kind.program, - parsed, - files.len() - parsed + remaining.len() ); - break; } - let Ok(text) = fs::read_to_string(file) else { - continue; - }; - let Some(response) = - server.document_symbols(file, kind.language_id, &text, self.limits.request_timeout) - else { - continue; - }; - parsed += 1; - match serde_json::from_value::>(response) { - Ok(symbols) => self.index.collect(&symbols, file, &text, None), - Err(e) => tracing::debug!("unusable symbols for {}: {}", file.display(), e), + let mut server = + match LanguageServer::start(&program, kind.args, root, self.limits.request_timeout) + { + Ok(server) => server, + Err(e) => { + tracing::warn!("failed to start {}: {}", kind.program, e); + return; + } + }; + + let mut consumed = 0; + for file in remaining { + consumed += 1; + if self.unresolved.is_empty() { + break; + } + if Instant::now() >= deadline { + tracing::warn!( + "{}: out of time after {} file(s), {} left unparsed", + kind.program, + parsed, + files.len() - parsed + ); + return; + } + let Ok(text) = fs::read_to_string(file) else { + continue; + }; + let symbols = server.document_symbols( + file, + kind.language_id, + &text, + self.limits.request_timeout, + ); + if server.is_broken() { + break; + } + let Some(symbols) = symbols else { + continue; + }; + parsed += 1; + self.index.collect(&symbols, file, None); + let index = &self.index; + self.unresolved + .retain(|key| index.method_declaration(key).is_none()); } - let index = &self.index; - self.unresolved.retain(|key| index.lookup(key).is_none()); + remaining = &remaining[consumed.min(remaining.len())..]; + if !server.is_broken() { + break; + } + } + if !remaining.is_empty() && !self.unresolved.is_empty() { + tracing::warn!( + "{}: gave up with {} file(s) unparsed", + kind.program, + remaining.len() + ); } tracing::debug!( "{}: parsed {} of {} file(s)", @@ -344,60 +396,10 @@ impl Resolver { } } -#[derive(Debug, Deserialize)] -struct DocumentSymbol { - name: String, - kind: u64, - range: Range, - #[serde(rename = "selectionRange")] - selection_range: Option, - #[serde(default)] - children: Vec, -} - -impl DocumentSymbol { - /// LSP counts lines from zero; everything downstream counts from one. - fn declaration_line(&self) -> Option { - let range = self.selection_range.as_ref().unwrap_or(&self.range); - u32::try_from(range.start.line) - .ok() - .map(|line| line.saturating_add(1)) - } -} - -#[derive(Debug, Deserialize)] -struct Range { - start: Position, - end: Position, -} - -#[derive(Debug, Deserialize)] -struct Position { - line: u64, -} - -lazy_static! { - // `\b` sits inside the alternation: before `@interface` it would demand a word - // character ahead of the `@` and never match a declaration starting a line. - static ref SUPERCLASS: regex::Regex = - regex::Regex::new(r"(?:\bclass|@interface)\s+\w+\s*:\s*([A-Za-z_]\w*)").unwrap(); -} - -/// Enough to carry an inheritance clause, so a large class body is never searched. -const DECLARATION_HEAD_LINES: usize = 5; - -fn superclass(text: &str, range: &Range) -> Option { - let span = (range.end.line.saturating_sub(range.start.line) as usize).saturating_add(1); - let head = text - .lines() - .skip(range.start.line as usize) - .take(span.min(DECLARATION_HEAD_LINES)) - .collect::>() - .join("\n"); - SUPERCLASS - .captures(&head) - .and_then(|captures| captures.get(1)) - .map(|name| name.as_str().to_string()) +/// LSP counts lines from zero; everything downstream counts from one. +fn declaration_line(symbol: &DocumentSymbol) -> Option { + let range = symbol.selection_range; + range.start.line.checked_add(1) } /// Applied to identifier and symbol alike: Swift spells it `testExample()`, Objective-C @@ -422,31 +424,43 @@ fn has_extension(path: &Path, extensions: &[&str]) -> bool { /// Files most likely to pay off first: a suite is overwhelmingly declared in a file named /// after it, and parsing stops once every test resolves. Symlinked directories are skipped. +/// +/// The extensions are registered explicitly rather than taken from `ignore`'s built-in +/// definitions, because those map `.h` to Objective-C and a header is not something the +/// clang server can answer `documentSymbol` for on its own. +/// +/// `.gitignore` already excludes most of [`SKIPPED_DIRECTORIES`] in a normal checkout, but +/// nothing guarantees it, so the list stays as an override on top. fn scan_sources(repo_root: &Path, suites: &HashSet<&str>, max_files: usize) -> Vec { - let mut found = Vec::new(); - let mut stack = vec![repo_root.to_path_buf()]; - while let Some(directory) = stack.pop() { - let Ok(entries) = fs::read_dir(&directory) else { - continue; - }; - for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - if file_type.is_dir() { - if !SKIPPED_DIRECTORIES.contains(&entry.file_name().to_string_lossy().as_ref()) { - stack.push(entry.path()); - } - } else if file_type.is_file() { - let path = entry.path(); - if has_extension(&path, &SWIFT_EXTENSIONS) - || has_extension(&path, &CLANG_EXTENSIONS) - { - found.push(path); - } - } - } - } + let mut types = TypesBuilder::new(); + for extension in SWIFT_EXTENSIONS.iter().chain(CLANG_EXTENSIONS.iter()) { + // `add` only fails on a malformed glob, and these are built from literals. + let _ = types.add("sources", &format!("*.{extension}")); + } + types.select("sources"); + let Ok(types) = types.build() else { + return Vec::new(); + }; + + let walker = WalkBuilder::new(repo_root) + .types(types) + .hidden(false) + .follow_links(false) + .filter_entry(|entry| { + entry.depth() == 0 + || !SKIPPED_DIRECTORIES.contains(&entry.file_name().to_string_lossy().as_ref()) + }) + .build(); + + let mut found = walker + .flatten() + .filter(|entry| { + entry + .file_type() + .is_some_and(|file_type| file_type.is_file()) + }) + .map(|entry| entry.into_path()) + .collect::>(); found.sort_by_cached_key(|path| (rank(path, suites), path.clone())); found.truncate(max_files); found @@ -489,27 +503,35 @@ mod tests { serde_json::from_value(value).unwrap() } + fn span(from: u64, to: u64) -> Value { + json!({ + "start": { "line": from, "character": 0 }, + "end": { "line": to, "character": 0 } + }) + } + fn method(name: &str, line: u64) -> Value { json!({ "name": name, - "kind": 6, - "range": { "start": { "line": line }, "end": { "line": line } }, - "selectionRange": { "start": { "line": line }, "end": { "line": line } } + "kind": SymbolKind::METHOD, + "range": span(line, line), + "selectionRange": span(line, line) }) } - fn container(name: &str, kind: u64, lines: (u64, u64), children: Vec) -> Value { + fn container(name: &str, kind: SymbolKind, lines: (u64, u64), children: Vec) -> Value { json!({ "name": name, "kind": kind, - "range": { "start": { "line": lines.0 }, "end": { "line": lines.1 } }, + "range": span(lines.0, lines.1), + "selectionRange": span(lines.0, lines.0), "children": children }) } - fn indexed(file: &str, text: &str, value: Value) -> TestLocationIndex { + fn indexed(file: &str, value: Value) -> TestLocationIndex { let mut index = TestLocationIndex::default(); - index.collect(&symbols(value), Path::new(file), text, None); + index.collect(&symbols(value), Path::new(file), None); index } @@ -563,33 +585,13 @@ mod tests { ); } - #[test] - fn a_method_is_recorded_against_the_type_declaring_it() { - let index = indexed( - SWIFT_FILE, - "final class SnapshotReproTests: XCTestCase {\n func testExample() {}\n}", - json!([container( - "SnapshotReproTests", - 5, - (0, 2), - vec![method("testExample()", 1)] - )]), - ); - let site = index - .lookup(&key(Some("SnapshotReproTests"), "testExample")) - .expect("the test's own declaration"); - assert_eq!(site.file.as_str(), SWIFT_FILE); - assert_eq!(site.line, Some(2)); - } - #[test] fn a_category_records_against_the_class_it_extends() { let index = indexed( "/repo/Tests/ObjcXCTestTests+Extra.m", - "@interface ObjcXCTestTests (ExtraTests)\n- (void)testExample;\n@end", json!([container( "ObjcXCTestTests(ExtraTests)", - 11, + SymbolKind::INTERFACE, (0, 2), vec![method("-testExample", 1)] )]), @@ -601,40 +603,60 @@ mod tests { ); } + // The run reports the subclass, but only the base class declares the method. The + // reported file is what codeowners resolve from, so the test belongs to the concrete + // suite that chose to run it, not to whoever owns the base class. #[test] - fn a_top_level_test_is_found_without_a_suite() { - let index = indexed( - "/repo/Tests/TopLevel.swift", - "@Test func failingSnapshot() {}", - json!([method("failingSnapshot()", 0)]), - ); - assert!(index.lookup(&key(None, "failingSnapshot")).is_some()); - } - - // The run reports the subclass, but only the base class file declares the method. - #[test] - fn a_test_inherited_from_a_base_class_resolves_to_the_base_class_file() { + fn an_inherited_test_resolves_to_the_concrete_suites_file() { let mut index = indexed( "/repo/Tests/BaseTests.swift", - "class BaseTests: XCTestCase {\n func testInherited() {}\n}", json!([container( "BaseTests", - 5, + SymbolKind::CLASS, (0, 2), vec![method("testInherited()", 1)] )]), ); index.collect( - &symbols(json!([container("SubclassTests", 5, (0, 0), vec![])])), + &symbols(json!([container( + "SubclassTests", + SymbolKind::CLASS, + (0, 0), + vec![] + )])), Path::new("/repo/Tests/SubclassTests.swift"), - "final class SubclassTests: BaseTests {}", None, ); assert_eq!( index .lookup(&key(Some("SubclassTests"), "testInherited")) .map(|site| site.file.as_str().to_owned()), - Some(String::from("/repo/Tests/BaseTests.swift")) + Some(String::from("/repo/Tests/SubclassTests.swift")) + ); + } + + // Resolution stops when nothing is left to find, so if the suite fallback counted as + // an answer the scan would end at the first file naming the suite and never read the + // one declaring the method. The two lookups have to disagree here. + #[test] + fn the_suite_fallback_does_not_count_as_a_resolved_declaration() { + let index = indexed( + SWIFT_FILE, + json!([container( + "SnapshotReproTests", + SymbolKind::CLASS, + (0, 2), + vec![] + )]), + ); + let key = key(Some("SnapshotReproTests"), "testExample"); + assert!( + index.method_declaration(&key).is_none(), + "the method is declared nowhere yet, so there is still work to do" + ); + assert!( + index.lookup(&key).is_some(), + "but the suite is known, so a file can still be reported for it" ); } @@ -642,10 +664,9 @@ mod tests { fn an_unrelated_suite_does_not_borrow_another_suites_case() { let index = indexed( SWIFT_FILE, - "final class SnapshotReproTests: XCTestCase {\n func testExample() {}\n}", json!([container( "SnapshotReproTests", - 5, + SymbolKind::CLASS, (0, 2), vec![method("testExample()", 1)] )]), @@ -657,31 +678,6 @@ mod tests { ); } - // A cycle would otherwise be walked forever; `typealias`ed bases produce one. - #[test] - fn a_cyclic_superclass_chain_terminates() { - let mut index = TestLocationIndex::default(); - index - .supertypes - .insert(String::from("A"), String::from("B")); - index - .supertypes - .insert(String::from("B"), String::from("A")); - assert!(index.lookup(&key(Some("A"), "testExample")).is_none()); - } - - #[rstest] - #[case::swift("final class SubclassTests: BaseTests {", Some("BaseTests"))] - #[case::objc("@interface SubclassTests : BaseTests", Some("BaseTests"))] - #[case::no_inheritance_clause("struct PlainTests {", None)] - fn a_declaration_head_yields_its_supertype(#[case] text: &str, #[case] expected: Option<&str>) { - let range = Range { - start: Position { line: 0 }, - end: Position { line: 0 }, - }; - assert_eq!(superclass(text, &range).as_deref(), expected); - } - #[test] fn the_scan_ranks_suite_named_files_first_and_skips_vendored_directories() { let root = TempDir::default(); diff --git a/xcresult/src/xcresult.rs b/xcresult/src/xcresult.rs index 6d32ff9c..3e717f46 100644 --- a/xcresult/src/xcresult.rs +++ b/xcresult/src/xcresult.rs @@ -7,7 +7,6 @@ use chrono::{DateTime, Utc}; use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestRerun, TestSuite}; use tempfile::TempDir; -use crate::file_attribution::ReportedPath; use crate::test_locations::{Limits, TestKey, TestLocationIndex}; use crate::types::{ SWIFT_DEFAULT_TEST_SUITE_NAME, @@ -54,11 +53,10 @@ fn copy_bundle(path: &Path) -> anyhow::Result<(TempDir, PathBuf)> { Ok((temp_dir, destination)) } -/// Makes it visible whether the fallback ever fires; no bundle checked so far has one. +/// Makes it visible how many tests the checkout could not account for. #[derive(Debug, Default)] struct AttributionCounts { declared: AtomicUsize, - fell_back: AtomicUsize, unresolved: AtomicUsize, } @@ -227,9 +225,8 @@ impl XCResult { .collect(); if matches!(self.attribution, FileAttribution::Declarations(_)) { tracing::info!( - "xcresult test files: {} from a declaration, {} from the fallback, {} unresolved", + "xcresult test files: {} from a declaration, {} with no declaration found", self.counts.declared.load(Ordering::Relaxed), - self.counts.fell_back.load(Ordering::Relaxed), self.counts.unresolved.load(Ordering::Relaxed), ); } @@ -491,19 +488,13 @@ impl XCResult { self.counts.declared.fetch_add(1, Ordering::Relaxed); return Some(site.file.as_str().to_owned()); } - // A runtime-registered test (Quick, `+testInvocations`) has no declaration - // to find, so fall back to where the failure surfaced. - let fallback = first_source_location(test_case) - .map(|path| ReportedPath::new(&path)) - .filter(|path| !path.is_vendored_dependency()) - .map(ReportedPath::into_string); - if fallback.is_some() { - self.counts.fell_back.fetch_add(1, Ordering::Relaxed); - } else { - self.counts.unresolved.fetch_add(1, Ordering::Relaxed); - tracing::debug!("no declaration and no source location for {node_identifier}"); - } - fallback + // Where a failure surfaced is not where the test is written, and reporting + // it hands the test to whoever owns that file. No file at all resolves no + // codeowners, which is recoverable; the wrong file is not. A test with no + // declaration to find is runtime-registered (Quick, `+testInvocations`). + self.counts.unresolved.fetch_add(1, Ordering::Relaxed); + tracing::debug!("no declaration in the checkout for {node_identifier}"); + None } } } @@ -524,13 +515,6 @@ fn collect_test_keys(test_nodes: &[TestNode], keys: &mut Vec<(TestKey, Option Option { - if let Some(source_location) = &test_node.source_location { - return Some(source_location.file_path.clone()); - } - test_node.children.iter().find_map(first_source_location) -} - #[cfg(test)] mod tests { use rstest::rstest; @@ -824,15 +808,13 @@ mod tests { ); } - // With no declaration to find — a runtime-registered test — the raised-at location is - // all there is, and a vendored one must still be refused rather than reported. + // A runtime-registered test (Quick, `+testInvocations`) has no declaration anywhere + // in the checkout. Where its failure surfaced is not where it is written, and the + // reported file is what codeowners resolve from, so nothing is reported at all. #[rstest] - #[case::in_repo_helper_is_better_than_nothing(HELPER_FILE, Some(HELPER_FILE))] - #[case::vendored_dependency_is_refused(DEPENDENCY_FILE, None)] - fn an_unresolved_test_falls_back_to_the_raised_at_location( - #[case] raised_in: &str, - #[case] expected: Option<&str>, - ) { + #[case::in_repo_helper(HELPER_FILE)] + #[case::vendored_dependency(DEPENDENCY_FILE)] + fn an_unresolved_test_is_reported_with_no_file(#[case] raised_in: &str) { let tests = bundle( "ExampleTests", vec![suite( @@ -852,9 +834,8 @@ mod tests { FileAttribution::Declarations(TestLocationIndex::default()) ), "a calculator, fails on purpose()" - ) - .as_deref(), - expected + ), + None ); } } diff --git a/xcresult/tests/common/mod.rs b/xcresult/tests/common/mod.rs new file mode 100644 index 00000000..9e52c809 --- /dev/null +++ b/xcresult/tests/common/mod.rs @@ -0,0 +1,82 @@ +//! Harness shared by the integration test binaries in this directory. +//! +//! Each file under `tests/` is its own crate, so anything two of them need lives here +//! rather than being written twice. `tests/common/mod.rs` is a module rather than +//! `tests/common.rs`, which cargo would build and run as a third test binary. + +use std::{fs::File, path::Path}; + +use context::repo::RepoUrlParts; +use flate2::read::GzDecoder; +use lazy_static::lazy_static; +use tar::Archive; +use temp_testdir::TempDir; +#[cfg(target_os = "macos")] +use xcresult::{test_locations::Limits, xcresult::XCResult}; + +/// The bundles are checked in as tarballs, so a test reads one by unpacking it into a +/// temporary directory that is removed with the `TempDir`. +pub fn unpack_archive_to_temp_dir>(archive_file_path: T) -> TempDir { + let file = File::open(archive_file_path).unwrap(); + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let temp_dir = TempDir::default(); + if let Err(e) = archive.unpack(temp_dir.as_ref()) { + panic!("failed to unpack data.tar.gz: {}", e); + } + temp_dir +} + +lazy_static! { + pub static ref ORG_URL_SLUG: String = String::from("trunk"); + pub static ref REPO_FULL_NAME: String = RepoUrlParts { + host: "github.com".to_string(), + owner: "trunk-io".to_string(), + name: "analytics-cli".to_string() + } + .repo_full_name(); +} + +/// Read a bundle the way `--use-experimental-xcresult-test-locations` does: each test's +/// file comes from where it is declared in `repo_root`, not from a failure. +#[cfg(target_os = "macos")] +pub fn declaration_report, U: AsRef>( + bundle_path: T, + repo_root: U, +) -> quick_junit::Report { + let xcresult = XCResult::new_with_declaration_locations( + bundle_path.as_ref().to_str().unwrap(), + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + repo_root.as_ref(), + Limits::default(), + ) + .expect("the declaration path reads the bundle"); + + let mut junits = xcresult.generate_junits(); + assert_eq!(junits.len(), 1); + junits.pop().unwrap() +} + +/// Case name -> reported file. Only safe where case names are unique across the bundle; +/// where two suites run a case of the same name, key on the suite as well. +#[cfg(target_os = "macos")] +pub fn declaration_files, U: AsRef>( + bundle_path: T, + repo_root: U, +) -> std::collections::HashMap { + declaration_report(bundle_path, repo_root) + .test_suites + .iter() + .flat_map(|test_suite| test_suite.test_cases.iter()) + .map(|test_case| { + let file = test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "file") + .map(|(_, value)| value.as_str().to_owned()) + .unwrap_or_default(); + (test_case.name.as_str().to_owned(), file) + }) + .collect() +} diff --git a/xcresult/tests/data/test-inherited-test.xcresult.tar.gz b/xcresult/tests/data/test-inherited-test.xcresult.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..9e38e38e7d38f93728f95e8947325d0e725ec819 GIT binary patch literal 34769 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 literal 0 HcmV?d00001 diff --git a/xcresult/tests/data/test-objc-category.xcresult.tar.gz b/xcresult/tests/data/test-objc-category.xcresult.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..b37977fac547ca30fcedde3d2243f324c3680244 GIT binary patch literal 28762 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 (LpY0~Ygq39I9ac&WLCh`L$_JeSQIKEv7+ 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%0DxRx-#jgypfTd$iB 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 PU%>wWm=K-E09ph9mcsb7 literal 0 HcmV?d00001 diff --git a/xcresult/tests/declaration_locations.rs b/xcresult/tests/declaration_locations.rs new file mode 100644 index 00000000..c4329a53 --- /dev/null +++ b/xcresult/tests/declaration_locations.rs @@ -0,0 +1,90 @@ +//! Where the declaration path attributes a test whose own suite does not declare it. +//! +//! The rest of the declaration-path coverage lives in `xcresult.rs` alongside the +//! failure-summary tests it is compared against. These two are separate because their +//! fixtures exist only to pin *which* declaration is reported when more than one file +//! could plausibly answer. + +mod common; + +use common::unpack_archive_to_temp_dir; +use lazy_static::lazy_static; +use temp_testdir::TempDir; + +lazy_static! { + static ref TEMP_DIR_TEST_INHERITED_TEST: TempDir = + unpack_archive_to_temp_dir("tests/data/test-inherited-test.xcresult.tar.gz"); + static ref TEMP_DIR_TEST_OBJC_CATEGORY: TempDir = + unpack_archive_to_temp_dir("tests/data/test-objc-category.xcresult.tar.gz"); +} + +// XCTest runs a base class's `test*` method again under every concrete subclass, so the +// same method arrives twice under two different suites. Both raise the failure at the same +// line of `BaseTests.swift`, and `ConcreteTests.swift` is named nowhere in the bundle — so +// this only passes if the file comes from the suite that ran the test. Reporting the base +// class would hand `ConcreteTests`' failures to whoever owns `BaseTests.swift`, which is +// the misattribution the declaration path exists to prevent. +#[cfg(target_os = "macos")] +#[test] +fn test_an_inherited_test_is_attributed_to_the_suite_that_ran_it() { + let report = common::declaration_report( + TEMP_DIR_TEST_INHERITED_TEST + .as_ref() + .join("InheritedTest.xcresult"), + "tests/fixture-src/inherited-test", + ); + // Both suites run a case of the same name, so the suite has to be part of the key. + let files = report + .test_suites + .iter() + .flat_map(|test_suite| { + test_suite.test_cases.iter().map(move |test_case| { + ( + format!("{}/{}", test_suite.name.as_str(), test_case.name.as_str()), + test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "file") + .map(|(_, value)| value.as_str().to_owned()), + ) + }) + }) + .collect::>(); + + for (suite, expected) in [ + ("BaseTests", "BaseTests.swift"), + ("ConcreteTests", "ConcreteTests.swift"), + ] { + let key = format!("InheritedTestTests.{suite}/testInheritedFails()"); + let file = files + .get(&key) + .unwrap_or_else(|| panic!("{key} is missing from the report (found {files:?})")) + .as_deref() + .unwrap_or_else(|| panic!("{key} got no file from its declaration")); + assert!( + file.ends_with(expected), + "expected {key} to be attributed to {expected}, got {file}" + ); + } +} + +// A category's `documentSymbol` container is `ObjcCategoryTests(Extra)`, and the class's +// own file declares no tests at all — so if that name is not read back as the class it +// extends, the declaration is never matched and the file falls to the class's file. +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_resolve_a_test_declared_in_an_objc_category() { + let files = common::declaration_files( + TEMP_DIR_TEST_OBJC_CATEGORY + .as_ref() + .join("ObjcCategory.xcresult"), + "tests/fixture-src/objc-category", + ); + let file = files + .get("testDeclaredInACategory") + .unwrap_or_else(|| panic!("the test is missing from the report (found {files:?})")); + assert!( + file.ends_with("ObjcCategoryTests+Extra.m"), + "expected the category's file, got {file}" + ); +} diff --git a/xcresult/tests/fixture-src/README.md b/xcresult/tests/fixture-src/README.md index dc33c9ed..388fe44e 100644 --- a/xcresult/tests/fixture-src/README.md +++ b/xcresult/tests/fixture-src/README.md @@ -62,18 +62,21 @@ table below, and only then save the new output over `../data/test-*.junit.xml`. ## What each scenario must exhibit `verify-failure-summaries.py` enforces the "captured shape" column and fails the -regeneration if a bundle stops reproducing it. `nested-and-passing` is checked by -`verify-test-structure.py` instead: its shape is the result tree itself, which no -failure summary describes. - -| Scenario | Captured shape | Expected `file` (experimental) | Expected `file` (legacy) | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------- | -| `dependency-raises-failure` | A swift-testing test calls a dependency helper that records the issue at its own `#filePath`. `fileName`, the source code context's location and the innermost frame are all under `SourcePackages/checkouts/`; only the test's own frame names the test file. | the test's own file | _(none)_ | -| `in-repo-helper-raises-failure` | Same, with the helper in the test target. Nothing rejects the helper's path, so the test's own frame has to win on its own merits. | the test's own file | the helper's file | -| `crash-in-dependency` | Two tests that never reach their own frame: one `fatalError`s inside the dependency (Xcode records a summary with no file at all), and one is failed by the dependency's `TestScoping` trait after its body returned (every file source is a checkout path). | _(none)_ | _(none)_ | -| `objc-xctest` | An Objective-C `XCTestCase` whose failure is raised by `XCTFail` in a shared category in another file, so the frame is symbolicated as `-[ObjcXCTestTests testFailsInsideSharedHelper]`. | the test's own file | _(none)_ | -| `toplevel-swift-testing` | A top-level `@Test func` with no suite, failed by a helper in another file, so the frame is the bare function name. | the test's own file | the helper's file | -| `nested-and-passing` | A `@Suite` nested inside another `@Suite`, declared in a separate file, plus three passing tests. Before the flattening fix the inner suite was never visited, so its two tests — and its failure — vanished from the JUnit. Passing tests have no failure summary, so only a declaration can name their file. | the file each test is declared in | the failing test's own file _(none for the passing three)_ | +regeneration if a bundle stops reproducing it. `nested-and-passing`, `inherited-test` +and `objc-category` are checked by `verify-test-structure.py` instead: their shape is +the result tree itself — which suite ran which test — and no failure summary +describes that. + +| Scenario | Captured shape | Expected `file` (experimental) | Expected `file` (legacy) | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------- | +| `dependency-raises-failure` | A swift-testing test calls a dependency helper that records the issue at its own `#filePath`. `fileName`, the source code context's location and the innermost frame are all under `SourcePackages/checkouts/`; only the test's own frame names the test file. | the test's own file | _(none)_ | +| `in-repo-helper-raises-failure` | Same, with the helper in the test target. Nothing rejects the helper's path, so the test's own frame has to win on its own merits. | the test's own file | the helper's file | +| `crash-in-dependency` | Two tests that never reach their own frame: one `fatalError`s inside the dependency (Xcode records a summary with no file at all), and one is failed by the dependency's `TestScoping` trait after its body returned (every file source is a checkout path). | _(none)_ | _(none)_ | +| `objc-xctest` | An Objective-C `XCTestCase` whose failure is raised by `XCTFail` in a shared category in another file, so the frame is symbolicated as `-[ObjcXCTestTests testFailsInsideSharedHelper]`. | the test's own file | _(none)_ | +| `toplevel-swift-testing` | A top-level `@Test func` with no suite, failed by a helper in another file, so the frame is the bare function name. | the test's own file | the helper's file | +| `nested-and-passing` | A `@Suite` nested inside another `@Suite`, declared in a separate file, plus three passing tests. Before the flattening fix the inner suite was never visited, so its two tests — and its failure — vanished from the JUnit. Passing tests have no failure summary, so only a declaration can name their file. | the file each test is declared in | the failing test's own file _(none for the passing three)_ | +| `inherited-test` | One `test*` method declared only on a base class, so XCTest runs it twice — as `BaseTests/testInheritedFails` and again as `ConcreteTests/testInheritedFails`. Both raise the failure at the same line of `BaseTests.swift`, which is what makes the bundle discriminate: the concrete suite's own file is named nowhere in the result. | the file of the suite that ran it | `BaseTests.swift` for both | +| `objc-category` | An Objective-C test method declared in a category rather than in its class's own file. `documentSymbol` names the container `ObjcCategoryTests(Extra)`, and unless that is read back as the class it extends the declaration is never matched at all. | the category's file | _(none)_ | The two columns differ because only the experimental path reads the failure summary's call stack, which is the only source that can identify the test's own diff --git a/xcresult/tests/fixture-src/inherited-test/Package.swift b/xcresult/tests/fixture-src/inherited-test/Package.swift new file mode 100644 index 00000000..e5296046 --- /dev/null +++ b/xcresult/tests/fixture-src/inherited-test/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "InheritedTest", + platforms: [.macOS(.v13)], + targets: [ + .testTarget(name: "InheritedTestTests") + ] +) diff --git a/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/BaseTests.swift b/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/BaseTests.swift new file mode 100644 index 00000000..248ba029 --- /dev/null +++ b/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/BaseTests.swift @@ -0,0 +1,10 @@ +import XCTest + +// XCTest runs every `test*` method it finds on a concrete subclass, including the ones +// only this base class declares. So `testInheritedFails` runs twice: once as +// `BaseTests/testInheritedFails`, and once as `ConcreteTests/testInheritedFails`. +class BaseTests: XCTestCase { + func testInheritedFails() { + XCTFail("declared on the base class, run under whichever suite reported it") + } +} diff --git a/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/ConcreteTests.swift b/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/ConcreteTests.swift new file mode 100644 index 00000000..a6911beb --- /dev/null +++ b/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/ConcreteTests.swift @@ -0,0 +1,6 @@ +import XCTest + +// Declares no tests of its own. The file reported for `ConcreteTests/testInheritedFails` +// has to be this one: it is what codeowners resolve from, and this is the suite that chose +// to run the test, not whoever owns `BaseTests.swift`. +final class ConcreteTests: BaseTests {} diff --git a/xcresult/tests/fixture-src/objc-category/Package.swift b/xcresult/tests/fixture-src/objc-category/Package.swift new file mode 100644 index 00000000..5b50db2f --- /dev/null +++ b/xcresult/tests/fixture-src/objc-category/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "ObjcCategory", + platforms: [.macOS(.v13)], + targets: [ + .testTarget(name: "ObjcCategoryTests") + ] +) diff --git a/xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/ObjcCategoryTests+Extra.m b/xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/ObjcCategoryTests+Extra.m new file mode 100644 index 00000000..0291c4eb --- /dev/null +++ b/xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/ObjcCategoryTests+Extra.m @@ -0,0 +1,20 @@ +#import + +#import "include/ObjcCategoryTestsExtra.h" + +// The include is spelled relative to this file rather than relying on the `-I include` +// the build passes: `documentSymbol` is answered with no build settings at all, and a +// header clangd cannot open leaves the container named `<>(Extra)` — the +// class name is simply gone, and the declaration can never be matched. +// +// A category adds the method to `ObjcCategoryTests`, so the run reports it as +// `ObjcCategoryTests/testDeclaredInACategory` while it is declared here. +// `documentSymbol` names the container `ObjcCategoryTests(Extra)`, which has to be read +// back as the class it extends for the declaration to be found at all. +@implementation ObjcCategoryTests (Extra) + +- (void)testDeclaredInACategory { + XCTFail(@"declared in an Objective-C category, not in the class's own file"); +} + +@end diff --git a/xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/ObjcCategoryTests.m b/xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/ObjcCategoryTests.m new file mode 100644 index 00000000..b69710a8 --- /dev/null +++ b/xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/ObjcCategoryTests.m @@ -0,0 +1,5 @@ +#import "include/ObjcCategoryTestsExtra.h" + +// Declares no tests. Every test on this suite comes from the category in the other file. +@implementation ObjcCategoryTests +@end diff --git a/xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/include/ObjcCategoryTestsExtra.h b/xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/include/ObjcCategoryTestsExtra.h new file mode 100644 index 00000000..ad500245 --- /dev/null +++ b/xcresult/tests/fixture-src/objc-category/Tests/ObjcCategoryTests/include/ObjcCategoryTestsExtra.h @@ -0,0 +1,8 @@ +#import + +@interface ObjcCategoryTests : XCTestCase +@end + +@interface ObjcCategoryTests (Extra) +- (void)testDeclaredInACategory; +@end diff --git a/xcresult/tests/fixture-src/regenerate.sh b/xcresult/tests/fixture-src/regenerate.sh index 7ce42cc9..0efd69fd 100755 --- a/xcresult/tests/fixture-src/regenerate.sh +++ b/xcresult/tests/fixture-src/regenerate.sh @@ -25,6 +25,8 @@ ALL_SCENARIOS=( objc-xctest toplevel-swift-testing nested-and-passing + inherited-test + objc-category ) # scenario -> the package name, which is both the xcodebuild scheme prefix and the @@ -37,6 +39,8 @@ package_name() { objc-xctest) echo ObjcXCTest ;; toplevel-swift-testing) echo ToplevelSwiftTesting ;; nested-and-passing) echo NestedAndPassing ;; + inherited-test) echo InheritedTest ;; + objc-category) echo ObjcCategory ;; *) echo "unknown scenario: $1" >&2 exit 1 @@ -115,7 +119,8 @@ regenerate() { return 1 } - if [[ ${scenario} == nested-and-passing ]]; then + if [[ ${scenario} == nested-and-passing || ${scenario} == inherited-test || + ${scenario} == objc-category ]]; then "${FIXTURE_SRC_DIR}/verify-test-structure.py" "${scenario}" "${bundle}" else "${FIXTURE_SRC_DIR}/verify-failure-summaries.py" "${scenario}" "${dump}" diff --git a/xcresult/tests/fixture-src/verify-test-structure.py b/xcresult/tests/fixture-src/verify-test-structure.py index 7bf2dcf6..87d480f4 100755 --- a/xcresult/tests/fixture-src/verify-test-structure.py +++ b/xcresult/tests/fixture-src/verify-test-structure.py @@ -2,8 +2,9 @@ """Assert that a captured .xcresult exhibits the *structure* it was captured for. The sibling `verify-failure-summaries.py` covers scenarios whose shape is a failure -raised somewhere other than the test. Neither a suite nested in a suite nor a test -that simply passed is visible in a failure summary at all. +raised somewhere other than the test. What these scenarios are captured for is the +result tree itself — a suite nested in a suite, a test that simply passed, or which +suite ran a test it does not declare — and no failure summary describes any of that. ./verify-test-structure.py """ @@ -23,6 +24,20 @@ ], "failing": ["OuterSuite/InnerSuite/innerFails()"], }, + # The same method has to arrive twice, under both suites, or there is no inherited + # test to attribute and the fixture proves nothing. + "inherited-test": { + "passing": [], + "failing": [ + "BaseTests/testInheritedFails()", + "ConcreteTests/testInheritedFails()", + ], + }, + # Declared in a category, so the run names a suite whose own file declares nothing. + "objc-category": { + "passing": [], + "failing": ["ObjcCategoryTests/testDeclaredInACategory"], + }, } @@ -55,7 +70,7 @@ def main(): by_identifier = {case.get("nodeIdentifier"): case for case in cases} failures = [] - if expected["nested_suite"] not in suites: + if "nested_suite" in expected and expected["nested_suite"] not in suites: failures.append( f"expected a nested suite {expected['nested_suite']}, found {suites}" ) @@ -83,8 +98,9 @@ def main(): sys.exit(1) print( - f"{scenario}: verified a nested suite and " + f"{scenario}: verified {len(expected['failing'])} failing and " f"{len(expected['passing'])} passing test(s)" + + (" plus a nested suite" if "nested_suite" in expected else "") ) diff --git a/xcresult/tests/xcresult.rs b/xcresult/tests/xcresult.rs index 9b036e3b..232f6510 100644 --- a/xcresult/tests/xcresult.rs +++ b/xcresult/tests/xcresult.rs @@ -1,25 +1,17 @@ -use std::{fs::File, path::Path}; +use std::path::Path; -use context::repo::RepoUrlParts; -use flate2::read::GzDecoder; use lazy_static::lazy_static; use rstest::rstest; -use tar::Archive; use temp_testdir::TempDir; #[cfg(target_os = "macos")] use xcresult::test_locations::Limits; use xcresult::xcresult::XCResult; -fn unpack_archive_to_temp_dir>(archive_file_path: T) -> TempDir { - let file = File::open(archive_file_path).unwrap(); - let decoder = GzDecoder::new(file); - let mut archive = Archive::new(decoder); - let temp_dir = TempDir::default(); - if let Err(e) = archive.unpack(temp_dir.as_ref()) { - panic!("failed to unpack data.tar.gz: {}", e); - } - temp_dir -} +mod common; + +use common::{ORG_URL_SLUG, REPO_FULL_NAME, unpack_archive_to_temp_dir}; +#[cfg(target_os = "macos")] +use common::{declaration_files, declaration_report}; lazy_static! { static ref TEMP_DIR_TEST_1: TempDir = @@ -52,13 +44,6 @@ lazy_static! { unpack_archive_to_temp_dir("tests/data/test-timestamp.xcresult.tar.gz"); static ref TEMP_DIR_TEST_VARIANT: TempDir = unpack_archive_to_temp_dir("tests/data/test-variant.xcresult.tar.gz"); - static ref ORG_URL_SLUG: String = String::from("trunk"); - static ref REPO_FULL_NAME: String = RepoUrlParts { - host: "github.com".to_string(), - owner: "trunk-io".to_string(), - name: "analytics-cli".to_string() - } - .repo_full_name(); } #[cfg(target_os = "macos")] @@ -644,50 +629,6 @@ fn test_xcresult_with_variant_id_generation() { } } -// The declaration path (`--use-experimental-xcresult-test-locations`) resolves each test -// against the checkout rather than a failure, so its expectation is the file the test is -// *written in* — which for these two fixtures is exactly the file the failure-summary -// paths cannot name, because the failure is raised in a helper. -#[cfg(target_os = "macos")] -fn declaration_report, U: AsRef>( - bundle_path: T, - repo_root: U, -) -> quick_junit::Report { - let xcresult = XCResult::new_with_declaration_locations( - bundle_path.as_ref().to_str().unwrap(), - ORG_URL_SLUG.clone(), - REPO_FULL_NAME.clone(), - repo_root.as_ref(), - Limits::default(), - ) - .expect("the declaration path reads the bundle"); - - let mut junits = xcresult.generate_junits(); - assert_eq!(junits.len(), 1); - junits.pop().unwrap() -} - -#[cfg(target_os = "macos")] -fn declaration_files, U: AsRef>( - bundle_path: T, - repo_root: U, -) -> std::collections::HashMap { - declaration_report(bundle_path, repo_root) - .test_suites - .iter() - .flat_map(|test_suite| test_suite.test_cases.iter()) - .map(|test_case| { - let file = test_case - .extra - .iter() - .find(|(key, _)| key.as_str() == "file") - .map(|(_, value)| value.as_str().to_owned()) - .unwrap_or_default(); - (test_case.name.as_str().to_owned(), file) - }) - .collect() -} - // Every file this bundle's failure summary offers is under `SourcePackages/checkouts/`. #[cfg(target_os = "macos")] #[test] @@ -992,6 +933,16 @@ fn test_declaration_locations_give_a_passing_test_its_file() { "NestedAndPassing.xcresult", Some("tests/fixture-src/nested-and-passing") )] +#[case::inherited_test( + "tests/data/test-inherited-test.xcresult.tar.gz", + "InheritedTest.xcresult", + Some("tests/fixture-src/inherited-test") +)] +#[case::objc_category( + "tests/data/test-objc-category.xcresult.tar.gz", + "ObjcCategory.xcresult", + Some("tests/fixture-src/objc-category") +)] fn test_the_declaration_flag_moves_the_file_and_nothing_else( #[case] archive: &str, #[case] bundle: &str, From 6194484d2417bc8b963d30218f469177311152c2 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Thu, 3 Sep 2026 14:16:12 -0700 Subject: [PATCH 08/24] test(xcresult): test the public surface, and delete what fixtures already prove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline tests were carrying 1033 lines across five modules. Sorting them by why they were inline rather than by where they sat: `file_attribution`'s 22 tests only ever touched public API — `ReportedPath`, `TestIdentity::is_named_by`, `FileCandidate::from_failure_summary` and `from_issue_summary` are all `pub` — so they move to `tests/` verbatim. The one exception called the private `stack_frames`; with no `fileName` and no location every candidate offered is a stack frame, so it reaches the same filtering through `from_failure_summary` and asserts the provenance too. `xcresult`'s tests observed only public output but built their input by struct literal over private fields, and five of them turned out to duplicate coverage that already exists against real bundles: - `nested_suites_are_flattened` and `a_passing_test_case_has_no_file` are both encoded in `test-nested-and-passing.junit.xml`, which is compared byte for byte — the three passing cases carry no `file` attribute and the failing one does. - `a_failure_raised_elsewhere` is the two `prefer_the_tests_own_file_over_*` tests. - `a_nested_test_case_is_attributed` is `test_declaration_locations_give_a_passing_test_its_file` plus the id comparison in the flag-parity test. - `a_category_records_against_the_class_it_extends` and `an_inherited_test_resolves_to_the_concrete_suites_file` are the two fixtures added earlier on this branch, which prove the same thing from a real bundle rather than from hand-written symbol JSON. The sixth is expressible without the private seam: run the declaration path against a checkout that declares nothing, and no test gets a file at all. That leaves `TestLocationIndex::declaring` unused — the `#[cfg(test)]` seam for seeding an index without a language server — so it is deleted. Nothing was made public to get here. What stays inline, and why it cannot move: - `xcresult_legacy` reaches three private associated functions, and its public entry points read a bundle, so its fifteen-case precedence tables would need fifteen `.xcresult` fixtures. - `test_locations` asserts on `TestKey`'s private fields, and the rest are negative cases or private pure functions with no capturable fixture. - `lsp`'s three assertions cover `file_uri`, where both failure modes are silent: a server that cannot parse a URI answers with no symbols. One coverage loss worth naming: the `Skipped` and `Expected Failure` statuses are no longer pinned. `Passed` and `Failed` are covered by real bundles, and `find_test_case_file` keys on `node_identifier` without inspecting `result`. xcresult.rs 841 -> 516 lines, 0 inline tests file_attribution.rs 468 -> 271 lines, 0 inline tests inline test lines 1033 -> 447 Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/src/file_attribution.rs | 197 -------------- xcresult/src/test_locations.rs | 82 ------ xcresult/src/xcresult.rs | 325 ------------------------ xcresult/tests/declaration_locations.rs | 56 ++++ xcresult/tests/file_attribution.rs | 196 ++++++++++++++ 5 files changed, 252 insertions(+), 604 deletions(-) create mode 100644 xcresult/tests/file_attribution.rs diff --git a/xcresult/src/file_attribution.rs b/xcresult/src/file_attribution.rs index 6c8cc44e..e8a17e07 100644 --- a/xcresult/src/file_attribution.rs +++ b/xcresult/src/file_attribution.rs @@ -269,200 +269,3 @@ fn stack_frames(failure_summary: &legacy_schema::ActionTestFailureSummary) -> Ve .rev() .collect() } - -#[cfg(test)] -mod tests { - use rstest::rstest; - use serde_json::{Value, json}; - - use super::*; - - const SUITE: &str = "SnapshotReproTests"; - const CASE: &str = "failingSnapshot()"; - - fn xc_string(value: &str) -> Value { - json!({ "_value": value }) - } - - fn failure_summary( - file_name: Option<&str>, - location: Option<&str>, - stack: &[(&str, &str)], - ) -> legacy_schema::ActionTestFailureSummary { - serde_json::from_value(json!({ - "fileName": file_name.map(xc_string), - "sourceCodeContext": { - "location": { "filePath": location.map(xc_string) }, - "callStack": { "_values": stack.iter().map(|(symbol, path)| json!({ - "symbolInfo": { - "symbolName": xc_string(symbol), - "location": { "filePath": xc_string(path) } - } - })).collect::>() } - } - })) - .unwrap() - } - - fn identity() -> TestIdentity<'static> { - TestIdentity { - suite: Some(SUITE), - case: CASE, - } - } - - #[rstest] - #[case::spaces_are_encoded("/repo/Tests/My Test.swift", "/repo/Tests/My%20Test.swift")] - #[case::already_safe("/repo/Tests/Test.swift", "/repo/Tests/Test.swift")] - fn reported_path_normalizes_once(#[case] path: &str, #[case] expected: &str) { - assert_eq!(ReportedPath::new(path).as_str(), expected); - } - - #[rstest] - #[case::tuist_checkout("/repo/Tuist/.build/checkouts/Dep/Dep.swift", true)] - #[case::derived_data("/repo/DerivedData/SourcePackages/checkouts/Dep/Dep.swift", true)] - #[case::the_repos_own_code("/repo/Tests/SnapshotReproTests.swift", false)] - fn reported_path_recognizes_vendored_sources(#[case] path: &str, #[case] expected: bool) { - assert_eq!(ReportedPath::new(path).is_vendored_dependency(), expected); - } - - #[rstest] - #[case::swift_symbol("SnapshotReproTests.failingSnapshot()", true)] - #[case::objc_symbol("-[SnapshotReproTests failingSnapshot]", true)] - #[case::closure_inside_test("closure #1 in SnapshotReproTests.failingSnapshot()", true)] - #[case::helper_the_test_called("assertSnapshot(of:as:)", false)] - #[case::same_case_name_in_another_suite("OtherTests.failingSnapshot()", false)] - #[case::trait_that_invoked_the_test( - "closure #1 in _SnapshotsTestTrait.provideScope(for:testCase:performing:)", - false - )] - fn identity_recognizes_only_the_tests_own_frame(#[case] symbol: &str, #[case] expected: bool) { - assert_eq!(identity().is_named_by(symbol), expected); - } - - #[rstest] - #[case::top_level_swift_testing_function("failingSnapshot()", true)] - #[case::closure_inside_it("closure #1 in failingSnapshot()", true)] - #[case::suite_scoped_symbol("SnapshotReproTests.failingSnapshot()", false)] - fn a_suiteless_test_is_matched_by_its_bare_function( - #[case] symbol: &str, - #[case] expected: bool, - ) { - let identity = TestIdentity { - suite: None, - case: CASE, - }; - assert_eq!(identity.is_named_by(symbol), expected); - } - - #[test] - fn candidates_are_offered_in_preference_order_and_keep_their_provenance() { - let summary = failure_summary( - Some("/repo/Tests/Raised.swift"), - Some("/repo/Tests/Location.swift"), - &[ - ("helper()", "/repo/Tests/Inner.swift"), - ( - "SnapshotReproTests.failingSnapshot()", - "/repo/Tests/Own.swift", - ), - ("framework()", "/repo/Tests/Outer.swift"), - ], - ); - assert_eq!( - FileCandidate::from_failure_summary(&summary, &identity()) - .iter() - .map(|candidate| (candidate.path.as_str(), candidate.source)) - .collect::>(), - vec![ - ("/repo/Tests/Own.swift", FileSource::TestFrame), - ("/repo/Tests/Raised.swift", FileSource::RaisedFrom), - ("/repo/Tests/Location.swift", FileSource::SourceCodeLocation), - // Frames run innermost first, so they are offered outermost first. - ("/repo/Tests/Outer.swift", FileSource::LastStackFrame), - ("/repo/Tests/Own.swift", FileSource::LastStackFrame), - ("/repo/Tests/Inner.swift", FileSource::LastStackFrame), - ] - ); - } - - #[test] - fn a_summary_offering_nothing_yields_no_candidates() { - let summary = failure_summary(None, None, &[]); - assert!(FileCandidate::from_failure_summary(&summary, &identity()).is_empty()); - } - - #[rstest] - #[case::other_languages_skipped( - &[("a", "/repo/Tests/Real.swift"), ("b", "/repo/Tests/Generated.cc"), ("c", "/repo/Readme.md")], - vec!["/repo/Tests/Real.swift"] - )] - #[case::nothing_usable(&[("a", "/repo/Tests/Generated.cc")], vec![])] - fn only_swift_and_objc_frames_are_offered( - #[case] stack: &[(&str, &str)], - #[case] expected: Vec<&str>, - ) { - let summary = failure_summary(None, None, stack); - assert_eq!( - stack_frames(&summary) - .iter() - .map(|candidate| candidate.path.as_str()) - .collect::>(), - expected - ); - } - - // Apple declares `SortedKeyValueArrayPair.value` as `SchemaSerializable`, a type - // the format description never defines, so the generator cannot model it and drops - // the property. The data still carries it, and an object that is both missing the - // property and declared exhaustive fails to deserialize — which silently disabled - // the whole experimental path for any bundle with test attachments. - #[test] - fn a_summary_parses_despite_properties_the_schema_cannot_model() { - let summary: legacy_schema::ActionTestPlanRunSummaries = serde_json::from_value(json!({ - "failureSummaries": { "_values": [{ - "fileName": xc_string("/repo/Tests/SnapshotReproTests.swift"), - "attachments": { "_values": [{ - "userInfo": { "storage": { "_values": [{ - "_type": { "_name": "SortedKeyValueArrayPair" }, - "key": xc_string("Encoding"), - "value": xc_string("{ XCTImageEncodingCompressionQualityKey = 0.7; }") - }] } } - }] } - }] } - })) - .expect("a summary carrying attachment metadata must still deserialize"); - let failure_summary = &summary.failure_summaries.unwrap().values[0]; - assert_eq!( - FileCandidate::from_failure_summary(failure_summary, &identity()) - .first() - .map(|candidate| candidate.path.as_str().to_string()), - Some(String::from("/repo/Tests/SnapshotReproTests.swift")) - ); - } - - #[rstest] - #[case::scheme_and_fragment_stripped( - Some("file:///repo/Tests/Test.swift#EndingLineNumber=8"), - Some("/repo/Tests/Test.swift") - )] - #[case::spaces_encoded( - Some("file:///repo/Tests/My Test.swift"), - Some("/repo/Tests/My%20Test.swift") - )] - #[case::no_document_location(None, None)] - fn an_issue_summary_yields_a_cleaned_document_location( - #[case] url: Option<&str>, - #[case] expected: Option<&str>, - ) { - let summary = serde_json::from_value(json!({ - "documentLocationInCreatingWorkspace": { "url": url.map(xc_string) } - })) - .unwrap(); - let candidate = FileCandidate::from_issue_summary(&summary); - assert_eq!(candidate.as_ref().map(|c| c.path.as_str()), expected); - if let Some(candidate) = candidate { - assert_eq!(candidate.source, FileSource::DocumentLocation); - } - } -} diff --git a/xcresult/src/test_locations.rs b/xcresult/src/test_locations.rs index 56758b81..e2f30105 100644 --- a/xcresult/src/test_locations.rs +++ b/xcresult/src/test_locations.rs @@ -207,20 +207,6 @@ impl TestLocationIndex { self.declarations.is_empty() } - /// Seed an index without a language server, so the code downstream of it can be tested - /// off macOS. - #[cfg(test)] - pub(crate) fn declaring(mut self, node_identifier: &str, file: &str) -> Self { - self.declarations.insert( - TestKey::from_node_identifier(node_identifier), - DeclarationSite { - file: ReportedPath::new(file), - line: None, - }, - ); - self - } - fn record(&mut self, key: TestKey, candidate: DeclarationSite) { match self.declarations.get(&key) { None => { @@ -585,56 +571,6 @@ mod tests { ); } - #[test] - fn a_category_records_against_the_class_it_extends() { - let index = indexed( - "/repo/Tests/ObjcXCTestTests+Extra.m", - json!([container( - "ObjcXCTestTests(ExtraTests)", - SymbolKind::INTERFACE, - (0, 2), - vec![method("-testExample", 1)] - )]), - ); - assert!( - index - .lookup(&key(Some("ObjcXCTestTests"), "testExample")) - .is_some() - ); - } - - // The run reports the subclass, but only the base class declares the method. The - // reported file is what codeowners resolve from, so the test belongs to the concrete - // suite that chose to run it, not to whoever owns the base class. - #[test] - fn an_inherited_test_resolves_to_the_concrete_suites_file() { - let mut index = indexed( - "/repo/Tests/BaseTests.swift", - json!([container( - "BaseTests", - SymbolKind::CLASS, - (0, 2), - vec![method("testInherited()", 1)] - )]), - ); - index.collect( - &symbols(json!([container( - "SubclassTests", - SymbolKind::CLASS, - (0, 0), - vec![] - )])), - Path::new("/repo/Tests/SubclassTests.swift"), - None, - ); - assert_eq!( - index - .lookup(&key(Some("SubclassTests"), "testInherited")) - .map(|site| site.file.as_str().to_owned()), - Some(String::from("/repo/Tests/SubclassTests.swift")) - ); - } - // Resolution stops when nothing is left to find, so if the suite fallback counted as // an answer the scan would end at the first file naming the suite and never read the // one declaring the method. The two lookups have to disagree here. @@ -716,24 +652,6 @@ mod tests { assert_eq!(scan_sources(root.as_ref(), &HashSet::new(), 2).len(), 2); } - #[rstest] - #[case::plain( - "test://com.apple.xcode/InRepoHelper/InRepoHelperTests/Suite/case()", - Some("InRepoHelperTests") - )] - #[case::percent_encoded( - "test://com.apple.xcode/swift%20testing/swift%20testing%20exampleTests/helloWorld()", - Some("swift testing exampleTests") - )] - #[case::too_short("test://com.apple.xcode/OnlyAScheme", None)] - #[case::not_a_url("Suite/case()", None)] - fn an_identifier_url_names_the_target(#[case] url: &str, #[case] expected: Option<&str>) { - assert_eq!( - TestKey::target_from_identifier_url(url).as_deref(), - expected - ); - } - fn site(file: &str) -> DeclarationSite { DeclarationSite { file: ReportedPath::new(file), diff --git a/xcresult/src/xcresult.rs b/xcresult/src/xcresult.rs index 3e717f46..70456740 100644 --- a/xcresult/src/xcresult.rs +++ b/xcresult/src/xcresult.rs @@ -514,328 +514,3 @@ fn collect_test_keys(test_nodes: &[TestNode], keys: &mut Vec<(TestKey, Option) -> Value { - json!({ "nodeType": "Test Suite", "name": name, "children": children }) - } - - fn case(name: &str, node_identifier: &str, result: &str, children: Vec) -> Value { - json!({ - "nodeType": "Test Case", - "name": name, - "nodeIdentifier": node_identifier, - "result": result, - "children": children - }) - } - - /// The node a failure hangs its location off, which is where the failure was *raised*. - fn raised_at(file: &str) -> Value { - json!({ - "nodeType": "Source Code Reference", - "name": file, - "sourceLocation": { "filePath": file, "lineNumber": 9 } - }) - } - - fn bundle(name: &str, children: Vec) -> Tests { - serde_json::from_value(json!({ - "testPlanConfigurations": [], - "devices": [], - "testNodes": [{ - "nodeType": "Test Plan", - "name": "ExamplePlan", - "children": [{ "nodeType": "Unit test bundle", "name": name, "children": children }] - }] - })) - .unwrap() - } - - fn report(tests: Tests, attribution: FileAttribution) -> Report { - let xcresult = XCResult { - tests, - org_url_slug: String::from("trunk"), - repo_full_name: String::from("github.com/trunk-io/analytics-cli"), - attribution, - test_run_started_at: None, - counts: AttributionCounts::default(), - _bundle_copy: TempDir::new().unwrap(), - }; - let mut reports = xcresult.generate_junits(); - assert_eq!(reports.len(), 1); - reports.pop().unwrap() - } - - fn extra(test_case: &TestCase, key: &str) -> Option { - test_case - .extra - .iter() - .find(|(name, _)| name.as_str() == key) - .map(|(_, value)| value.as_str().to_owned()) - } - - fn suites_and_cases(report: &Report) -> Vec<(String, Vec)> { - report - .test_suites - .iter() - .map(|test_suite| { - ( - test_suite.name.as_str().to_owned(), - test_suite - .test_cases - .iter() - .map(|test_case| test_case.name.as_str().to_owned()) - .collect(), - ) - }) - .collect() - } - - fn file_of(report: &Report, name: &str) -> Option { - report - .test_suites - .iter() - .flat_map(|test_suite| test_suite.test_cases.iter()) - .find(|test_case| test_case.name.as_str() == name) - .and_then(|test_case| extra(test_case, "file")) - } - - fn declarations() -> FileAttribution { - FileAttribution::Declarations( - TestLocationIndex::default().declaring("SnapshotReproTests/testExample()", TEST_FILE), - ) - } - - // The shape that used to lose tests: an outer suite whose children are suites rather - // than cases. Every one of the six cases below has to survive, at a name that says - // where it came from. - #[test] - fn nested_suites_are_flattened_rather_than_dropped() { - let tests = bundle( - "ExampleTests", - vec![ - suite( - "OuterSuite", - vec![ - suite( - "InnerSuite", - vec![ - case( - "innerOne()", - "OuterSuite/InnerSuite/innerOne()", - "Passed", - vec![], - ), - case( - "innerTwo()", - "OuterSuite/InnerSuite/innerTwo()", - "Failed", - vec![], - ), - suite( - "DeeperSuite", - vec![case( - "deepOne()", - "OuterSuite/InnerSuite/DeeperSuite/deepOne()", - "Passed", - vec![], - )], - ), - ], - ), - suite( - "OtherInner", - vec![case( - "otherOne()", - "OuterSuite/OtherInner/otherOne()", - "Passed", - vec![], - )], - ), - ], - ), - suite( - "SiblingSuite", - vec![case( - "siblingOne()", - "SiblingSuite/siblingOne()", - "Passed", - vec![], - )], - ), - case("danglingOne()", "danglingOne()", "Passed", vec![]), - ], - ); - - assert_eq!( - suites_and_cases(&report( - tests, - FileAttribution::FailureSummaries(HashMap::new()) - )), - vec![ - (String::from("ExampleTests.OuterSuite"), vec![]), - ( - String::from("ExampleTests.OuterSuite.InnerSuite"), - vec![String::from("innerOne()"), String::from("innerTwo()")] - ), - ( - String::from("ExampleTests.OuterSuite.InnerSuite.DeeperSuite"), - vec![String::from("deepOne()")] - ), - ( - String::from("ExampleTests.OuterSuite.OtherInner"), - vec![String::from("otherOne()")] - ), - ( - String::from("ExampleTests.SiblingSuite"), - vec![String::from("siblingOne()")] - ), - ( - String::from("ExampleTests"), - vec![String::from("danglingOne()")] - ), - ] - ); - } - - #[test] - fn a_nested_test_case_is_attributed_and_identified_like_any_other() { - let tests = bundle( - "ExampleTests", - vec![suite( - "OuterSuite", - vec![suite( - "SnapshotReproTests", - vec![case( - "testExample()", - "OuterSuite/SnapshotReproTests/testExample()", - "Passed", - vec![], - )], - )], - )], - ); - let report = report(tests, declarations()); - let test_case = report - .test_suites - .iter() - .flat_map(|test_suite| test_suite.test_cases.iter()) - .find(|test_case| test_case.name.as_str() == "testExample()") - .expect("the nested case is emitted"); - assert_eq!(extra(test_case, "file").as_deref(), Some(TEST_FILE)); - assert!(extra(test_case, "id").is_some()); - } - - // The capability the failure-summary paths cannot have at all: a test that never failed - // has no summary, so there is nothing for them to read a path out of. - #[rstest] - #[case::passed("Passed")] - #[case::skipped("Skipped")] - #[case::expected_failure("Expected Failure")] - fn a_test_case_that_did_not_fail_still_gets_its_declaration_file(#[case] result: &str) { - let tests = bundle( - "ExampleTests", - vec![suite( - "SnapshotReproTests", - vec![case( - "testExample()", - "SnapshotReproTests/testExample()", - result, - vec![], - )], - )], - ); - assert_eq!( - file_of(&report(tests, declarations()), "testExample()").as_deref(), - Some(TEST_FILE) - ); - } - - #[test] - fn a_passing_test_case_has_no_file_from_failure_summaries() { - let tests = bundle( - "ExampleTests", - vec![suite( - "SnapshotReproTests", - vec![case( - "testExample()", - "SnapshotReproTests/testExample()", - "Passed", - vec![], - )], - )], - ); - assert_eq!( - file_of( - &report(tests, FileAttribution::FailureSummaries(HashMap::new())), - "testExample()" - ), - None - ); - } - - // Whether the helper is in the repo or vendored, the raised-at location is not the test. - #[rstest] - #[case::in_repo_helper(HELPER_FILE)] - #[case::vendored_dependency(DEPENDENCY_FILE)] - fn a_failure_raised_elsewhere_is_still_attributed_to_the_test_file(#[case] raised_in: &str) { - let tests = bundle( - "ExampleTests", - vec![suite( - "SnapshotReproTests", - vec![case( - "testExample()", - "SnapshotReproTests/testExample()", - "Failed", - vec![raised_at(raised_in)], - )], - )], - ); - assert_eq!( - file_of(&report(tests, declarations()), "testExample()").as_deref(), - Some(TEST_FILE) - ); - } - - // A runtime-registered test (Quick, `+testInvocations`) has no declaration anywhere - // in the checkout. Where its failure surfaced is not where it is written, and the - // reported file is what codeowners resolve from, so nothing is reported at all. - #[rstest] - #[case::in_repo_helper(HELPER_FILE)] - #[case::vendored_dependency(DEPENDENCY_FILE)] - fn an_unresolved_test_is_reported_with_no_file(#[case] raised_in: &str) { - let tests = bundle( - "ExampleTests", - vec![suite( - "QuickSpec", - vec![case( - "a calculator, fails on purpose()", - "QuickSpec/a calculator, fails on purpose()", - "Failed", - vec![raised_at(raised_in)], - )], - )], - ); - assert_eq!( - file_of( - &report( - tests, - FileAttribution::Declarations(TestLocationIndex::default()) - ), - "a calculator, fails on purpose()" - ), - None - ); - } -} diff --git a/xcresult/tests/declaration_locations.rs b/xcresult/tests/declaration_locations.rs index c4329a53..33eb2fa1 100644 --- a/xcresult/tests/declaration_locations.rs +++ b/xcresult/tests/declaration_locations.rs @@ -9,7 +9,9 @@ mod common; use common::unpack_archive_to_temp_dir; use lazy_static::lazy_static; +use rstest::rstest; use temp_testdir::TempDir; +use xcresult::test_locations::TestKey; lazy_static! { static ref TEMP_DIR_TEST_INHERITED_TEST: TempDir = @@ -88,3 +90,57 @@ fn test_declaration_locations_resolve_a_test_declared_in_an_objc_category() { "expected the category's file, got {file}" ); } + +#[rstest] +#[case::plain( + "test://com.apple.xcode/InRepoHelper/InRepoHelperTests/Suite/case()", + Some("InRepoHelperTests") +)] +#[case::percent_encoded( + "test://com.apple.xcode/swift%20testing/swift%20testing%20exampleTests/helloWorld()", + Some("swift testing exampleTests") +)] +#[case::too_short("test://com.apple.xcode/OnlyAScheme", None)] +#[case::not_a_url("Suite/case()", None)] +fn an_identifier_url_names_the_target(#[case] url: &str, #[case] expected: Option<&str>) { + assert_eq!( + TestKey::target_from_identifier_url(url).as_deref(), + expected + ); +} + +// A checkout that declares none of the tests. Where a failure surfaced is not where the +// test is written, so with nothing to resolve the reported file is absent rather than the +// helper the failure came from — that path resolves the wrong codeowners. +#[cfg(target_os = "macos")] +#[test] +fn test_a_test_with_no_declaration_in_the_checkout_gets_no_file() { + let empty_checkout = TempDir::default(); + let report = common::declaration_report( + TEMP_DIR_TEST_INHERITED_TEST + .as_ref() + .join("InheritedTest.xcresult"), + empty_checkout.as_ref(), + ); + let cases = report + .test_suites + .iter() + .flat_map(|test_suite| test_suite.test_cases.iter()) + .collect::>(); + assert!( + !cases.is_empty(), + "the bundle must still emit its test cases" + ); + for test_case in cases { + assert_eq!( + test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "file") + .map(|(_, value)| value.as_str()), + None, + "{} was given a file with nothing declaring it", + test_case.name.as_str() + ); + } +} diff --git a/xcresult/tests/file_attribution.rs b/xcresult/tests/file_attribution.rs new file mode 100644 index 00000000..f7389fc4 --- /dev/null +++ b/xcresult/tests/file_attribution.rs @@ -0,0 +1,196 @@ +//! `file_attribution`'s public surface: which file a failure summary offers, in which +//! order, and how a path is normalised on the way out. + +use rstest::rstest; +use serde_json::{Value, json}; +use xcresult::file_attribution::{FileCandidate, FileSource, ReportedPath, TestIdentity}; +use xcresult::types::legacy_schema; + +const SUITE: &str = "SnapshotReproTests"; +const CASE: &str = "failingSnapshot()"; + +fn xc_string(value: &str) -> Value { + json!({ "_value": value }) +} + +fn failure_summary( + file_name: Option<&str>, + location: Option<&str>, + stack: &[(&str, &str)], +) -> legacy_schema::ActionTestFailureSummary { + serde_json::from_value(json!({ + "fileName": file_name.map(xc_string), + "sourceCodeContext": { + "location": { "filePath": location.map(xc_string) }, + "callStack": { "_values": stack.iter().map(|(symbol, path)| json!({ + "symbolInfo": { + "symbolName": xc_string(symbol), + "location": { "filePath": xc_string(path) } + } + })).collect::>() } + } + })) + .unwrap() +} + +fn identity() -> TestIdentity<'static> { + TestIdentity { + suite: Some(SUITE), + case: CASE, + } +} + +#[rstest] +#[case::spaces_are_encoded("/repo/Tests/My Test.swift", "/repo/Tests/My%20Test.swift")] +#[case::already_safe("/repo/Tests/Test.swift", "/repo/Tests/Test.swift")] +fn reported_path_normalizes_once(#[case] path: &str, #[case] expected: &str) { + assert_eq!(ReportedPath::new(path).as_str(), expected); +} + +#[rstest] +#[case::tuist_checkout("/repo/Tuist/.build/checkouts/Dep/Dep.swift", true)] +#[case::derived_data("/repo/DerivedData/SourcePackages/checkouts/Dep/Dep.swift", true)] +#[case::the_repos_own_code("/repo/Tests/SnapshotReproTests.swift", false)] +fn reported_path_recognizes_vendored_sources(#[case] path: &str, #[case] expected: bool) { + assert_eq!(ReportedPath::new(path).is_vendored_dependency(), expected); +} + +#[rstest] +#[case::swift_symbol("SnapshotReproTests.failingSnapshot()", true)] +#[case::objc_symbol("-[SnapshotReproTests failingSnapshot]", true)] +#[case::closure_inside_test("closure #1 in SnapshotReproTests.failingSnapshot()", true)] +#[case::helper_the_test_called("assertSnapshot(of:as:)", false)] +#[case::same_case_name_in_another_suite("OtherTests.failingSnapshot()", false)] +#[case::trait_that_invoked_the_test( + "closure #1 in _SnapshotsTestTrait.provideScope(for:testCase:performing:)", + false +)] +fn identity_recognizes_only_the_tests_own_frame(#[case] symbol: &str, #[case] expected: bool) { + assert_eq!(identity().is_named_by(symbol), expected); +} + +#[rstest] +#[case::top_level_swift_testing_function("failingSnapshot()", true)] +#[case::closure_inside_it("closure #1 in failingSnapshot()", true)] +#[case::suite_scoped_symbol("SnapshotReproTests.failingSnapshot()", false)] +fn a_suiteless_test_is_matched_by_its_bare_function(#[case] symbol: &str, #[case] expected: bool) { + let identity = TestIdentity { + suite: None, + case: CASE, + }; + assert_eq!(identity.is_named_by(symbol), expected); +} + +#[test] +fn candidates_are_offered_in_preference_order_and_keep_their_provenance() { + let summary = failure_summary( + Some("/repo/Tests/Raised.swift"), + Some("/repo/Tests/Location.swift"), + &[ + ("helper()", "/repo/Tests/Inner.swift"), + ( + "SnapshotReproTests.failingSnapshot()", + "/repo/Tests/Own.swift", + ), + ("framework()", "/repo/Tests/Outer.swift"), + ], + ); + assert_eq!( + FileCandidate::from_failure_summary(&summary, &identity()) + .iter() + .map(|candidate| (candidate.path.as_str(), candidate.source)) + .collect::>(), + vec![ + ("/repo/Tests/Own.swift", FileSource::TestFrame), + ("/repo/Tests/Raised.swift", FileSource::RaisedFrom), + ("/repo/Tests/Location.swift", FileSource::SourceCodeLocation), + // Frames run innermost first, so they are offered outermost first. + ("/repo/Tests/Outer.swift", FileSource::LastStackFrame), + ("/repo/Tests/Own.swift", FileSource::LastStackFrame), + ("/repo/Tests/Inner.swift", FileSource::LastStackFrame), + ] + ); +} + +#[test] +fn a_summary_offering_nothing_yields_no_candidates() { + let summary = failure_summary(None, None, &[]); + assert!(FileCandidate::from_failure_summary(&summary, &identity()).is_empty()); +} + +#[rstest] +#[case::other_languages_skipped( + &[("a", "/repo/Tests/Real.swift"), ("b", "/repo/Tests/Generated.cc"), ("c", "/repo/Readme.md")], + vec!["/repo/Tests/Real.swift"] +)] +#[case::nothing_usable(&[("a", "/repo/Tests/Generated.cc")], vec![])] +fn only_swift_and_objc_frames_are_offered( + #[case] stack: &[(&str, &str)], + #[case] expected: Vec<&str>, +) { + // With no `fileName` and no location, every candidate offered is a stack frame, + // so this reaches the same filtering through the public entry point. + let summary = failure_summary(None, None, stack); + assert_eq!( + FileCandidate::from_failure_summary(&summary, &identity()) + .iter() + .inspect(|candidate| assert_eq!(candidate.source, FileSource::LastStackFrame)) + .map(|candidate| candidate.path.as_str()) + .collect::>(), + expected + ); +} + +// Apple declares `SortedKeyValueArrayPair.value` as `SchemaSerializable`, a type +// the format description never defines, so the generator cannot model it and drops +// the property. The data still carries it, and an object that is both missing the +// property and declared exhaustive fails to deserialize — which silently disabled +// the whole experimental path for any bundle with test attachments. +#[test] +fn a_summary_parses_despite_properties_the_schema_cannot_model() { + let summary: legacy_schema::ActionTestPlanRunSummaries = serde_json::from_value(json!({ + "failureSummaries": { "_values": [{ + "fileName": xc_string("/repo/Tests/SnapshotReproTests.swift"), + "attachments": { "_values": [{ + "userInfo": { "storage": { "_values": [{ + "_type": { "_name": "SortedKeyValueArrayPair" }, + "key": xc_string("Encoding"), + "value": xc_string("{ XCTImageEncodingCompressionQualityKey = 0.7; }") + }] } } + }] } + }] } + })) + .expect("a summary carrying attachment metadata must still deserialize"); + let failure_summary = &summary.failure_summaries.unwrap().values[0]; + assert_eq!( + FileCandidate::from_failure_summary(failure_summary, &identity()) + .first() + .map(|candidate| candidate.path.as_str().to_string()), + Some(String::from("/repo/Tests/SnapshotReproTests.swift")) + ); +} + +#[rstest] +#[case::scheme_and_fragment_stripped( + Some("file:///repo/Tests/Test.swift#EndingLineNumber=8"), + Some("/repo/Tests/Test.swift") +)] +#[case::spaces_encoded( + Some("file:///repo/Tests/My Test.swift"), + Some("/repo/Tests/My%20Test.swift") +)] +#[case::no_document_location(None, None)] +fn an_issue_summary_yields_a_cleaned_document_location( + #[case] url: Option<&str>, + #[case] expected: Option<&str>, +) { + let summary = serde_json::from_value(json!({ + "documentLocationInCreatingWorkspace": { "url": url.map(xc_string) } + })) + .unwrap(); + let candidate = FileCandidate::from_issue_summary(&summary); + assert_eq!(candidate.as_ref().map(|c| c.path.as_str()), expected); + if let Some(candidate) = candidate { + assert_eq!(candidate.source, FileSource::DocumentLocation); + } +} From 64ee56d5c355f3671072f2f74afcf468050945ff Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Thu, 3 Sep 2026 14:34:00 -0700 Subject: [PATCH 09/24] test(xcresult): move the new fixtures' parity cases out of xcresult.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two fixtures added on this branch were checked by appending `#[case::]` entries to `test_the_declaration_flag_moves_the_file_and_nothing_else`, which lives in `tests/xcresult.rs`. That put new tests in the file everything else was being moved out of, and the only alternative at the time looked like duplicating the 135-line host test. Extracting its assertion to `tests/common` removes the choice: the check is now `assert_the_declaration_flag_moves_only_the_file`, and each set of fixtures runs it from wherever its own tests live. The twelve original cases stay in `xcresult.rs` against the older bundles; the two new ones move next to the fixtures they cover. `tests/xcresult.rs` now has no test that was not already there before the branch — its only removals are the four helpers that moved into `tests/common`. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/tests/common/mod.rs | 79 ++++++++++++++++++++++++ xcresult/tests/declaration_locations.rs | 23 +++++++ xcresult/tests/xcresult.rs | 81 ++----------------------- 3 files changed, 106 insertions(+), 77 deletions(-) diff --git a/xcresult/tests/common/mod.rs b/xcresult/tests/common/mod.rs index 9e52c809..433f3564 100644 --- a/xcresult/tests/common/mod.rs +++ b/xcresult/tests/common/mod.rs @@ -80,3 +80,82 @@ pub fn declaration_files, U: AsRef>( }) .collect() } + +/// Assert the declaration flag changes a bundle's reported `file` and nothing else. +/// +/// Everything except `file` — suite and case names, ids, statuses, timestamps — has to +/// come out identical on both paths, because the flag is only meant to move the file. +/// `repo_root` of `None` means an empty checkout, where nothing resolves. +#[cfg(target_os = "macos")] +pub fn assert_the_declaration_flag_moves_only_the_file( + archive: &str, + bundle: &str, + repo_root: Option<&str>, +) { + fn shape(xcresult: &xcresult::xcresult::XCResult) -> Vec { + let mut junits = xcresult.generate_junits(); + assert_eq!(junits.len(), 1); + let junit = junits.pop().unwrap(); + let mut rows = Vec::new(); + for test_suite in &junit.test_suites { + for test_case in &test_suite.test_cases { + let id = test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "id") + .map(|(_, value)| value.as_str().to_owned()) + .unwrap_or_default(); + rows.push(format!( + "{} | {} | {} | {:?} | {}", + test_suite.name.as_str(), + test_case.name.as_str(), + id, + test_case.status, + test_case + .timestamp + .map(|timestamp| timestamp.to_string()) + .unwrap_or_default(), + )); + } + } + rows + } + + let temp_dir = unpack_archive_to_temp_dir(archive); + let bundle_path = temp_dir.as_ref().join(bundle); + let path_str = bundle_path.to_str().unwrap(); + let empty_checkout = TempDir::default(); + let root: &Path = match repo_root { + Some(repo_root) => Path::new(repo_root), + None => empty_checkout.as_ref(), + }; + + let default = XCResult::new( + path_str, + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + false, + ) + .expect("the default path reads the bundle"); + let declarations = XCResult::new_with_declaration_locations( + path_str, + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + root, + Limits::default(), + ) + .expect("the declaration path reads the bundle"); + + let expected = shape(&default); + assert!(!expected.is_empty(), "the bundle must have test cases"); + pretty_assertions::assert_eq!(shape(&declarations), expected); + + for file in declaration_files(&bundle_path, root).values() { + assert!( + !["/.build/", "/checkouts/", "/DerivedData/"] + .iter() + .any(|segment| file.contains(segment)), + "the declaration path reported a vendored file: {file}" + ); + } +} diff --git a/xcresult/tests/declaration_locations.rs b/xcresult/tests/declaration_locations.rs index 33eb2fa1..bdbe22ad 100644 --- a/xcresult/tests/declaration_locations.rs +++ b/xcresult/tests/declaration_locations.rs @@ -144,3 +144,26 @@ fn test_a_test_with_no_declaration_in_the_checkout_gets_no_file() { ); } } + +// The flag is only meant to move the `file` attribute, so both new fixtures are run +// through each path and compared on everything else. Shares its assertion with the +// same check over the older bundles in `xcresult.rs`. +#[cfg(target_os = "macos")] +#[rstest] +#[case::inherited_test( + "tests/data/test-inherited-test.xcresult.tar.gz", + "InheritedTest.xcresult", + Some("tests/fixture-src/inherited-test") +)] +#[case::objc_category( + "tests/data/test-objc-category.xcresult.tar.gz", + "ObjcCategory.xcresult", + Some("tests/fixture-src/objc-category") +)] +fn test_the_declaration_flag_moves_the_file_and_nothing_else( + #[case] archive: &str, + #[case] bundle: &str, + #[case] repo_root: Option<&str>, +) { + common::assert_the_declaration_flag_moves_only_the_file(archive, bundle, repo_root); +} diff --git a/xcresult/tests/xcresult.rs b/xcresult/tests/xcresult.rs index 232f6510..821d4a30 100644 --- a/xcresult/tests/xcresult.rs +++ b/xcresult/tests/xcresult.rs @@ -11,7 +11,9 @@ mod common; use common::{ORG_URL_SLUG, REPO_FULL_NAME, unpack_archive_to_temp_dir}; #[cfg(target_os = "macos")] -use common::{declaration_files, declaration_report}; +use common::{ + assert_the_declaration_flag_moves_only_the_file, declaration_files, declaration_report, +}; lazy_static! { static ref TEMP_DIR_TEST_1: TempDir = @@ -933,87 +935,12 @@ fn test_declaration_locations_give_a_passing_test_its_file() { "NestedAndPassing.xcresult", Some("tests/fixture-src/nested-and-passing") )] -#[case::inherited_test( - "tests/data/test-inherited-test.xcresult.tar.gz", - "InheritedTest.xcresult", - Some("tests/fixture-src/inherited-test") -)] -#[case::objc_category( - "tests/data/test-objc-category.xcresult.tar.gz", - "ObjcCategory.xcresult", - Some("tests/fixture-src/objc-category") -)] fn test_the_declaration_flag_moves_the_file_and_nothing_else( #[case] archive: &str, #[case] bundle: &str, #[case] repo_root: Option<&str>, ) { - fn shape(xcresult: &XCResult) -> Vec { - let mut junits = xcresult.generate_junits(); - assert_eq!(junits.len(), 1); - let junit = junits.pop().unwrap(); - let mut rows = Vec::new(); - for test_suite in &junit.test_suites { - for test_case in &test_suite.test_cases { - let id = test_case - .extra - .iter() - .find(|(key, _)| key.as_str() == "id") - .map(|(_, value)| value.as_str().to_owned()) - .unwrap_or_default(); - rows.push(format!( - "{} | {} | {} | {:?} | {}", - test_suite.name.as_str(), - test_case.name.as_str(), - id, - test_case.status, - test_case - .timestamp - .map(|timestamp| timestamp.to_string()) - .unwrap_or_default(), - )); - } - } - rows - } - - let temp_dir = unpack_archive_to_temp_dir(archive); - let bundle_path = temp_dir.as_ref().join(bundle); - let path_str = bundle_path.to_str().unwrap(); - let empty_checkout = TempDir::default(); - let root: &Path = match repo_root { - Some(repo_root) => Path::new(repo_root), - None => empty_checkout.as_ref(), - }; - - let default = XCResult::new( - path_str, - ORG_URL_SLUG.clone(), - REPO_FULL_NAME.clone(), - false, - ) - .expect("the default path reads the bundle"); - let declarations = XCResult::new_with_declaration_locations( - path_str, - ORG_URL_SLUG.clone(), - REPO_FULL_NAME.clone(), - root, - Limits::default(), - ) - .expect("the declaration path reads the bundle"); - - let expected = shape(&default); - assert!(!expected.is_empty(), "the bundle must have test cases"); - pretty_assertions::assert_eq!(shape(&declarations), expected); - - for file in declaration_files(&bundle_path, root).values() { - assert!( - !["/.build/", "/checkouts/", "/DerivedData/"] - .iter() - .any(|segment| file.contains(segment)), - "the declaration path reported a vendored file: {file}" - ); - } + assert_the_declaration_flag_moves_only_the_file(archive, bundle, repo_root); } // Reading used to migrate the bundle in place, which failed when it was not writable. From 8d26989eb5481047e6e506f47c59b1b0c46675e3 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Thu, 3 Sep 2026 22:58:53 -0700 Subject: [PATCH 10/24] fix(xcresult): read the caller's bundle instead of copying every one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying was introduced to stop `xcresulttool` migrating a bundle in place, which writes into a directory we were only asked to read and fails where it is not writable. It cost a full copy of every bundle on every upload to buy that. The copy is not what it looked like. Removing it and running the suite four times produced no races at all: the shared fixtures are current-format bundles that carry `database.sqlite3` already, so nothing migrates and nothing contends. The only failure was the test written to pin the read-only guarantee, on the one fixture that genuinely predates the file. So the trade is narrower than the copy implied — it protects bundles in a format Xcode no longer writes, and nothing else. Reading in place is what the CLI did before this branch, and the older format is now an accepted limitation rather than something every upload pays to avoid. `tests/bundle_reading.rs` pins both halves so the behaviour is documented rather than rediscovered: a read-only current-format bundle is readable and comes back byte-identical on disk, and a read-only legacy one fails its in-place migration. The second test is the limitation itself; if it ever starts passing, the migration behaviour changed and the note in `xcresult.rs` is stale. Its `entries` and `set_writable` helpers move to `tests/common` on the way, since both halves need them. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/src/xcresult.rs | 41 +++------------- xcresult/tests/bundle_reading.rs | 80 ++++++++++++++++++++++++++++++++ xcresult/tests/common/mod.rs | 44 ++++++++++++++++++ xcresult/tests/xcresult.rs | 76 ------------------------------ 4 files changed, 131 insertions(+), 110 deletions(-) create mode 100644 xcresult/tests/bundle_reading.rs diff --git a/xcresult/src/xcresult.rs b/xcresult/src/xcresult.rs index 70456740..84c3e5f2 100644 --- a/xcresult/src/xcresult.rs +++ b/xcresult/src/xcresult.rs @@ -1,11 +1,10 @@ use std::collections::HashMap; use std::str; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::{fs, path::Path, path::PathBuf, time::Duration}; +use std::{fs, path::Path, time::Duration}; use chrono::{DateTime, Utc}; use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestRerun, TestSuite}; -use tempfile::TempDir; use crate::test_locations::{Limits, TestKey, TestLocationIndex}; use crate::types::{ @@ -18,6 +17,12 @@ use crate::xcrun::{ xcresulttool_get_test_results_tests, }; +// `xcresulttool` migrates a bundle that predates `database.sqlite3` in place the first time +// it is read, so reading one writes into a directory we were only asked to read — and fails +// where it is not writable. Every bundle Xcode writes today already carries the file and is +// unaffected; copying every bundle to avoid the older case costs more than the case is worth. +// `tests/bundle_reading.rs` pins both halves of that. + /// Where a test's file comes from — where a failure surfaced, or where the test is /// declared — which also decides which `xcresulttool` calls the bundle is read with. #[derive(Debug)] @@ -26,33 +31,6 @@ pub enum FileAttribution { Declarations(TestLocationIndex), } -/// `xcresulttool` migrates an older bundle in place on first read, writing into a directory -/// we were only asked to read and failing outright when it is not writable. -fn copy_bundle(path: &Path) -> anyhow::Result<(TempDir, PathBuf)> { - fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { - fs::create_dir_all(to)?; - for entry in fs::read_dir(from)? { - let entry = entry?; - let destination = to.join(entry.file_name()); - if entry.file_type()?.is_dir() { - copy_dir(&entry.path(), &destination)?; - } else { - fs::copy(entry.path(), destination)?; - } - } - Ok(()) - } - - let temp_dir = TempDir::new()?; - let destination = temp_dir.path().join( - path.file_name() - .unwrap_or_else(|| std::ffi::OsStr::new("bundle.xcresult")), - ); - copy_dir(path, &destination) - .map_err(|e| anyhow::anyhow!("failed to copy {} for reading: {}", path.display(), e))?; - Ok((temp_dir, destination)) -} - /// Makes it visible how many tests the checkout could not account for. #[derive(Debug, Default)] struct AttributionCounts { @@ -68,7 +46,6 @@ pub struct XCResult { attribution: FileAttribution, test_run_started_at: Option>, counts: AttributionCounts, - _bundle_copy: TempDir, } impl XCResult { @@ -85,7 +62,6 @@ impl XCResult { e ) })?; - let (bundle_copy, absolute_path) = copy_bundle(&absolute_path)?; // Call xcresulttool_get_object once and use it for both timestamp extraction and legacy tests let actions_invocation_record = xcresulttool_get_object(&absolute_path); @@ -151,7 +127,6 @@ impl XCResult { repo_full_name, test_run_started_at, counts: AttributionCounts::default(), - _bundle_copy: bundle_copy, }) } @@ -173,7 +148,6 @@ impl XCResult { e ) })?; - let (bundle_copy, absolute_path) = copy_bundle(&absolute_path)?; let tests = xcresulttool_get_test_results_tests(&absolute_path)?; let test_run_started_at = match xcresulttool_get_test_results_summary(&absolute_path) { @@ -205,7 +179,6 @@ impl XCResult { repo_full_name, test_run_started_at, counts: AttributionCounts::default(), - _bundle_copy: bundle_copy, }) } diff --git a/xcresult/tests/bundle_reading.rs b/xcresult/tests/bundle_reading.rs new file mode 100644 index 00000000..d1302e71 --- /dev/null +++ b/xcresult/tests/bundle_reading.rs @@ -0,0 +1,80 @@ +//! What reading a bundle does to it on disk, and what it needs from the filesystem. +//! +//! `xcresulttool` migrates a bundle that predates `database.sqlite3` in place the first +//! time it is read. Both halves of that are pinned here: the format Xcode writes today is +//! unaffected, and the older one is a known limitation rather than something we pay to +//! avoid on every upload. + +mod common; + +use common::{ORG_URL_SLUG, REPO_FULL_NAME, entries, set_writable, unpack_archive_to_temp_dir}; +use xcresult::xcresult::XCResult; + +// The case that matters now: a current bundle already carries `database.sqlite3`, so there +// is nothing to migrate and it is read where it lies. CI hands out artifact mounts without +// write access, so this has to hold without copying the bundle first. +#[cfg(target_os = "macos")] +#[test] +fn a_read_only_modern_bundle_is_readable_and_is_not_written_to() { + let temp_dir = unpack_archive_to_temp_dir("tests/data/test-inherited-test.xcresult.tar.gz"); + let bundle = temp_dir.as_ref().join("InheritedTest.xcresult"); + let before = entries(&bundle); + assert!( + before.iter().any(|entry| entry == "database.sqlite3"), + "the fixture must already be migrated for this to prove anything" + ); + + set_writable(&bundle, false); + let read_only_result = XCResult::new( + bundle.to_str().unwrap(), + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + false, + ) + .map(|xcresult| xcresult.generate_junits().len()); + set_writable(&bundle, true); + + assert_eq!( + read_only_result.map_err(|e| e.to_string()), + Ok(1), + "a read-only bundle in the current format must still be readable" + ); + pretty_assertions::assert_eq!( + entries(&bundle), + before, + "reading the bundle changed it on disk" + ); +} + +// The accepted limitation. A bundle predating `database.sqlite3` is migrated in place on +// first read, so a read-only one cannot be read at all. Copying every bundle to avoid this +// costs more than the case is worth, and no bundle Xcode writes today is in this format. +// If this ever starts passing, the migration behaviour changed and the note in +// `xcresult.rs` is stale. +#[cfg(target_os = "macos")] +#[test] +fn a_read_only_legacy_bundle_cannot_be_read() { + let temp_dir = unpack_archive_to_temp_dir("tests/data/test4.xcresult.tar.gz"); + let bundle = temp_dir.as_ref().join("test4.xcresult"); + assert!( + !entries(&bundle) + .iter() + .any(|entry| entry == "database.sqlite3"), + "the fixture must start un-migrated for this to prove anything" + ); + + set_writable(&bundle, false); + let read_only_result = XCResult::new( + bundle.to_str().unwrap(), + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + false, + ) + .map(|xcresult| xcresult.generate_junits().len()); + set_writable(&bundle, true); + + assert!( + read_only_result.is_err(), + "a read-only bundle in the older format is expected to fail the in-place migration" + ); +} diff --git a/xcresult/tests/common/mod.rs b/xcresult/tests/common/mod.rs index 433f3564..bac41957 100644 --- a/xcresult/tests/common/mod.rs +++ b/xcresult/tests/common/mod.rs @@ -159,3 +159,47 @@ pub fn assert_the_declaration_flag_moves_only_the_file( ); } } + +/// Every path under `dir`, relative to it, sorted — so two listings can be compared to +/// prove a read left the directory alone. +pub fn entries(dir: &Path) -> Vec { + let mut found = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + for entry in std::fs::read_dir(¤t).unwrap() { + let path = entry.unwrap().path(); + found.push(path.strip_prefix(dir).unwrap().display().to_string()); + if path.is_dir() { + stack.push(path); + } + } + } + found.sort(); + found +} + +/// Make `dir` and everything under it writable or not, for proving a read does not need +/// write access — CI hands out artifact mounts that do not have it. +pub fn set_writable(dir: &Path, writable: bool) { + let mut stack = vec![dir.to_path_buf()]; + let mut all = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + for entry in std::fs::read_dir(¤t).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + stack.push(path.clone()); + } + all.push(path); + } + } + // Directories have to come last on the way down and first on the way back up. + all.sort(); + if !writable { + all.reverse(); + } + for path in all { + let mode = if writable { 0o755 } else { 0o555 }; + std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(mode)) + .unwrap(); + } +} diff --git a/xcresult/tests/xcresult.rs b/xcresult/tests/xcresult.rs index 821d4a30..48cac563 100644 --- a/xcresult/tests/xcresult.rs +++ b/xcresult/tests/xcresult.rs @@ -942,79 +942,3 @@ fn test_the_declaration_flag_moves_the_file_and_nothing_else( ) { assert_the_declaration_flag_moves_only_the_file(archive, bundle, repo_root); } - -// Reading used to migrate the bundle in place, which failed when it was not writable. -#[cfg(target_os = "macos")] -#[test] -fn test_reading_a_bundle_neither_writes_to_it_nor_needs_it_writable() { - fn entries(dir: &Path) -> Vec { - let mut found = Vec::new(); - let mut stack = vec![dir.to_path_buf()]; - while let Some(current) = stack.pop() { - for entry in std::fs::read_dir(¤t).unwrap() { - let path = entry.unwrap().path(); - found.push(path.strip_prefix(dir).unwrap().display().to_string()); - if path.is_dir() { - stack.push(path); - } - } - } - found.sort(); - found - } - - fn set_writable(dir: &Path, writable: bool) { - let mut stack = vec![dir.to_path_buf()]; - let mut all = vec![dir.to_path_buf()]; - while let Some(current) = stack.pop() { - for entry in std::fs::read_dir(¤t).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - stack.push(path.clone()); - } - all.push(path); - } - } - // Directories have to come last on the way down and first on the way back up. - all.sort(); - if !writable { - all.reverse(); - } - for path in all { - let mode = if writable { 0o755 } else { 0o555 }; - std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(mode)) - .unwrap(); - } - } - - let temp_dir = unpack_archive_to_temp_dir("tests/data/test4.xcresult.tar.gz"); - let bundle = temp_dir.as_ref().join("test4.xcresult"); - let before = entries(&bundle); - assert!( - !before - .iter() - .any(|entry| entry.contains("database.sqlite3")), - "the fixture must start un-migrated for this to prove anything" - ); - - set_writable(&bundle, false); - let xcresult = XCResult::new( - bundle.to_str().unwrap(), - ORG_URL_SLUG.clone(), - REPO_FULL_NAME.clone(), - false, - ); - let read_only_result = xcresult.map(|xcresult| xcresult.generate_junits().len()); - set_writable(&bundle, true); - - assert_eq!( - read_only_result.map_err(|e| e.to_string()), - Ok(1), - "a read-only bundle must still be readable" - ); - pretty_assertions::assert_eq!( - entries(&bundle), - before, - "reading the bundle changed it on disk" - ); -} From 064ae1c6649a8a4fb0044001ff7cc8374695ac86 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Thu, 3 Sep 2026 23:46:41 -0700 Subject: [PATCH 11/24] test(xcresult): move this PR's declaration tests out of xcresult.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every test this branch added to `tests/xcresult.rs` is about the declaration path, and they had accumulated at the end of a file that was already the longest in the crate. They move to `tests/declaration_locations.rs`, where the rest of that coverage already lives: - the seven `test_declaration_locations_*` tests, - `test_a_nested_suite_is_flattened_rather_than_dropped`, and - `test_the_declaration_flag_moves_the_file_and_nothing_else`, whose twelve cases rejoin the two that were split off into `declaration_locations.rs` earlier. `assert_junit` is now called from both files, so it joins the rest of the harness in `tests/common`, which also picks up a blanket `allow(dead_code)`: the module is compiled into each test binary separately, so whatever one binary does not call is dead from its point of view. Measured against `main` rather than the branch tip, `tests/xcresult.rs` now has no test this PR did not find there — 601 lines, all of it pre-existing. The suite is unchanged at 110 tests, and no case was dropped in the move. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/tests/common/mod.rs | 31 +++ xcresult/tests/declaration_locations.rs | 329 +++++++++++++++++++++- xcresult/tests/xcresult.rs | 347 +----------------------- 3 files changed, 356 insertions(+), 351 deletions(-) diff --git a/xcresult/tests/common/mod.rs b/xcresult/tests/common/mod.rs index bac41957..9b46f087 100644 --- a/xcresult/tests/common/mod.rs +++ b/xcresult/tests/common/mod.rs @@ -1,9 +1,14 @@ //! Harness shared by the integration test binaries in this directory. //! +//! This module is compiled into each of them separately, so anything one binary does not +//! call is dead code from that binary's point of view — hence the blanket allow. +//! //! Each file under `tests/` is its own crate, so anything two of them need lives here //! rather than being written twice. `tests/common/mod.rs` is a module rather than //! `tests/common.rs`, which cargo would build and run as a third test binary. +#![allow(dead_code)] + use std::{fs::File, path::Path}; use context::repo::RepoUrlParts; @@ -203,3 +208,29 @@ pub fn set_writable(dir: &Path, writable: bool) { .unwrap(); } } + +// available: only the experimental path reads the per-test failure summary, and so +// the call stack, so it is the only one that can produce a `FileSource::TestFrame`. +// The legacy path sees `FileSource::DocumentLocation` alone. +#[cfg(target_os = "macos")] +pub fn assert_junit>( + bundle_path: T, + use_experimental_failure_summary: bool, + expected_junit_xml: &str, +) { + let path_str = bundle_path.as_ref().to_str().unwrap(); + let xcresult = XCResult::new( + path_str, + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + use_experimental_failure_summary, + ); + assert!(xcresult.is_ok()); + + let mut junits = xcresult.unwrap().generate_junits(); + assert_eq!(junits.len(), 1); + let junit = junits.pop().unwrap(); + let mut junit_writer: Vec = Vec::new(); + junit.serialize(&mut junit_writer).unwrap(); + pretty_assertions::assert_eq!(String::from_utf8(junit_writer).unwrap(), expected_junit_xml); +} diff --git a/xcresult/tests/declaration_locations.rs b/xcresult/tests/declaration_locations.rs index bdbe22ad..6c129880 100644 --- a/xcresult/tests/declaration_locations.rs +++ b/xcresult/tests/declaration_locations.rs @@ -7,13 +7,31 @@ mod common; -use common::unpack_archive_to_temp_dir; +use common::{ + ORG_URL_SLUG, REPO_FULL_NAME, assert_junit, assert_the_declaration_flag_moves_only_the_file, + declaration_files, declaration_report, unpack_archive_to_temp_dir, +}; use lazy_static::lazy_static; use rstest::rstest; use temp_testdir::TempDir; -use xcresult::test_locations::TestKey; +use xcresult::test_locations::{Limits, TestKey}; +use xcresult::xcresult::XCResult; lazy_static! { + static ref TEMP_DIR_TEST_DEPENDENCY_RAISES_FAILURE: TempDir = + unpack_archive_to_temp_dir("tests/data/test-dependency-raises-failure.xcresult.tar.gz"); + static ref TEMP_DIR_TEST_IN_REPO_HELPER_RAISES_FAILURE: TempDir = + unpack_archive_to_temp_dir("tests/data/test-in-repo-helper-raises-failure.xcresult.tar.gz"); + static ref TEMP_DIR_TEST_CRASH_IN_DEPENDENCY: TempDir = + unpack_archive_to_temp_dir("tests/data/test-crash-in-dependency.xcresult.tar.gz"); + static ref TEMP_DIR_TEST_OBJC_XCTEST: TempDir = + unpack_archive_to_temp_dir("tests/data/test-objc-xctest.xcresult.tar.gz"); + static ref TEMP_DIR_TEST_TOPLEVEL_SWIFT_TESTING: TempDir = + unpack_archive_to_temp_dir("tests/data/test-toplevel-swift-testing.xcresult.tar.gz"); + static ref TEMP_DIR_TEST_NESTED_AND_PASSING: TempDir = + unpack_archive_to_temp_dir("tests/data/test-nested-and-passing.xcresult.tar.gz"); + static ref TEMP_DIR_TEST_TIMESTAMP: TempDir = + unpack_archive_to_temp_dir("tests/data/test-timestamp.xcresult.tar.gz"); static ref TEMP_DIR_TEST_INHERITED_TEST: TempDir = unpack_archive_to_temp_dir("tests/data/test-inherited-test.xcresult.tar.gz"); static ref TEMP_DIR_TEST_OBJC_CATEGORY: TempDir = @@ -145,11 +163,310 @@ fn test_a_test_with_no_declaration_in_the_checkout_gets_no_file() { } } -// The flag is only meant to move the `file` attribute, so both new fixtures are run -// through each path and compared on everything else. Shares its assertion with the -// same check over the older bundles in `xcresult.rs`. +// Every file this bundle's failure summary offers is under `SourcePackages/checkouts/`. +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_prefer_the_tests_own_file_over_a_vendored_dependency() { + let files = declaration_files( + TEMP_DIR_TEST_DEPENDENCY_RAISES_FAILURE + .as_ref() + .join("DependencyRaisesFailure.xcresult"), + "tests/fixture-src/dependency-raises-failure", + ); + let file = files + .get("failsInsideDependency()") + .expect("the fixture's only test"); + assert!( + file.ends_with("DependencyRaisesFailureTests.swift"), + "expected the test's own file, got {file}" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_prefer_the_tests_own_file_over_an_in_repo_helper() { + let files = declaration_files( + TEMP_DIR_TEST_IN_REPO_HELPER_RAISES_FAILURE + .as_ref() + .join("InRepoHelperRaisesFailure.xcresult"), + "tests/fixture-src/in-repo-helper-raises-failure", + ); + let file = files + .get("failsInsideHelper()") + .expect("the fixture's only test"); + assert!( + file.ends_with("InRepoHelperRaisesFailureTests.swift"), + "expected the test's own file, got {file}" + ); +} + +// The case no failure summary can serve: one test crashes inside a dependency with zero +// call-stack frames, the other is failed by a trait after its own frame is gone, so both +// failure-summary paths report no file — `data/test-crash-in-dependency.junit.xml` has none. +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_give_a_crashed_test_its_file() { + let files = declaration_files( + TEMP_DIR_TEST_CRASH_IN_DEPENDENCY + .as_ref() + .join("CrashInDependency.xcresult"), + "tests/fixture-src/crash-in-dependency", + ); + for (name, expected) in [ + ( + "testCrashesInsideDependency()", + "CrashInDependencyTests.swift", + ), + ( + "failsAfterItsOwnFrameIsGone()", + "TeardownFailureTests.swift", + ), + ] { + let file = files + .get(name) + .unwrap_or_else(|| panic!("{name} is missing from the report")); + assert!( + file.ends_with(expected), + "expected {name} to resolve to {expected}, got {file}" + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_resolve_an_objc_test_through_clangd() { + let files = declaration_files( + TEMP_DIR_TEST_OBJC_XCTEST + .as_ref() + .join("ObjcXCTest.xcresult"), + "tests/fixture-src/objc-xctest", + ); + let file = files + .get("testFailsInsideSharedHelper") + .expect("the fixture's only test"); + assert!( + file.ends_with("ObjcXCTestTests.m"), + "expected the test's own file, got {file}" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_find_a_top_level_swift_testing_function() { + let files = declaration_files( + TEMP_DIR_TEST_TOPLEVEL_SWIFT_TESTING + .as_ref() + .join("ToplevelSwiftTesting.xcresult"), + "tests/fixture-src/toplevel-swift-testing", + ); + let file = files + .get("failsInsideHelperWithoutASuite()") + .expect("the fixture's only test"); + assert!( + file.ends_with("ToplevelSwiftTestingTests.swift"), + "expected the test's own file, got {file}" + ); +} + +// Two things the declaration path reads that only a real bundle can confirm, both of which +// fail silently rather than loudly if the assumption is wrong. +// +// `nodeIdentifierURL` is meant to be the legacy record's `identifierURL` under another name, +// and ids are derived from it — if it is absent from the modern API, ids fall back to +// `nodeIdentifier` and every xcresult test case in the product gets a new identity. +// `get test-results summary`'s `startTime` is read as seconds since the Unix epoch; if it is +// an Apple reference-date offset instead, every timestamp lands three decades off. +// +// Both are checked as equivalence against the path already in production, on a bundle whose +// repo root is empty so no language server runs and nothing else can move. +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_keep_ids_and_timestamps_identical_to_the_legacy_path() { + fn ids_and_timestamps(xcresult: &XCResult) -> Vec<(String, String, String)> { + let mut junits = xcresult.generate_junits(); + assert_eq!(junits.len(), 1); + junits + .pop() + .unwrap() + .test_suites + .iter() + .flat_map(|test_suite| test_suite.test_cases.iter()) + .map(|test_case| { + let id = test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "id") + .map(|(_, value)| value.as_str().to_owned()) + .unwrap_or_default(); + ( + test_case.name.as_str().to_owned(), + id, + test_case + .timestamp + .map(|timestamp| timestamp.to_string()) + .unwrap_or_default(), + ) + }) + .collect() + } + + let path = TEMP_DIR_TEST_TIMESTAMP.as_ref().join("test1.xcresult"); + let path_str = path.to_str().unwrap(); + let empty_checkout = TempDir::default(); + + let legacy = XCResult::new( + path_str, + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + false, + ) + .unwrap(); + let declarations = XCResult::new_with_declaration_locations( + path_str, + ORG_URL_SLUG.clone(), + REPO_FULL_NAME.clone(), + empty_checkout.as_ref(), + Limits::default(), + ) + .unwrap(); + + let expected = ids_and_timestamps(&legacy); + assert!( + !expected.is_empty(), + "the fixture must have test cases for this to prove anything" + ); + assert!( + expected + .iter() + .all(|(_, id, timestamp)| !id.is_empty() && !timestamp.is_empty()), + "the fixture must carry ids and timestamps on the legacy path" + ); + pretty_assertions::assert_eq!(ids_and_timestamps(&declarations), expected); +} + +// Before the fix this bundle emitted tests="2" failures="0" — the inner suite's two tests +// and its failure all vanished. +#[cfg(target_os = "macos")] +#[rstest] +#[case::experimental_failure_summary(true)] +#[case::legacy_fallback(false)] +fn test_a_nested_suite_is_flattened_rather_than_dropped( + #[case] use_experimental_failure_summary: bool, +) { + assert_junit( + TEMP_DIR_TEST_NESTED_AND_PASSING + .as_ref() + .join("NestedAndPassing.xcresult"), + use_experimental_failure_summary, + include_str!("data/test-nested-and-passing.junit.xml"), + ); +} + +// Three of these four passed, so no failure summary names a file for any of them. The +// status is asserted too, or the fixture could drift to all-failing and still pass here. +#[cfg(target_os = "macos")] +#[test] +fn test_declaration_locations_give_a_passing_test_its_file() { + let report = declaration_report( + TEMP_DIR_TEST_NESTED_AND_PASSING + .as_ref() + .join("NestedAndPassing.xcresult"), + "tests/fixture-src/nested-and-passing", + ); + let cases: std::collections::HashMap = report + .test_suites + .iter() + .flat_map(|test_suite| test_suite.test_cases.iter()) + .map(|test_case| (test_case.name.as_str().to_owned(), test_case)) + .collect(); + + for (name, passed, expected) in [ + ("outerPasses()", true, "NestedAndPassingTests.swift"), + ("topLevelPasses()", true, "NestedAndPassingTests.swift"), + ("innerPasses()", true, "InnerSuite.swift"), + ("innerFails()", false, "InnerSuite.swift"), + ] { + let test_case = cases + .get(name) + .unwrap_or_else(|| panic!("{name} is missing from the report")); + assert_eq!( + matches!( + test_case.status, + quick_junit::TestCaseStatus::Success { .. } + ), + passed, + "{name} did not have the status the fixture was captured for" + ); + let file = test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "file") + .map(|(_, value)| value.as_str()) + .unwrap_or_else(|| panic!("{name} got no file from its declaration")); + assert!( + file.ends_with(expected), + "expected {name} to be declared in {expected}, got {file}" + ); + } +} + +// The flag is meant to move the `file` attribute and leave everything else alone, so every +// bundle the suite reads is run both ways and compared on all of it but that. Each case +// unpacks its own copy so another test cannot perturb the comparison. #[cfg(target_os = "macos")] #[rstest] +#[case::simple("tests/data/test1.xcresult.tar.gz", "test1.xcresult", None)] +#[case::complex("tests/data/test4.xcresult.tar.gz", "test4.xcresult", None)] +#[case::expected_failures( + "tests/data/test-ExpectedFailures.xcresult.tar.gz", + "test-ExpectedFailures.xcresult", + None +)] +#[case::swift_mix( + "tests/data/test-swift-mix.xcresult.tar.gz", + "test-swift-mix.xcresult", + None +)] +#[case::swift_without_test_suites( + "tests/data/test-swift-without-test-suites.xcresult.tar.gz", + "test-swift-without-test-suites.xcresult", + None +)] +#[case::swift_snapshot_testing( + "tests/data/test-swift-snapshot-testing.xcresult.tar.gz", + "SnapshotRepro.xcresult", + None +)] +#[case::dependency_raises_failure( + "tests/data/test-dependency-raises-failure.xcresult.tar.gz", + "DependencyRaisesFailure.xcresult", + Some("tests/fixture-src/dependency-raises-failure") +)] +#[case::in_repo_helper_raises_failure( + "tests/data/test-in-repo-helper-raises-failure.xcresult.tar.gz", + "InRepoHelperRaisesFailure.xcresult", + Some("tests/fixture-src/in-repo-helper-raises-failure") +)] +#[case::crash_in_dependency( + "tests/data/test-crash-in-dependency.xcresult.tar.gz", + "CrashInDependency.xcresult", + Some("tests/fixture-src/crash-in-dependency") +)] +#[case::objc_xctest( + "tests/data/test-objc-xctest.xcresult.tar.gz", + "ObjcXCTest.xcresult", + Some("tests/fixture-src/objc-xctest") +)] +#[case::toplevel_swift_testing( + "tests/data/test-toplevel-swift-testing.xcresult.tar.gz", + "ToplevelSwiftTesting.xcresult", + Some("tests/fixture-src/toplevel-swift-testing") +)] +#[case::nested_and_passing( + "tests/data/test-nested-and-passing.xcresult.tar.gz", + "NestedAndPassing.xcresult", + Some("tests/fixture-src/nested-and-passing") +)] #[case::inherited_test( "tests/data/test-inherited-test.xcresult.tar.gz", "InheritedTest.xcresult", @@ -165,5 +482,5 @@ fn test_the_declaration_flag_moves_the_file_and_nothing_else( #[case] bundle: &str, #[case] repo_root: Option<&str>, ) { - common::assert_the_declaration_flag_moves_only_the_file(archive, bundle, repo_root); + assert_the_declaration_flag_moves_only_the_file(archive, bundle, repo_root); } diff --git a/xcresult/tests/xcresult.rs b/xcresult/tests/xcresult.rs index 48cac563..4b842144 100644 --- a/xcresult/tests/xcresult.rs +++ b/xcresult/tests/xcresult.rs @@ -1,19 +1,13 @@ -use std::path::Path; - use lazy_static::lazy_static; use rstest::rstest; use temp_testdir::TempDir; -#[cfg(target_os = "macos")] -use xcresult::test_locations::Limits; use xcresult::xcresult::XCResult; mod common; -use common::{ORG_URL_SLUG, REPO_FULL_NAME, unpack_archive_to_temp_dir}; #[cfg(target_os = "macos")] -use common::{ - assert_the_declaration_flag_moves_only_the_file, declaration_files, declaration_report, -}; +use common::assert_junit; +use common::{ORG_URL_SLUG, REPO_FULL_NAME, unpack_archive_to_temp_dir}; lazy_static! { static ref TEMP_DIR_TEST_1: TempDir = @@ -252,31 +246,6 @@ fn test_swift_snapshot_testing_trait_failure_uses_assertion_file( // bundle must exhibit and how to regenerate it. // // The two cases expect different JUnit because they have different sources -// available: only the experimental path reads the per-test failure summary, and so -// the call stack, so it is the only one that can produce a `FileSource::TestFrame`. -// The legacy path sees `FileSource::DocumentLocation` alone. -#[cfg(target_os = "macos")] -fn assert_junit>( - bundle_path: T, - use_experimental_failure_summary: bool, - expected_junit_xml: &str, -) { - let path_str = bundle_path.as_ref().to_str().unwrap(); - let xcresult = XCResult::new( - path_str, - ORG_URL_SLUG.clone(), - REPO_FULL_NAME.clone(), - use_experimental_failure_summary, - ); - assert!(xcresult.is_ok()); - - let mut junits = xcresult.unwrap().generate_junits(); - assert_eq!(junits.len(), 1); - let junit = junits.pop().unwrap(); - let mut junit_writer: Vec = Vec::new(); - junit.serialize(&mut junit_writer).unwrap(); - pretty_assertions::assert_eq!(String::from_utf8(junit_writer).unwrap(), expected_junit_xml); -} // `FileSource::TestFrame` is the only usable source. The failure is recorded inside // the dependency, so `RaisedFrom`, `SourceCodeLocation` and the innermost @@ -630,315 +599,3 @@ fn test_xcresult_with_variant_id_generation() { ); } } - -// Every file this bundle's failure summary offers is under `SourcePackages/checkouts/`. -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_prefer_the_tests_own_file_over_a_vendored_dependency() { - let files = declaration_files( - TEMP_DIR_TEST_DEPENDENCY_RAISES_FAILURE - .as_ref() - .join("DependencyRaisesFailure.xcresult"), - "tests/fixture-src/dependency-raises-failure", - ); - let file = files - .get("failsInsideDependency()") - .expect("the fixture's only test"); - assert!( - file.ends_with("DependencyRaisesFailureTests.swift"), - "expected the test's own file, got {file}" - ); -} - -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_prefer_the_tests_own_file_over_an_in_repo_helper() { - let files = declaration_files( - TEMP_DIR_TEST_IN_REPO_HELPER_RAISES_FAILURE - .as_ref() - .join("InRepoHelperRaisesFailure.xcresult"), - "tests/fixture-src/in-repo-helper-raises-failure", - ); - let file = files - .get("failsInsideHelper()") - .expect("the fixture's only test"); - assert!( - file.ends_with("InRepoHelperRaisesFailureTests.swift"), - "expected the test's own file, got {file}" - ); -} - -// The case no failure summary can serve: one test crashes inside a dependency with zero -// call-stack frames, the other is failed by a trait after its own frame is gone, so both -// failure-summary paths report no file — `data/test-crash-in-dependency.junit.xml` has none. -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_give_a_crashed_test_its_file() { - let files = declaration_files( - TEMP_DIR_TEST_CRASH_IN_DEPENDENCY - .as_ref() - .join("CrashInDependency.xcresult"), - "tests/fixture-src/crash-in-dependency", - ); - for (name, expected) in [ - ( - "testCrashesInsideDependency()", - "CrashInDependencyTests.swift", - ), - ( - "failsAfterItsOwnFrameIsGone()", - "TeardownFailureTests.swift", - ), - ] { - let file = files - .get(name) - .unwrap_or_else(|| panic!("{name} is missing from the report")); - assert!( - file.ends_with(expected), - "expected {name} to resolve to {expected}, got {file}" - ); - } -} - -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_resolve_an_objc_test_through_clangd() { - let files = declaration_files( - TEMP_DIR_TEST_OBJC_XCTEST - .as_ref() - .join("ObjcXCTest.xcresult"), - "tests/fixture-src/objc-xctest", - ); - let file = files - .get("testFailsInsideSharedHelper") - .expect("the fixture's only test"); - assert!( - file.ends_with("ObjcXCTestTests.m"), - "expected the test's own file, got {file}" - ); -} - -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_find_a_top_level_swift_testing_function() { - let files = declaration_files( - TEMP_DIR_TEST_TOPLEVEL_SWIFT_TESTING - .as_ref() - .join("ToplevelSwiftTesting.xcresult"), - "tests/fixture-src/toplevel-swift-testing", - ); - let file = files - .get("failsInsideHelperWithoutASuite()") - .expect("the fixture's only test"); - assert!( - file.ends_with("ToplevelSwiftTestingTests.swift"), - "expected the test's own file, got {file}" - ); -} - -// Two things the declaration path reads that only a real bundle can confirm, both of which -// fail silently rather than loudly if the assumption is wrong. -// -// `nodeIdentifierURL` is meant to be the legacy record's `identifierURL` under another name, -// and ids are derived from it — if it is absent from the modern API, ids fall back to -// `nodeIdentifier` and every xcresult test case in the product gets a new identity. -// `get test-results summary`'s `startTime` is read as seconds since the Unix epoch; if it is -// an Apple reference-date offset instead, every timestamp lands three decades off. -// -// Both are checked as equivalence against the path already in production, on a bundle whose -// repo root is empty so no language server runs and nothing else can move. -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_keep_ids_and_timestamps_identical_to_the_legacy_path() { - fn ids_and_timestamps(xcresult: &XCResult) -> Vec<(String, String, String)> { - let mut junits = xcresult.generate_junits(); - assert_eq!(junits.len(), 1); - junits - .pop() - .unwrap() - .test_suites - .iter() - .flat_map(|test_suite| test_suite.test_cases.iter()) - .map(|test_case| { - let id = test_case - .extra - .iter() - .find(|(key, _)| key.as_str() == "id") - .map(|(_, value)| value.as_str().to_owned()) - .unwrap_or_default(); - ( - test_case.name.as_str().to_owned(), - id, - test_case - .timestamp - .map(|timestamp| timestamp.to_string()) - .unwrap_or_default(), - ) - }) - .collect() - } - - let path = TEMP_DIR_TEST_TIMESTAMP.as_ref().join("test1.xcresult"); - let path_str = path.to_str().unwrap(); - let empty_checkout = TempDir::default(); - - let legacy = XCResult::new( - path_str, - ORG_URL_SLUG.clone(), - REPO_FULL_NAME.clone(), - false, - ) - .unwrap(); - let declarations = XCResult::new_with_declaration_locations( - path_str, - ORG_URL_SLUG.clone(), - REPO_FULL_NAME.clone(), - empty_checkout.as_ref(), - Limits::default(), - ) - .unwrap(); - - let expected = ids_and_timestamps(&legacy); - assert!( - !expected.is_empty(), - "the fixture must have test cases for this to prove anything" - ); - assert!( - expected - .iter() - .all(|(_, id, timestamp)| !id.is_empty() && !timestamp.is_empty()), - "the fixture must carry ids and timestamps on the legacy path" - ); - pretty_assertions::assert_eq!(ids_and_timestamps(&declarations), expected); -} - -// Before the fix this bundle emitted tests="2" failures="0" — the inner suite's two tests -// and its failure all vanished. -#[cfg(target_os = "macos")] -#[rstest] -#[case::experimental_failure_summary(true)] -#[case::legacy_fallback(false)] -fn test_a_nested_suite_is_flattened_rather_than_dropped( - #[case] use_experimental_failure_summary: bool, -) { - assert_junit( - TEMP_DIR_TEST_NESTED_AND_PASSING - .as_ref() - .join("NestedAndPassing.xcresult"), - use_experimental_failure_summary, - include_str!("data/test-nested-and-passing.junit.xml"), - ); -} - -// Three of these four passed, so no failure summary names a file for any of them. The -// status is asserted too, or the fixture could drift to all-failing and still pass here. -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_give_a_passing_test_its_file() { - let report = declaration_report( - TEMP_DIR_TEST_NESTED_AND_PASSING - .as_ref() - .join("NestedAndPassing.xcresult"), - "tests/fixture-src/nested-and-passing", - ); - let cases: std::collections::HashMap = report - .test_suites - .iter() - .flat_map(|test_suite| test_suite.test_cases.iter()) - .map(|test_case| (test_case.name.as_str().to_owned(), test_case)) - .collect(); - - for (name, passed, expected) in [ - ("outerPasses()", true, "NestedAndPassingTests.swift"), - ("topLevelPasses()", true, "NestedAndPassingTests.swift"), - ("innerPasses()", true, "InnerSuite.swift"), - ("innerFails()", false, "InnerSuite.swift"), - ] { - let test_case = cases - .get(name) - .unwrap_or_else(|| panic!("{name} is missing from the report")); - assert_eq!( - matches!( - test_case.status, - quick_junit::TestCaseStatus::Success { .. } - ), - passed, - "{name} did not have the status the fixture was captured for" - ); - let file = test_case - .extra - .iter() - .find(|(key, _)| key.as_str() == "file") - .map(|(_, value)| value.as_str()) - .unwrap_or_else(|| panic!("{name} got no file from its declaration")); - assert!( - file.ends_with(expected), - "expected {name} to be declared in {expected}, got {file}" - ); - } -} - -// The flag is meant to move the `file` attribute and leave everything else alone, so every -// bundle the suite reads is run both ways and compared on all of it but that. Each case -// unpacks its own copy so another test cannot perturb the comparison. -#[cfg(target_os = "macos")] -#[rstest] -#[case::simple("tests/data/test1.xcresult.tar.gz", "test1.xcresult", None)] -#[case::complex("tests/data/test4.xcresult.tar.gz", "test4.xcresult", None)] -#[case::expected_failures( - "tests/data/test-ExpectedFailures.xcresult.tar.gz", - "test-ExpectedFailures.xcresult", - None -)] -#[case::swift_mix( - "tests/data/test-swift-mix.xcresult.tar.gz", - "test-swift-mix.xcresult", - None -)] -#[case::swift_without_test_suites( - "tests/data/test-swift-without-test-suites.xcresult.tar.gz", - "test-swift-without-test-suites.xcresult", - None -)] -#[case::swift_snapshot_testing( - "tests/data/test-swift-snapshot-testing.xcresult.tar.gz", - "SnapshotRepro.xcresult", - None -)] -#[case::dependency_raises_failure( - "tests/data/test-dependency-raises-failure.xcresult.tar.gz", - "DependencyRaisesFailure.xcresult", - Some("tests/fixture-src/dependency-raises-failure") -)] -#[case::in_repo_helper_raises_failure( - "tests/data/test-in-repo-helper-raises-failure.xcresult.tar.gz", - "InRepoHelperRaisesFailure.xcresult", - Some("tests/fixture-src/in-repo-helper-raises-failure") -)] -#[case::crash_in_dependency( - "tests/data/test-crash-in-dependency.xcresult.tar.gz", - "CrashInDependency.xcresult", - Some("tests/fixture-src/crash-in-dependency") -)] -#[case::objc_xctest( - "tests/data/test-objc-xctest.xcresult.tar.gz", - "ObjcXCTest.xcresult", - Some("tests/fixture-src/objc-xctest") -)] -#[case::toplevel_swift_testing( - "tests/data/test-toplevel-swift-testing.xcresult.tar.gz", - "ToplevelSwiftTesting.xcresult", - Some("tests/fixture-src/toplevel-swift-testing") -)] -#[case::nested_and_passing( - "tests/data/test-nested-and-passing.xcresult.tar.gz", - "NestedAndPassing.xcresult", - Some("tests/fixture-src/nested-and-passing") -)] -fn test_the_declaration_flag_moves_the_file_and_nothing_else( - #[case] archive: &str, - #[case] bundle: &str, - #[case] repo_root: Option<&str>, -) { - assert_the_declaration_flag_moves_only_the_file(archive, bundle, repo_root); -} From 215a6ddd77ccef92dab585f78821ec2632cc2c10 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Fri, 4 Sep 2026 00:01:00 -0700 Subject: [PATCH 12/24] test(xcresult): collapse the declaration tests that were the same test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six of them differed only in which fixture they read: unpack a bundle, ask `declaration_files` for it, assert a case resolves to the file it is written in. They become one table of six cases, which also drops five of the file's fixture `lazy_static`s — each case unpacks its own bundle. `keep_ids_and_timestamps_identical_to_the_legacy_path` looked subsumed by the flag-parity test, whose comparison already covers both fields, and was not: it carried a guard the parity assertion lacked. Two paths that both emit an empty id compare equal, so the comparison can pass while proving nothing — which is the failure mode worth catching, since a missing `nodeIdentifierURL` would silently re-identify every xcresult test case in the product and a `startTime` read against the wrong epoch would put every timestamp three decades out. The guard moves into the shared assertion instead, so all fifteen parity cases get it rather than one bundle, and the test folds in as the fifteenth case. Checked against every fixture first: all 558 cases across the fifteen bundles carry both fields, so the guard holds. `shape` now returns its columns field-wise so the guard reads the id and timestamp rather than sniffing their rendering, and blanking the id in `shape` makes all fifteen cases fail, so the guard is doing something. declaration_locations.rs 486 -> 339 lines, 13 tests -> 7 suite unchanged at 110 Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/tests/common/mod.rs | 26 ++- xcresult/tests/declaration_locations.rs | 293 ++++++------------------ 2 files changed, 93 insertions(+), 226 deletions(-) diff --git a/xcresult/tests/common/mod.rs b/xcresult/tests/common/mod.rs index 9b46f087..72f6be4d 100644 --- a/xcresult/tests/common/mod.rs +++ b/xcresult/tests/common/mod.rs @@ -97,7 +97,12 @@ pub fn assert_the_declaration_flag_moves_only_the_file( bundle: &str, repo_root: Option<&str>, ) { - fn shape(xcresult: &xcresult::xcresult::XCResult) -> Vec { + /// Everything the flag must not change: which suites and cases exist, their ids, + /// statuses and timestamps. Returned field-wise so the guard below can look at the id + /// and timestamp themselves rather than at their rendering. + fn shape( + xcresult: &xcresult::xcresult::XCResult, + ) -> Vec<(String, String, String, String, String)> { let mut junits = xcresult.generate_junits(); assert_eq!(junits.len(), 1); let junit = junits.pop().unwrap(); @@ -110,12 +115,11 @@ pub fn assert_the_declaration_flag_moves_only_the_file( .find(|(key, _)| key.as_str() == "id") .map(|(_, value)| value.as_str().to_owned()) .unwrap_or_default(); - rows.push(format!( - "{} | {} | {} | {:?} | {}", - test_suite.name.as_str(), - test_case.name.as_str(), + rows.push(( + test_suite.name.as_str().to_owned(), + test_case.name.as_str().to_owned(), id, - test_case.status, + format!("{:?}", test_case.status), test_case .timestamp .map(|timestamp| timestamp.to_string()) @@ -153,6 +157,16 @@ pub fn assert_the_declaration_flag_moves_only_the_file( let expected = shape(&default); assert!(!expected.is_empty(), "the bundle must have test cases"); + // Without this the comparison passes vacuously: two paths that both emit an empty id + // or timestamp are equal. `nodeIdentifierURL` going missing would silently re-identify + // every xcresult test case in the product, and a `startTime` read against the wrong + // epoch would land every timestamp three decades off — both are quiet failures. + assert!( + expected + .iter() + .all(|(_, _, id, _, timestamp)| !id.is_empty() && !timestamp.is_empty()), + "the bundle must carry an id and a timestamp on every case for this to prove anything" + ); pretty_assertions::assert_eq!(shape(&declarations), expected); for file in declaration_files(&bundle_path, root).values() { diff --git a/xcresult/tests/declaration_locations.rs b/xcresult/tests/declaration_locations.rs index 6c129880..f29f577f 100644 --- a/xcresult/tests/declaration_locations.rs +++ b/xcresult/tests/declaration_locations.rs @@ -8,34 +8,19 @@ mod common; use common::{ - ORG_URL_SLUG, REPO_FULL_NAME, assert_junit, assert_the_declaration_flag_moves_only_the_file, - declaration_files, declaration_report, unpack_archive_to_temp_dir, + assert_junit, assert_the_declaration_flag_moves_only_the_file, declaration_files, + declaration_report, unpack_archive_to_temp_dir, }; use lazy_static::lazy_static; use rstest::rstest; use temp_testdir::TempDir; -use xcresult::test_locations::{Limits, TestKey}; -use xcresult::xcresult::XCResult; +use xcresult::test_locations::TestKey; lazy_static! { - static ref TEMP_DIR_TEST_DEPENDENCY_RAISES_FAILURE: TempDir = - unpack_archive_to_temp_dir("tests/data/test-dependency-raises-failure.xcresult.tar.gz"); - static ref TEMP_DIR_TEST_IN_REPO_HELPER_RAISES_FAILURE: TempDir = - unpack_archive_to_temp_dir("tests/data/test-in-repo-helper-raises-failure.xcresult.tar.gz"); - static ref TEMP_DIR_TEST_CRASH_IN_DEPENDENCY: TempDir = - unpack_archive_to_temp_dir("tests/data/test-crash-in-dependency.xcresult.tar.gz"); - static ref TEMP_DIR_TEST_OBJC_XCTEST: TempDir = - unpack_archive_to_temp_dir("tests/data/test-objc-xctest.xcresult.tar.gz"); - static ref TEMP_DIR_TEST_TOPLEVEL_SWIFT_TESTING: TempDir = - unpack_archive_to_temp_dir("tests/data/test-toplevel-swift-testing.xcresult.tar.gz"); static ref TEMP_DIR_TEST_NESTED_AND_PASSING: TempDir = unpack_archive_to_temp_dir("tests/data/test-nested-and-passing.xcresult.tar.gz"); - static ref TEMP_DIR_TEST_TIMESTAMP: TempDir = - unpack_archive_to_temp_dir("tests/data/test-timestamp.xcresult.tar.gz"); static ref TEMP_DIR_TEST_INHERITED_TEST: TempDir = unpack_archive_to_temp_dir("tests/data/test-inherited-test.xcresult.tar.gz"); - static ref TEMP_DIR_TEST_OBJC_CATEGORY: TempDir = - unpack_archive_to_temp_dir("tests/data/test-objc-category.xcresult.tar.gz"); } // XCTest runs a base class's `test*` method again under every concrete subclass, so the @@ -88,27 +73,6 @@ fn test_an_inherited_test_is_attributed_to_the_suite_that_ran_it() { } } -// A category's `documentSymbol` container is `ObjcCategoryTests(Extra)`, and the class's -// own file declares no tests at all — so if that name is not read back as the class it -// extends, the declaration is never matched and the file falls to the class's file. -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_resolve_a_test_declared_in_an_objc_category() { - let files = common::declaration_files( - TEMP_DIR_TEST_OBJC_CATEGORY - .as_ref() - .join("ObjcCategory.xcresult"), - "tests/fixture-src/objc-category", - ); - let file = files - .get("testDeclaredInACategory") - .unwrap_or_else(|| panic!("the test is missing from the report (found {files:?})")); - assert!( - file.ends_with("ObjcCategoryTests+Extra.m"), - "expected the category's file, got {file}" - ); -} - #[rstest] #[case::plain( "test://com.apple.xcode/InRepoHelper/InRepoHelperTests/Suite/case()", @@ -127,6 +91,75 @@ fn an_identifier_url_names_the_target(#[case] url: &str, #[case] expected: Optio ); } +// Every one of these bundles names some other file in its failure summary — a vendored +// checkout, an in-repo helper, or nothing at all — so each case is a shape where the +// declaration is the only source that can name the file the test is written in. +#[cfg(target_os = "macos")] +#[rstest] +#[case::vendored_dependency( + "tests/data/test-dependency-raises-failure.xcresult.tar.gz", + "DependencyRaisesFailure.xcresult", + "tests/fixture-src/dependency-raises-failure", + &[("failsInsideDependency()", "DependencyRaisesFailureTests.swift")] +)] +#[case::in_repo_helper( + "tests/data/test-in-repo-helper-raises-failure.xcresult.tar.gz", + "InRepoHelperRaisesFailure.xcresult", + "tests/fixture-src/in-repo-helper-raises-failure", + &[("failsInsideHelper()", "InRepoHelperRaisesFailureTests.swift")] +)] +// Neither test reaches its own frame, so no failure summary can serve either of them: +// one crashes inside the dependency, the other is failed by a trait after its frame is +// gone. `data/test-crash-in-dependency.junit.xml` reports no file for both. +#[case::crashed_and_torn_down( + "tests/data/test-crash-in-dependency.xcresult.tar.gz", + "CrashInDependency.xcresult", + "tests/fixture-src/crash-in-dependency", + &[ + ("testCrashesInsideDependency()", "CrashInDependencyTests.swift"), + ("failsAfterItsOwnFrameIsGone()", "TeardownFailureTests.swift"), + ] +)] +#[case::objc_through_clangd( + "tests/data/test-objc-xctest.xcresult.tar.gz", + "ObjcXCTest.xcresult", + "tests/fixture-src/objc-xctest", + &[("testFailsInsideSharedHelper", "ObjcXCTestTests.m")] +)] +#[case::top_level_swift_testing_function( + "tests/data/test-toplevel-swift-testing.xcresult.tar.gz", + "ToplevelSwiftTesting.xcresult", + "tests/fixture-src/toplevel-swift-testing", + &[("failsInsideHelperWithoutASuite()", "ToplevelSwiftTestingTests.swift")] +)] +// A category's `documentSymbol` container is `ObjcCategoryTests(Extra)`, and the class's +// own file declares no tests at all — so unless that name is read back as the class it +// extends, the declaration is never matched and the file falls to the class's file. +#[case::declared_in_an_objc_category( + "tests/data/test-objc-category.xcresult.tar.gz", + "ObjcCategory.xcresult", + "tests/fixture-src/objc-category", + &[("testDeclaredInACategory", "ObjcCategoryTests+Extra.m")] +)] +fn test_a_declaration_names_the_file_the_test_is_written_in( + #[case] archive: &str, + #[case] bundle: &str, + #[case] repo_root: &str, + #[case] expected: &[(&str, &str)], +) { + let temp_dir = unpack_archive_to_temp_dir(archive); + let files = declaration_files(temp_dir.as_ref().join(bundle), repo_root); + for (name, suffix) in expected { + let file = files + .get(*name) + .unwrap_or_else(|| panic!("{name} is missing from the report (found {files:?})")); + assert!( + file.ends_with(suffix), + "expected {name} to resolve to {suffix}, got {file}" + ); + } +} + // A checkout that declares none of the tests. Where a failure surfaced is not where the // test is written, so with nothing to resolve the reported file is absent rather than the // helper the failure came from — that path resolves the wrong codeowners. @@ -163,187 +196,6 @@ fn test_a_test_with_no_declaration_in_the_checkout_gets_no_file() { } } -// Every file this bundle's failure summary offers is under `SourcePackages/checkouts/`. -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_prefer_the_tests_own_file_over_a_vendored_dependency() { - let files = declaration_files( - TEMP_DIR_TEST_DEPENDENCY_RAISES_FAILURE - .as_ref() - .join("DependencyRaisesFailure.xcresult"), - "tests/fixture-src/dependency-raises-failure", - ); - let file = files - .get("failsInsideDependency()") - .expect("the fixture's only test"); - assert!( - file.ends_with("DependencyRaisesFailureTests.swift"), - "expected the test's own file, got {file}" - ); -} - -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_prefer_the_tests_own_file_over_an_in_repo_helper() { - let files = declaration_files( - TEMP_DIR_TEST_IN_REPO_HELPER_RAISES_FAILURE - .as_ref() - .join("InRepoHelperRaisesFailure.xcresult"), - "tests/fixture-src/in-repo-helper-raises-failure", - ); - let file = files - .get("failsInsideHelper()") - .expect("the fixture's only test"); - assert!( - file.ends_with("InRepoHelperRaisesFailureTests.swift"), - "expected the test's own file, got {file}" - ); -} - -// The case no failure summary can serve: one test crashes inside a dependency with zero -// call-stack frames, the other is failed by a trait after its own frame is gone, so both -// failure-summary paths report no file — `data/test-crash-in-dependency.junit.xml` has none. -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_give_a_crashed_test_its_file() { - let files = declaration_files( - TEMP_DIR_TEST_CRASH_IN_DEPENDENCY - .as_ref() - .join("CrashInDependency.xcresult"), - "tests/fixture-src/crash-in-dependency", - ); - for (name, expected) in [ - ( - "testCrashesInsideDependency()", - "CrashInDependencyTests.swift", - ), - ( - "failsAfterItsOwnFrameIsGone()", - "TeardownFailureTests.swift", - ), - ] { - let file = files - .get(name) - .unwrap_or_else(|| panic!("{name} is missing from the report")); - assert!( - file.ends_with(expected), - "expected {name} to resolve to {expected}, got {file}" - ); - } -} - -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_resolve_an_objc_test_through_clangd() { - let files = declaration_files( - TEMP_DIR_TEST_OBJC_XCTEST - .as_ref() - .join("ObjcXCTest.xcresult"), - "tests/fixture-src/objc-xctest", - ); - let file = files - .get("testFailsInsideSharedHelper") - .expect("the fixture's only test"); - assert!( - file.ends_with("ObjcXCTestTests.m"), - "expected the test's own file, got {file}" - ); -} - -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_find_a_top_level_swift_testing_function() { - let files = declaration_files( - TEMP_DIR_TEST_TOPLEVEL_SWIFT_TESTING - .as_ref() - .join("ToplevelSwiftTesting.xcresult"), - "tests/fixture-src/toplevel-swift-testing", - ); - let file = files - .get("failsInsideHelperWithoutASuite()") - .expect("the fixture's only test"); - assert!( - file.ends_with("ToplevelSwiftTestingTests.swift"), - "expected the test's own file, got {file}" - ); -} - -// Two things the declaration path reads that only a real bundle can confirm, both of which -// fail silently rather than loudly if the assumption is wrong. -// -// `nodeIdentifierURL` is meant to be the legacy record's `identifierURL` under another name, -// and ids are derived from it — if it is absent from the modern API, ids fall back to -// `nodeIdentifier` and every xcresult test case in the product gets a new identity. -// `get test-results summary`'s `startTime` is read as seconds since the Unix epoch; if it is -// an Apple reference-date offset instead, every timestamp lands three decades off. -// -// Both are checked as equivalence against the path already in production, on a bundle whose -// repo root is empty so no language server runs and nothing else can move. -#[cfg(target_os = "macos")] -#[test] -fn test_declaration_locations_keep_ids_and_timestamps_identical_to_the_legacy_path() { - fn ids_and_timestamps(xcresult: &XCResult) -> Vec<(String, String, String)> { - let mut junits = xcresult.generate_junits(); - assert_eq!(junits.len(), 1); - junits - .pop() - .unwrap() - .test_suites - .iter() - .flat_map(|test_suite| test_suite.test_cases.iter()) - .map(|test_case| { - let id = test_case - .extra - .iter() - .find(|(key, _)| key.as_str() == "id") - .map(|(_, value)| value.as_str().to_owned()) - .unwrap_or_default(); - ( - test_case.name.as_str().to_owned(), - id, - test_case - .timestamp - .map(|timestamp| timestamp.to_string()) - .unwrap_or_default(), - ) - }) - .collect() - } - - let path = TEMP_DIR_TEST_TIMESTAMP.as_ref().join("test1.xcresult"); - let path_str = path.to_str().unwrap(); - let empty_checkout = TempDir::default(); - - let legacy = XCResult::new( - path_str, - ORG_URL_SLUG.clone(), - REPO_FULL_NAME.clone(), - false, - ) - .unwrap(); - let declarations = XCResult::new_with_declaration_locations( - path_str, - ORG_URL_SLUG.clone(), - REPO_FULL_NAME.clone(), - empty_checkout.as_ref(), - Limits::default(), - ) - .unwrap(); - - let expected = ids_and_timestamps(&legacy); - assert!( - !expected.is_empty(), - "the fixture must have test cases for this to prove anything" - ); - assert!( - expected - .iter() - .all(|(_, id, timestamp)| !id.is_empty() && !timestamp.is_empty()), - "the fixture must carry ids and timestamps on the legacy path" - ); - pretty_assertions::assert_eq!(ids_and_timestamps(&declarations), expected); -} - // Before the fix this bundle emitted tests="2" failures="0" — the inner suite's two tests // and its failure all vanished. #[cfg(target_os = "macos")] @@ -467,6 +319,7 @@ fn test_declaration_locations_give_a_passing_test_its_file() { "NestedAndPassing.xcresult", Some("tests/fixture-src/nested-and-passing") )] +#[case::timestamps("tests/data/test-timestamp.xcresult.tar.gz", "test1.xcresult", None)] #[case::inherited_test( "tests/data/test-inherited-test.xcresult.tar.gz", "InheritedTest.xcresult", From 4695f84a73a06ab1a95d5396e3dceb110749116d Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Wed, 9 Sep 2026 21:11:26 +0000 Subject: [PATCH 13/24] fix(xcresult): gate the macOS-only test imports off other platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every test in these files is `#[cfg(target_os = "macos")]`, but their imports were not — so on Linux and Windows `declaration_locations.rs` fails to compile against helpers that are gated out of `common`, and the other two carry unused imports. Only the test targets are affected, so `cargo build --all` misses it; it surfaces under `cargo nextest --workspace`, which is what CI runs. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/tests/bundle_reading.rs | 2 ++ xcresult/tests/declaration_locations.rs | 4 +++- xcresult/tests/xcresult.rs | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/xcresult/tests/bundle_reading.rs b/xcresult/tests/bundle_reading.rs index d1302e71..e3dcdfa2 100644 --- a/xcresult/tests/bundle_reading.rs +++ b/xcresult/tests/bundle_reading.rs @@ -7,7 +7,9 @@ mod common; +#[cfg(target_os = "macos")] use common::{ORG_URL_SLUG, REPO_FULL_NAME, entries, set_writable, unpack_archive_to_temp_dir}; +#[cfg(target_os = "macos")] use xcresult::xcresult::XCResult; // The case that matters now: a current bundle already carries `database.sqlite3`, so there diff --git a/xcresult/tests/declaration_locations.rs b/xcresult/tests/declaration_locations.rs index f29f577f..8d65b492 100644 --- a/xcresult/tests/declaration_locations.rs +++ b/xcresult/tests/declaration_locations.rs @@ -7,9 +7,11 @@ mod common; +use common::unpack_archive_to_temp_dir; +#[cfg(target_os = "macos")] use common::{ assert_junit, assert_the_declaration_flag_moves_only_the_file, declaration_files, - declaration_report, unpack_archive_to_temp_dir, + declaration_report, }; use lazy_static::lazy_static; use rstest::rstest; diff --git a/xcresult/tests/xcresult.rs b/xcresult/tests/xcresult.rs index 4b842144..ca3ba62a 100644 --- a/xcresult/tests/xcresult.rs +++ b/xcresult/tests/xcresult.rs @@ -1,4 +1,5 @@ use lazy_static::lazy_static; +#[cfg(target_os = "macos")] use rstest::rstest; use temp_testdir::TempDir; use xcresult::xcresult::XCResult; From 3c23c41b0d19c9e7a67a27e9804110ca61670e79 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Thu, 10 Sep 2026 04:52:08 +0000 Subject: [PATCH 14/24] fix(xcresult): attribute an inherited test to the class that declares it A test arriving under a suite that declares no such method was inherited -- a suite cannot run a method it does not have -- so the method is written in some base class, and that is the file the test is in. Reporting the concrete suite's file names a file the test does not appear in, which resolves the wrong codeowners. No inheritance graph is needed to know this, and there is no build-free way to get one: `documentSymbol` carries no inheritance at all, sourcekitd's `key.inheritedtypes` gives only the name as written, and resolving a chain is semantic. Upstream's own `SyntacticSwiftXCTestScanner` gives up at the same point. The test having run is the proof of inheritance, so the case name alone identifies the declaration. Two suites in one target can still declare the same case name, and nothing here can say which of them a third inherited from, so an ambiguous answer declines and the suite's own file stands in rather than naming a file at random. This replaces `an_unrelated_suite_does_not_borrow_another_suites_case`, which pinned the opposite: it guarded an input that cannot occur, since a suite that neither declares nor inherits a case never runs it. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/src/test_locations.rs | 213 +++++++++++++++++- xcresult/tests/declaration_locations.rs | 18 +- .../Tests/InheritedTestTests/BaseTests.swift | 2 +- .../InheritedTestTests/ConcreteTests.swift | 6 +- 4 files changed, 221 insertions(+), 18 deletions(-) diff --git a/xcresult/src/test_locations.rs b/xcresult/src/test_locations.rs index e2f30105..24de6dfc 100644 --- a/xcresult/src/test_locations.rs +++ b/xcresult/src/test_locations.rs @@ -178,13 +178,21 @@ impl TestLocationIndex { /// The file the test is written in, preferring the declaration of the method itself — /// a suite split across extensions declares each test in its own file. /// - /// A test run under a suite that does not declare it is inherited from a base class, - /// and reporting the base class would hand the test to whoever owns *that* file. The - /// concrete suite is the one that chose to run it, so it is the one reported. + /// A test run under a suite that does not declare it was inherited: a suite cannot run a + /// method it does not have, so the declaration is in some base class and that is the file + /// the test is written in. No inheritance graph is needed to reach that conclusion — the + /// test having run is the proof. + /// + /// One is needed to act on it whenever more than one class declares the case, though, + /// because an override is a declaration too: see [`Self::inherited_declaration`]. Where + /// nothing declares it, or several things do, the suite's own file stands in. pub fn lookup(&self, key: &TestKey) -> Option<&DeclarationSite> { if let Some(site) = self.method_declaration(key) { return Some(site); } + if let Some(site) = self.inherited_declaration(key) { + return Some(site); + } match key.suite.as_ref() { Some(suite) => self.suites.get(suite), // A top-level swift-testing test has no suite, so the function is all there is. @@ -192,6 +200,57 @@ impl TestLocationIndex { } } + /// The one declaration of `key`'s case under some other suite, which for a test that ran + /// is the base class it was inherited from. + /// + /// Only answers when there is exactly one candidate. Nothing here can tell an *ancestor* + /// that declares the method from an unrelated *sibling* that overrides it — an override + /// is a declaration of the same name, so by name alone the two are the same fact, and + /// separating them would need the inheritance graph, which is semantic and would cost a + /// build. So more than one candidate reports nothing and lets the suite's own file stand + /// in, rather than picking a file the test may well not be written in. + /// + /// Scoped to the target that ran it, since two modules can each declare a case of the + /// same name and those are never the same method. + fn inherited_declaration(&self, key: &TestKey) -> Option<&DeclarationSite> { + let target = self.targets.get(key); + let mut candidates: Vec<&DeclarationSite> = Vec::new(); + for (candidate, site) in &self.declarations { + if candidate.case != key.case || candidate.suite == key.suite { + continue; + } + if let Some(target) = target + && !is_in_target(&site.file, target) + { + continue; + } + // Two suites declaring it in one file is still one answer. + if !candidates.iter().any(|found| found.file == site.file) { + candidates.push(site); + } + } + + if candidates.len() > 1 { + // Sorted because the candidates come out of a `HashMap`, and a warning that + // names the same files in a different order every run is hard to trust. + let mut files = candidates + .iter() + .map(|site| site.file.as_str()) + .collect::>(); + files.sort_unstable(); + tracing::warn!( + "{} declares no {}, and {} places declare one -- any of them could be what it \ + inherited, so its own file is reported instead of guessing between {}", + key.suite.as_deref().unwrap_or("a top-level test"), + key.case, + files.len(), + files.join(", ") + ); + return None; + } + candidates.first().copied() + } + /// Only the method's own declaration, which is what decides whether there is still /// something worth parsing for. /// @@ -596,21 +655,161 @@ mod tests { ); } + // A suite cannot run a method it does not have, so a case arriving under a suite that + // declares no such method was inherited, and the one declaration of that name is the base + // class it came from. Nothing else in the checkout can be the answer. #[test] - fn an_unrelated_suite_does_not_borrow_another_suites_case() { + fn a_suite_that_does_not_declare_its_case_inherited_it() { let index = indexed( SWIFT_FILE, json!([container( - "SnapshotReproTests", + "BaseTests", SymbolKind::CLASS, (0, 2), vec![method("testExample()", 1)] )]), ); + assert_eq!( + index + .lookup(&key(Some("ChildTests"), "testExample")) + .map(|site| site.line), + Some(Some(2)), + "the inherited method resolves to the line it is written on" + ); + } + + // An override is its own declaration, so it answers before anything is inherited. + #[test] + fn an_override_keeps_its_own_declaration() { + let index = indexed( + SWIFT_FILE, + json!([ + container( + "BaseTests", + SymbolKind::CLASS, + (0, 2), + vec![method("testExample()", 1)] + ), + container( + "ChildTests", + SymbolKind::CLASS, + (3, 5), + vec![method("testExample()", 4)] + ) + ]), + ); + assert_eq!( + index + .lookup(&key(Some("ChildTests"), "testExample")) + .map(|site| site.line), + Some(Some(5)) + ); + } + + // The shape that matters in practice, and the reason this cannot be done by name: a + // sibling overriding the method declares it too. `ChildA` inherits from `Base` and + // `ChildB` overrides, so `testInherited` is declared in two files and neither the + // ancestor nor the sibling can be told apart from the case name -- which is what the + // `swift-test-xunit` fixture captures. + #[test] + fn a_sibling_override_makes_the_inherited_declaration_ambiguous() { + let mut index = indexed( + "/repo/Tests/MyCLITests/BaseTests.swift", + json!([container( + "BaseTests", + SymbolKind::CLASS, + (0, 2), + vec![method("testInherited()", 1)] + )]), + ); + index.collect( + &symbols(json!([container( + "ChildBTests", + SymbolKind::CLASS, + (0, 2), + vec![method("testInherited()", 1)] + )])), + Path::new("/repo/Tests/MyCLITests/ChildBTests.swift"), + None, + ); + index.suites.insert( + String::from("ChildATests"), + site("/repo/Tests/MyCLITests/ChildATests.swift"), + ); + let key = key(Some("ChildATests"), "testInherited"); + index + .targets + .insert(key.clone(), String::from("MyCLITests")); + assert!( + index.inherited_declaration(&key).is_none(), + "the ancestor and the override are indistinguishable by name" + ); + assert_eq!( + index.lookup(&key).map(|site| site.file.as_str().to_owned()), + Some(String::from("/repo/Tests/MyCLITests/ChildATests.swift")), + "so the suite's own file stands in" + ); + } + + // Two unrelated suites declare the case in different files, so which one a third + // inherited it from is unknowable here. Guessing would report a file the test is not + // written in, so the suite's own file stands in instead. + #[test] + fn an_ambiguous_inheritance_declines_rather_than_guessing() { + let mut index = indexed( + SWIFT_FILE, + json!([container( + "OneTests", + SymbolKind::CLASS, + (0, 2), + vec![method("testExample()", 1)] + )]), + ); + index.collect( + &symbols(json!([container( + "TwoTests", + SymbolKind::CLASS, + (0, 2), + vec![method("testExample()", 1)] + )])), + Path::new("/repo/Tests/TwoTests.swift"), + None, + ); + index.suites.insert( + String::from("ThirdTests"), + site("/repo/Tests/ThirdTests.swift"), + ); + + assert_eq!( index - .lookup(&key(Some("OtherTests"), "testExample")) - .is_none() + .lookup(&key(Some("ThirdTests"), "testExample")) + .map(|site| site.file.as_str().to_owned()), + Some(String::from("/repo/Tests/ThirdTests.swift")) + ); + } + + // The same-named case in another module is not what this one inherited, so the target + // has to bound the search even when only one candidate exists. + #[test] + fn a_case_of_the_same_name_in_another_target_is_not_inherited() { + let mut index = indexed( + "/repo/Tests/OtherFeatureTests/OtherTests.swift", + json!([container( + "OtherTests", + SymbolKind::CLASS, + (0, 2), + vec![method("testExample()", 1)] + )]), + ); + let key = key(Some("ChildTests"), "testExample"); + index + .targets + .insert(key.clone(), String::from("MyFeatureTests")); + + assert!( + index.lookup(&key).is_none(), + "no declaration under MyFeatureTests, and no suite either" ); } diff --git a/xcresult/tests/declaration_locations.rs b/xcresult/tests/declaration_locations.rs index 8d65b492..505bcffc 100644 --- a/xcresult/tests/declaration_locations.rs +++ b/xcresult/tests/declaration_locations.rs @@ -26,14 +26,16 @@ lazy_static! { } // XCTest runs a base class's `test*` method again under every concrete subclass, so the -// same method arrives twice under two different suites. Both raise the failure at the same -// line of `BaseTests.swift`, and `ConcreteTests.swift` is named nowhere in the bundle — so -// this only passes if the file comes from the suite that ran the test. Reporting the base -// class would hand `ConcreteTests`' failures to whoever owns `BaseTests.swift`, which is -// the misattribution the declaration path exists to prevent. +// same method arrives twice under two different suites. Neither arrival is written in +// `ConcreteTests.swift` — that file declares no test at all — so both report the base +// class's file, which is where the method they ran is actually written. +// +// A suite cannot run a method it does not have, so `ConcreteTests` having no declaration of +// its own is itself the proof that it inherited one, and the single declaration of that name +// in the checkout is where it came from. #[cfg(target_os = "macos")] #[test] -fn test_an_inherited_test_is_attributed_to_the_suite_that_ran_it() { +fn test_an_inherited_test_is_attributed_to_the_class_that_declares_it() { let report = common::declaration_report( TEMP_DIR_TEST_INHERITED_TEST .as_ref() @@ -58,9 +60,11 @@ fn test_an_inherited_test_is_attributed_to_the_suite_that_ran_it() { }) .collect::>(); + // Both arrivals of the one method, so both name the file it is declared in. That the + // suite differs and the file does not is the whole point. for (suite, expected) in [ ("BaseTests", "BaseTests.swift"), - ("ConcreteTests", "ConcreteTests.swift"), + ("ConcreteTests", "BaseTests.swift"), ] { let key = format!("InheritedTestTests.{suite}/testInheritedFails()"); let file = files diff --git a/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/BaseTests.swift b/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/BaseTests.swift index 248ba029..0a078fab 100644 --- a/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/BaseTests.swift +++ b/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/BaseTests.swift @@ -5,6 +5,6 @@ import XCTest // `BaseTests/testInheritedFails`, and once as `ConcreteTests/testInheritedFails`. class BaseTests: XCTestCase { func testInheritedFails() { - XCTFail("declared on the base class, run under whichever suite reported it") + XCTFail("declared on the base class, and reported against this file under either suite") } } diff --git a/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/ConcreteTests.swift b/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/ConcreteTests.swift index a6911beb..507857de 100644 --- a/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/ConcreteTests.swift +++ b/xcresult/tests/fixture-src/inherited-test/Tests/InheritedTestTests/ConcreteTests.swift @@ -1,6 +1,6 @@ import XCTest -// Declares no tests of its own. The file reported for `ConcreteTests/testInheritedFails` -// has to be this one: it is what codeowners resolve from, and this is the suite that chose -// to run the test, not whoever owns `BaseTests.swift`. +// Declares no tests of its own, which is what makes it worth capturing: the method it runs +// is written nowhere in this file, so `ConcreteTests/testInheritedFails` has to report +// `BaseTests.swift`. Attributing it here would name a file the test does not appear in. final class ConcreteTests: BaseTests {} From 00605b10e8e0fc411a180d3414b500e6d91ec687 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Thu, 10 Sep 2026 04:53:05 +0000 Subject: [PATCH 15/24] perf(xcresult): stream each source file to the server instead of holding it `didOpen` carries a file's whole text -- the protocol takes it inline, accepting neither a path nor a stream -- so asking a server what a file declares used to mean a `String` of it, `to_owned` into `TextDocumentItem`, `to_value` re-serializing that into the `Value` a `Notification` needs, and `Message::write`'s `to_string` of the finished message. Four copies of every source file in the checkout, one file at a time, unbounded in size. `Content-Length` precedes the body and a pipe cannot be rewound, so the length has to be known before any body byte goes out -- but that forces a measurement, not a buffer. `Serializer::serialize_str` is what forced the buffer: it wants the whole string contiguous. `collect_str` does not, and serde_json overrides it to push each fragment of a `Display` through its escaper straight into the writer. So the file is now read in 64 KiB chunks by a `Display` impl, twice: once escaped into a counting sink to measure, then again into a `BufWriter` over the server's stdin behind the header. Peak footprint per `didOpen` is flat in file size, and `a_file_larger_than_a_chunk_is_never_handed_over_whole` keeps it that way. Three things that fall out of this and are handled rather than discovered: - A read boundary splits multi-byte characters and `write_str` takes only valid UTF-8, so the incomplete tail of a chunk is carried into the next one. - Two reads can disagree if the file is rewritten between them, and a body that does not match its announced length misframes every message after it -- so the emitting pass is tallied too, and a mismatch abandons the server, which the caller already knows how to restart. - `Display::fmt` can only fail with a payload-free `fmt::Error`, so the real cause is stashed the way serde_json's own adapter does it, keeping the diagnostic that `read_to_string` used to give. Also caps file size, which nothing did before: `max_files` bounds how many files are parsed, not how big one may be. The cap is applied during the scan using the size the walker has already stat'd, so an oversized generated file is never opened, and skips are counted and logged rather than silent. Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/context.rs | 3 + cli/src/upload_command.rs | 10 + constants/src/lib.rs | 2 + xcresult/src/lsp.rs | 452 +++++++++++++++++++++++++++++++-- xcresult/src/main.rs | 11 + xcresult/src/test_locations.rs | 76 +++++- 6 files changed, 523 insertions(+), 31 deletions(-) diff --git a/cli/src/context.rs b/cli/src/context.rs index ef5e1f79..b35fdf66 100644 --- a/cli/src/context.rs +++ b/cli/src/context.rs @@ -147,6 +147,8 @@ pub fn gather_initial_test_context( xcresult_test_locations_request_timeout_secs, #[cfg(target_os = "macos")] xcresult_test_locations_retries, + #[cfg(target_os = "macos")] + xcresult_test_locations_max_file_bytes, .. } = upload_args; @@ -173,6 +175,7 @@ pub fn gather_initial_test_context( budget: Duration::from_secs(xcresult_test_locations_budget_secs), request_timeout: Duration::from_secs(xcresult_test_locations_request_timeout_secs), retries: xcresult_test_locations_retries, + max_file_bytes: xcresult_test_locations_max_file_bytes, }, }; diff --git a/cli/src/upload_command.rs b/cli/src/upload_command.rs index 403e13d3..2b9f320a 100644 --- a/cli/src/upload_command.rs +++ b/cli/src/upload_command.rs @@ -335,6 +335,16 @@ pub struct UploadArgs { hide = true )] pub xcresult_test_locations_retries: usize, + #[cfg(target_os = "macos")] + #[arg( + long, + env = constants::TRUNK_XCRESULT_TEST_LOCATIONS_MAX_FILE_BYTES_ENV, + help = "Largest source file to parse for declarations. One request carries the whole file, so an outsized generated one costs memory and usually a timeout for symbols it does not declare. Skipped without being read.", + required = false, + default_value_t = xcresult::test_locations::Limits::default().max_file_bytes, + hide = true + )] + pub xcresult_test_locations_max_file_bytes: u64, #[arg( long, env = constants::TRUNK_VALIDATION_REPORT_ENV, diff --git a/constants/src/lib.rs b/constants/src/lib.rs index 95b1e836..cc9b9639 100644 --- a/constants/src/lib.rs +++ b/constants/src/lib.rs @@ -69,6 +69,8 @@ pub const TRUNK_XCRESULT_TEST_LOCATIONS_BUDGET_SECS_ENV: &str = pub const TRUNK_XCRESULT_TEST_LOCATIONS_REQUEST_TIMEOUT_SECS_ENV: &str = "TRUNK_XCRESULT_TEST_LOCATIONS_REQUEST_TIMEOUT_SECS"; pub const TRUNK_XCRESULT_TEST_LOCATIONS_RETRIES_ENV: &str = "TRUNK_XCRESULT_TEST_LOCATIONS_RETRIES"; +pub const TRUNK_XCRESULT_TEST_LOCATIONS_MAX_FILE_BYTES_ENV: &str = + "TRUNK_XCRESULT_TEST_LOCATIONS_MAX_FILE_BYTES"; // RSpec-only: when set to "true", aborts the RSpec run if quarantine lookup fails. // Handled in rspec-trunk-flaky-tests/lib/trunk_spec_helper.rb, not the CLI. diff --git a/xcresult/src/lsp.rs b/xcresult/src/lsp.rs index fffdabb5..2d80caff 100644 --- a/xcresult/src/lsp.rs +++ b/xcresult/src/lsp.rs @@ -7,11 +7,43 @@ //! request was sent. Replies are matched by id, so a late one is discarded rather than //! misread — but the server has also shown it cannot keep up, so it is killed and the //! caller restarts it instead of waiting on it again. +//! +//! # How source files are read +//! +//! **No file is ever held in memory, whole or in part beyond `CHUNK`.** `didOpen` has to +//! carry a file's entire text — the protocol takes it inline, and neither a path nor a +//! stream — so the naive shape of this is a `String` per file, which for one generated +//! source file is tens of megabytes resident and several copies of it before serialization +//! is done. +//! +//! Instead each file is read **twice, in `CHUNK`-sized pieces, and never retained**: +//! +//! 1. **The measuring pass.** `FileText` reads a chunk, hands it to serde_json's escaper +//! via [`Serializer::collect_str`], and the escaped bytes land in a `Counting` writer +//! over [`io::sink`] — counted, then dropped. This yields the exact `Content-Length` +//! while holding nothing, and is also where an unreadable or non-UTF-8 file is caught, +//! before anything has been announced to the server. +//! 2. **The emitting pass.** The header goes out, then the same chunked read runs again, +//! this time escaping into a [`BufWriter`] over the server's stdin. +//! +//! Two reads rather than one is the price of not buffering; the second usually comes from +//! the page cache. Both passes escape through serde_json, so the length announced cannot +//! disagree with the body written — and `send_did_open` tallies the second +//! pass anyway, because a file rewritten between the two would otherwise misframe the +//! stream. Peak memory per `didOpen` is therefore flat in file size, which +//! `a_file_larger_than_a_chunk_is_never_handed_over_whole` exists to keep true. +//! +//! Reading in fixed-size pieces splits multi-byte characters, and [`fmt::Write::write_str`] +//! takes only valid UTF-8, so `FileText::stream` carries the incomplete tail of a chunk +//! over into the next one. use std::{ - io::{BufReader, Write}, + cell::Cell, + fmt, fs, + io::{self, BufReader, BufWriter, Read, Write}, path::Path, process::{Child, ChildStdin, Command, Stdio}, + str, sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}, thread, time::{Duration, Instant}, @@ -19,13 +51,21 @@ use std::{ use lsp_server::{Message, Notification, Request, RequestId, Response}; use lsp_types::{ - ClientCapabilities, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentSymbol, + ClientCapabilities, DidCloseTextDocumentParams, DocumentSymbol, DocumentSymbolClientCapabilities, DocumentSymbolParams, DocumentSymbolResponse, InitializeParams, PartialResultParams, TextDocumentClientCapabilities, TextDocumentIdentifier, - TextDocumentItem, Uri, WorkDoneProgressParams, - notification::{DidCloseTextDocument, DidOpenTextDocument, Initialized}, + Uri, WorkDoneProgressParams, + notification::{DidCloseTextDocument, DidOpenTextDocument, Initialized, Notification as _}, request::{DocumentSymbolRequest, Initialize}, }; +use serde::ser::{Serialize, SerializeStruct, Serializer}; + +/// How much of a file is read, and buffered towards the pipe, at a time. +/// +/// Nothing here ever holds a whole file: a `didOpen` for a 40 MB source file has the same +/// peak footprint as one for a 400 byte source file -- this buffer, plus the `BufWriter` of +/// the same size, plus at most three bytes of a character carried across a read boundary. +const CHUNK: usize = 64 * 1024; pub struct LanguageServer { process: Child, @@ -99,22 +139,20 @@ impl LanguageServer { /// The symbols `file_path` declares. The text is sent rather than left for the /// server to read, so this answers even where build settings do not resolve. + /// + /// The file is read in chunks and never held: see this module's "How source files are + /// read", and `send_did_open` below. pub fn document_symbols( &mut self, file_path: &Path, language_id: &str, - text: &str, timeout: Duration, ) -> Option> { let uri = file_uri(file_path).ok()?; - self.notify::(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: language_id.to_owned(), - version: 1, - text: text.to_owned(), - }, - }); + if let Err(error) = self.send_did_open(&uri, language_id, file_path) { + tracing::debug!("could not send {}: {}", file_path.display(), error); + return None; + } let response = self .request::( DocumentSymbolParams { @@ -144,6 +182,78 @@ impl LanguageServer { self.broken } + /// `didOpen` for `path`, framed and written here rather than through + /// [`Message::write`], which reaches the pipe only via a `to_string` of the whole + /// message — one full copy of the file on top of the one being read. + /// + /// `Content-Length` precedes the body and a pipe cannot be rewound, so the length has to + /// be known before any of the body goes out. That is what the two passes are for: the + /// first serializes into [`io::sink`] to measure, the second writes the header and + /// streams the body. serde_json does the escaping in both, so the count cannot disagree + /// with what is emitted. + /// + /// Reading twice means the two passes can disagree if the file is rewritten in between, + /// and a body that does not match the header it was announced with desynchronises the + /// stream for every message after it. So the second pass is tallied too, and a mismatch + /// abandons the server rather than corrupting the rest of the scan — the same response + /// this already has for one that stops answering. + fn send_did_open(&mut self, uri: &Uri, language_id: &str, path: &Path) -> io::Result<()> { + if self.broken { + return Err(io::Error::other("language server was already abandoned")); + } + let text = FileText::new(path); + let params = DidOpenParams { + uri, + language_id, + text: &text, + }; + let message = Envelope { + method: DidOpenTextDocument::METHOD, + params: ¶ms, + }; + + let mut measured = Counting { + inner: io::sink(), + count: 0, + }; + // A read or encoding failure surfaces here, before anything is written -- so an + // unreadable or non-UTF-8 file costs this file's symbols rather than the stream. + serde_json::to_writer(&mut measured, &message) + .map_err(|error| text.take_error().unwrap_or_else(|| io::Error::other(error)))?; + let length = measured.count; + + // Scoped so the borrow of `stdin` ends before the failure path needs `self`. + let count = { + let mut out = BufWriter::with_capacity(CHUNK, &mut self.stdin); + write!(out, "Content-Length: {length}\r\n\r\n")?; + let mut written = Counting { + inner: &mut out, + count: 0, + }; + let sent = serde_json::to_writer(&mut written, &message) + .map_err(|error| text.take_error().unwrap_or_else(|| io::Error::other(error))); + let count = written.count; + // Flushed before reporting a failure: the header is already on its way, so as + // much of the body as exists has to follow it for the length check to mean + // anything. + let flushed = out.flush(); + sent?; + flushed?; + count + }; + + if count != length { + self.abandon::<()>( + DidOpenTextDocument::METHOD, + "changed size while it was being sent", + ); + return Err(io::Error::other(format!( + "announced {length} bytes and sent {count}" + ))); + } + Ok(()) + } + fn request( &mut self, params: R::Params, @@ -196,14 +306,18 @@ impl LanguageServer { } fn notify(&mut self, params: N::Params) { - if self.broken { - return; - } let Ok(params) = serde_json::to_value(params) else { return; }; + self.notify_value(N::METHOD, params); + } + + fn notify_value(&mut self, method: &str, params: serde_json::Value) { + if self.broken { + return; + } self.send(Message::Notification(Notification { - method: N::METHOD.to_owned(), + method: method.to_owned(), params, })); } @@ -251,6 +365,152 @@ fn read_messages(mut reader: R, sender: &Sender) { /// `file://Tests/Foo.swift` parses with `Tests` as the *host* and loses a path component. /// Only the URI is absolute — the caller keeps reporting the path it was given, which is /// what codeowners are resolved against. +/// A file's contents as a JSON string value, read in chunks rather than held. +/// +/// [`Serializer::serialize_str`] wants the whole string contiguous, which is what forced the +/// file to be resident. [`Serializer::collect_str`] takes a [`fmt::Display`] instead, and +/// serde_json overrides it to push each fragment through its escaper straight into the +/// writer — so a `Display` that reads in chunks never materializes the file. +struct FileText<'a> { + path: &'a Path, + /// `Display::fmt` can only fail with a payload-free [`fmt::Error`], so the real cause is + /// stashed here. serde_json's own `collect_str` adapter does the same for writer errors. + error: Cell>, +} + +impl<'a> FileText<'a> { + fn new(path: &'a Path) -> Self { + Self { + path, + error: Cell::new(None), + } + } + + /// The stashed cause, if a pass failed. + fn take_error(&self) -> Option { + self.error.take() + } + + fn stream(&self, f: &mut fmt::Formatter<'_>) -> io::Result<()> { + let mut file = fs::File::open(self.path)?; + let mut buf = vec![0_u8; CHUNK]; + // Bytes at the front of `buf` held over from the last chunk: a read boundary can fall + // inside a multi-byte character, and `write_str` takes only valid UTF-8. + let mut carry = 0_usize; + loop { + let read = file.read(&mut buf[carry..])?; + if read == 0 { + break; + } + let filled = carry + read; + let valid = match str::from_utf8(&buf[..filled]) { + Ok(_) => filled, + // No `error_len` means the input merely stops mid-character, so the rest of + // it is in the next chunk. Anything else is a file we cannot send. + Err(error) if error.error_len().is_none() => error.valid_up_to(), + Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)), + }; + let chunk = str::from_utf8(&buf[..valid]) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + f.write_str(chunk) + .map_err(|_| io::Error::other("the serializer rejected a fragment"))?; + buf.copy_within(valid..filled, 0); + carry = filled - valid; + } + if carry > 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "file ends inside a multi-byte character", + )); + } + Ok(()) + } +} + +impl fmt::Display for FileText<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.stream(f) { + Ok(()) => Ok(()), + Err(error) => { + self.error.set(Some(error)); + Err(fmt::Error) + } + } + } +} + +/// `{"jsonrpc": "2.0", "method": ..., "params": ...}` — the framing [`Message::write`] would +/// add, rebuilt here because it reaches the pipe only through a fully buffered `to_string`. +struct Envelope<'a, P> { + method: &'a str, + params: &'a P, +} + +impl Serialize for Envelope<'_, P> { + fn serialize(&self, serializer: S) -> Result { + let mut envelope = serializer.serialize_struct("JsonRpc", 3)?; + envelope.serialize_field("jsonrpc", "2.0")?; + envelope.serialize_field("method", self.method)?; + envelope.serialize_field("params", self.params)?; + envelope.end() + } +} + +/// [`DidOpenTextDocumentParams`] with the text streamed instead of owned. +struct DidOpenParams<'a> { + uri: &'a Uri, + language_id: &'a str, + text: &'a FileText<'a>, +} + +impl Serialize for DidOpenParams<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut params = serializer.serialize_struct("DidOpenTextDocumentParams", 1)?; + params.serialize_field("textDocument", &TextDocumentItemRef(self))?; + params.end() + } +} + +struct TextDocumentItemRef<'a>(&'a DidOpenParams<'a>); + +impl Serialize for TextDocumentItemRef<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut item = serializer.serialize_struct("TextDocumentItem", 4)?; + item.serialize_field("uri", self.0.uri)?; + item.serialize_field("languageId", self.0.language_id)?; + item.serialize_field("version", &1)?; + item.serialize_field("text", &StreamedText(self.0.text))?; + item.end() + } +} + +struct StreamedText<'a>(&'a FileText<'a>); + +impl Serialize for StreamedText<'_> { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self.0) + } +} + +/// Tallies what it passes on, so the body's length can be measured in one pass and checked +/// against the header in the next. [`io::sink`] as the inner writer makes it count alone. +struct Counting { + inner: W, + count: usize, +} + +impl Write for Counting { + fn write(&mut self, buf: &[u8]) -> io::Result { + let written = self.inner.write(buf)?; + self.count += written; + Ok(written) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + fn file_uri(path: &Path) -> anyhow::Result { // Lexical, so a symlinked checkout is not rewritten to somewhere the caller never named. let absolute = std::path::absolute(path) @@ -264,8 +524,166 @@ fn file_uri(path: &Path) -> anyhow::Result { #[cfg(test)] mod tests { + use temp_testdir::TempDir; + use super::*; + /// Records what the streamed file is handed to the serializer in, without keeping it. + struct Fragments { + largest: usize, + total: usize, + text: String, + } + + impl fmt::Write for Fragments { + fn write_str(&mut self, fragment: &str) -> fmt::Result { + self.largest = self.largest.max(fragment.len()); + self.total += fragment.len(); + self.text.push_str(fragment); + Ok(()) + } + } + + fn fragments_of(path: &Path) -> Fragments { + let mut fragments = Fragments { + largest: 0, + total: 0, + text: String::new(), + }; + // `write!` builds the `Formatter` that `Display::fmt` writes into, so this drives + // exactly the path `collect_str` does. + fmt::Write::write_fmt(&mut fragments, format_args!("{}", FileText::new(path))) + .expect("the file streams"); + fragments + } + + // The whole point of the streaming payload. A file many chunks long must still reach the + // serializer a chunk at a time, or nothing has been gained over reading it in one go. + #[test] + fn a_file_larger_than_a_chunk_is_never_handed_over_whole() { + let dir = TempDir::default(); + let path = dir.join("Big.swift"); + let line = "// a line of source that is long enough to matter\n"; + let body = line.repeat((CHUNK * 3) / line.len()); + fs::write(&path, &body).unwrap(); + assert!(body.len() > CHUNK * 2, "the fixture has to span chunks"); + + let fragments = fragments_of(&path); + assert!( + fragments.largest <= CHUNK, + "a fragment of {} bytes means {} of the file was held at once", + fragments.largest, + fragments.largest + ); + assert_eq!(fragments.total, body.len(), "and all of it is sent"); + } + + // A read boundary lands wherever it lands, and `write_str` takes only valid UTF-8, so a + // character split across two reads has to be rejoined rather than dropped or mangled. + #[test] + fn a_character_split_across_two_reads_survives() { + let dir = TempDir::default(); + let path = dir.join("Accented.swift"); + // One byte short of a chunk, so the two-byte character straddles the boundary. + let body = format!("{}é tail", "a".repeat(CHUNK - 1)); + fs::write(&path, &body).unwrap(); + + assert_eq!(fragments_of(&path).text, body); + } + + // Reported rather than silently emitted as replacement characters, and -- because it is + // found on the measuring pass -- before any of it has been announced to the server. + #[test] + fn a_file_that_is_not_utf8_is_refused() { + let dir = TempDir::default(); + let path = dir.join("Latin1.swift"); + fs::write(&path, [b'/', b'/', 0xFF, b'\n']).unwrap(); + + let text = FileText::new(&path); + let mut sink = Fragments { + largest: 0, + total: 0, + text: String::new(), + }; + assert!(fmt::Write::write_fmt(&mut sink, format_args!("{text}")).is_err()); + assert_eq!( + text.take_error().map(|error| error.kind()), + Some(io::ErrorKind::InvalidData) + ); + } + + // The payload is ours now rather than `lsp_types`', so it has to keep describing the + // same document. A server given a malformed `didOpen` answers with no symbols rather + // than an error, so a drifted field name would look exactly like a checkout that + // declares nothing. Compared parsed, since field order carries no meaning in JSON. + #[test] + fn the_streamed_did_open_describes_the_same_document_as_the_typed_one() { + let dir = TempDir::default(); + let path = dir.join("MyTests.swift"); + let body = "class MyTests {\n\t\"quoted\" \\ é\n}\n"; + fs::write(&path, body).unwrap(); + let uri = file_uri(&path).unwrap(); + + let text = FileText::new(&path); + let streamed = serde_json::to_string(&DidOpenParams { + uri: &uri, + language_id: "swift", + text: &text, + }) + .unwrap(); + + let typed = serde_json::to_string(&lsp_types::DidOpenTextDocumentParams { + text_document: lsp_types::TextDocumentItem { + uri: uri.clone(), + language_id: String::from("swift"), + version: 1, + text: String::from(body), + }, + }) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&streamed).unwrap(), + serde_json::from_str::(&typed).unwrap() + ); + } + + // The header is written before the body and a pipe cannot be rewound, so the measuring + // pass has to agree with the emitting one exactly -- a byte out either truncates the + // message or leaves the stream misframed for everything after it. + #[test] + fn the_measured_length_matches_what_is_emitted() { + let dir = TempDir::default(); + let path = dir.join("MyTests.swift"); + fs::write(&path, "class MyTests {\n\t\"q\" \\ é\u{7}\n}\n").unwrap(); + let uri = file_uri(&path).unwrap(); + + let text = FileText::new(&path); + let message = Envelope { + method: "textDocument/didOpen", + params: &DidOpenParams { + uri: &uri, + language_id: "swift", + text: &text, + }, + }; + + let mut measured = Counting { + inner: io::sink(), + count: 0, + }; + serde_json::to_writer(&mut measured, &message).unwrap(); + + let mut emitted = Counting { + inner: Vec::new(), + count: 0, + }; + serde_json::to_writer(&mut emitted, &message).unwrap(); + + assert_eq!(measured.count, emitted.count); + assert_eq!(measured.count, emitted.inner.len()); + } + // A server that cannot parse the URI answers with no symbols rather than an error, so // both of these fail silently in production if they regress. `lsp_types::Uri` will not // encode for us and `file://` takes an authority, so neither is free. diff --git a/xcresult/src/main.rs b/xcresult/src/main.rs index 47535db6..14015013 100644 --- a/xcresult/src/main.rs +++ b/xcresult/src/main.rs @@ -70,6 +70,15 @@ pub struct Cli { default_value_t = Limits::default().retries, )] pub xcresult_test_locations_retries: usize, + /// Largest source file to parse. One `didOpen` carries the whole file, so an outsized + /// generated one costs memory and usually a timed-out request for symbols it does not + /// declare. Skipped without being read. + #[arg( + long, + env = constants::TRUNK_XCRESULT_TEST_LOCATIONS_MAX_FILE_BYTES_ENV, + default_value_t = Limits::default().max_file_bytes, + )] + pub xcresult_test_locations_max_file_bytes: u64, } fn main() -> anyhow::Result<()> { @@ -89,6 +98,7 @@ fn main() -> anyhow::Result<()> { xcresult_test_locations_budget_secs, xcresult_test_locations_request_timeout_secs, xcresult_test_locations_retries, + xcresult_test_locations_max_file_bytes, } = Cli::parse(); let repo_url_parts = repo_url .and_then(|repo_url| RepoUrlParts::from_url(&repo_url).ok()) @@ -106,6 +116,7 @@ fn main() -> anyhow::Result<()> { budget: Duration::from_secs(xcresult_test_locations_budget_secs), request_timeout: Duration::from_secs(xcresult_test_locations_request_timeout_secs), retries: xcresult_test_locations_retries, + max_file_bytes: xcresult_test_locations_max_file_bytes, }, )? } else { diff --git a/xcresult/src/test_locations.rs b/xcresult/src/test_locations.rs index 24de6dfc..36abb5cb 100644 --- a/xcresult/src/test_locations.rs +++ b/xcresult/src/test_locations.rs @@ -4,7 +4,6 @@ use std::{ collections::{HashMap, HashSet}, - fs, path::{Path, PathBuf}, time::{Duration, Instant}, }; @@ -53,6 +52,10 @@ pub struct Limits { pub request_timeout: Duration, /// How many times a server that stops answering is replaced with a fresh one. pub retries: usize, + /// Skipped above this, unread. `didOpen` carries the whole file, so a generated source + /// file costs its own size twice over and will usually exhaust `request_timeout` as + /// well — taking a retry and the rest of the budget with it, for one file's symbols. + pub max_file_bytes: u64, } impl Default for Limits { @@ -62,6 +65,9 @@ impl Default for Limits { budget: Duration::from_secs(60), request_timeout: Duration::from_secs(30), retries: 1, + // Comfortably above hand-written source, and low enough that the generated files + // that reach megabytes -- which declare no tests -- are not read. + max_file_bytes: 2 * 1_024 * 1_024, } } } @@ -150,7 +156,7 @@ impl TestLocationIndex { .iter() .filter_map(|key| key.suite.as_deref()) .collect::>(); - let sources = scan_sources(repo_root, &suites, limits.max_files); + let sources = scan_sources(repo_root, &suites, limits.max_files, limits.max_file_bytes); let (swift, clang) = sources .into_iter() .partition::, _>(|path| has_extension(path, &SWIFT_EXTENSIONS)); @@ -399,15 +405,11 @@ impl Resolver { ); return; } - let Ok(text) = fs::read_to_string(file) else { - continue; - }; - let symbols = server.document_symbols( - file, - kind.language_id, - &text, - self.limits.request_timeout, - ); + // The file is read inside `document_symbols`, in chunks -- an unreadable or + // non-UTF-8 one is reported there and answers with no symbols, as it did + // when the read happened here. + let symbols = + server.document_symbols(file, kind.language_id, self.limits.request_timeout); if server.is_broken() { break; } @@ -476,7 +478,12 @@ fn has_extension(path: &Path, extensions: &[&str]) -> bool { /// /// `.gitignore` already excludes most of [`SKIPPED_DIRECTORIES`] in a normal checkout, but /// nothing guarantees it, so the list stays as an override on top. -fn scan_sources(repo_root: &Path, suites: &HashSet<&str>, max_files: usize) -> Vec { +fn scan_sources( + repo_root: &Path, + suites: &HashSet<&str>, + max_files: usize, + max_file_bytes: u64, +) -> Vec { let mut types = TypesBuilder::new(); for extension in SWIFT_EXTENSIONS.iter().chain(CLANG_EXTENSIONS.iter()) { // `add` only fails on a malformed glob, and these are built from literals. @@ -497,6 +504,9 @@ fn scan_sources(repo_root: &Path, suites: &HashSet<&str>, max_files: usize) -> V }) .build(); + // Counted rather than logged per file: a repo with a generated-sources directory would + // otherwise emit a warning for every one of them. + let mut oversized = 0_usize; let mut found = walker .flatten() .filter(|entry| { @@ -504,8 +514,22 @@ fn scan_sources(repo_root: &Path, suites: &HashSet<&str>, max_files: usize) -> V .file_type() .is_some_and(|file_type| file_type.is_file()) }) + .filter(|entry| { + // The walker has already stat'd the entry, so the size costs nothing extra, and + // failing to read it here means the read would have failed anyway. + match entry.metadata() { + Ok(metadata) if metadata.len() > max_file_bytes => { + oversized += 1; + false + } + _ => true, + } + }) .map(|entry| entry.into_path()) .collect::>(); + if oversized > 0 { + tracing::warn!("{oversized} source file(s) skipped for exceeding {max_file_bytes} bytes"); + } found.sort_by_cached_key(|path| (rank(path, suites), path.clone())); found.truncate(max_files); found @@ -529,6 +553,8 @@ fn rank(path: &Path, suites: &HashSet<&str>) -> u8 { #[cfg(test)] mod tests { + use std::fs; + use rstest::rstest; use serde_json::{Value, json}; use temp_testdir::TempDir; @@ -828,7 +854,7 @@ mod tests { fs::write(path, "").unwrap(); } let suites = HashSet::from(["SnapshotReproTests"]); - let scanned = scan_sources(root.as_ref(), &suites, 10) + let scanned = scan_sources(root.as_ref(), &suites, 10, u64::MAX) .iter() .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) .collect::>(); @@ -848,7 +874,29 @@ mod tests { for name in ["A.swift", "B.swift", "C.swift"] { fs::write(root.join(name), "").unwrap(); } - assert_eq!(scan_sources(root.as_ref(), &HashSet::new(), 2).len(), 2); + assert_eq!( + scan_sources(root.as_ref(), &HashSet::new(), 2, u64::MAX).len(), + 2 + ); + } + + // The size cap is on the scan rather than the read, so an outsized file never reaches + // `read_to_string` at all -- and the cap is a ceiling, not a rounding. + #[test] + fn the_scan_skips_a_file_over_the_byte_cap() { + let root = TempDir::default(); + fs::write(root.join("Small.swift"), "ab").unwrap(); + fs::write(root.join("Big.swift"), "abcd").unwrap(); + + let scanned = |max_file_bytes| { + scan_sources(root.as_ref(), &HashSet::new(), 10, max_file_bytes) + .iter() + .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) + .collect::>() + }; + + assert_eq!(scanned(2), vec![String::from("Small.swift")]); + assert_eq!(scanned(4).len(), 2, "a file exactly at the cap is parsed"); } fn site(file: &str) -> DeclarationSite { From 5f9e0d4ab5e34c3d102809f60aa47c37f094e1ae Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Mon, 31 Aug 2026 12:22:13 -0700 Subject: [PATCH 16/24] refactor(xcresult): find a language server without requiring xcrun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xcrun --find` is the only way to locate a tool inside an Xcode toolchain, so it is right on macOS and useless anywhere else — it returned `None` off macOS by construction. That was fine while the only consumer read `.xcresult` bundles, which cannot exist without Xcode. The Swift toolchain on Linux ships `sourcekit-lsp` on `PATH` and has no `xcrun` at all, so discovery now tries `xcrun` on macOS and falls back to a `PATH` scan. Nothing else in `test_locations.rs` or `lsp.rs` is platform-specific, which makes this the only thing standing between the declaration index and a non-Apple host. The scan also checks the executable bit rather than just for a file of the right name, so a stray non-executable `sourcekit-lsp` reports "not found" instead of failing later at spawn time with something less obvious. Both new tests run on any platform, which is the point. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/src/test_locations.rs | 4 +- xcresult/src/xcrun.rs | 81 +++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/xcresult/src/test_locations.rs b/xcresult/src/test_locations.rs index 36abb5cb..01d71051 100644 --- a/xcresult/src/test_locations.rs +++ b/xcresult/src/test_locations.rs @@ -11,7 +11,7 @@ use std::{ use ignore::{WalkBuilder, types::TypesBuilder}; use lsp_types::{DocumentSymbol, SymbolKind}; -use crate::{file_attribution::ReportedPath, lsp::LanguageServer, xcrun::xcrun_find}; +use crate::{file_attribution::ReportedPath, lsp::LanguageServer, xcrun::find_program}; /// Kinds that can declare a test. const METHOD_KINDS: [SymbolKind; 3] = [ @@ -357,7 +357,7 @@ impl Resolver { if files.is_empty() || self.unresolved.is_empty() { return; } - let Some(program) = xcrun_find(kind.program) else { + let Some(program) = find_program(kind.program) else { tracing::warn!( "{} not found; {} source file(s) left unparsed", kind.program, diff --git a/xcresult/src/xcrun.rs b/xcresult/src/xcrun.rs index 9498c84e..ea5296b2 100644 --- a/xcresult/src/xcrun.rs +++ b/xcresult/src/xcrun.rs @@ -1,4 +1,4 @@ -use std::{ffi::OsStr, path::PathBuf, process::Command}; +use std::{ffi::OsStr, fs, path::Path, path::PathBuf, process::Command}; use lazy_static::lazy_static; use serde::Deserialize; @@ -34,6 +34,45 @@ pub fn xcresulttool_get_test_results_summary>( } /// `None` when `name` ships in neither Xcode nor the Command Line Tools. +/// Locate a developer tool. `xcrun` is the only way to find one inside an Xcode toolchain, +/// but on Linux the Swift toolchain puts `sourcekit-lsp` on `PATH` and there is no `xcrun`. +pub fn find_program(name: &str) -> Option { + if cfg!(target_os = "macos") + && let Some(path) = xcrun_find(name) + { + return Some(path); + } + which_program(name) +} + +fn which_program(name: &str) -> Option { + which_program_in(name, &std::env::var_os("PATH")?) +} + +fn which_program_in(name: &str, path_var: &OsStr) -> Option { + std::env::split_paths(path_var) + .map(|directory| directory.join(name)) + .find(|candidate| is_executable_file(candidate)) +} + +/// A non-executable file of the right name is not the program, and spawning it would fail +/// later with something far less obvious than "not found". +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + true +} + pub fn xcrun_find(name: &str) -> Option { if !cfg!(target_os = "macos") { return None; @@ -160,3 +199,43 @@ fn xcrun>(args: &[T]) -> anyhow::Result { let result = String::from_utf8(data)?; Ok(result) } + +#[cfg(test)] +mod tests { + use super::*; + + // The Linux Swift toolchain puts `sourcekit-lsp` on `PATH` with no `xcrun` to ask, so the + // fallback is the only way the declaration path can find a server there. + #[test] + fn a_program_on_path_is_found_without_xcrun() { + let temp_dir = tempfile::tempdir().unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + let program = temp_dir.path().join("pretend-lsp"); + fs::write(&program, b"#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&program, fs::Permissions::from_mode(0o755)).unwrap(); + } + let path_var = std::env::join_paths([elsewhere.path(), temp_dir.path()]).unwrap(); + + assert_eq!( + which_program_in("pretend-lsp", &path_var), + Some(program.clone()) + ); + assert_eq!(which_program_in("not-installed", &path_var), None); + } + + #[cfg(unix)] + #[test] + fn a_file_without_the_executable_bit_is_not_the_program() { + use std::os::unix::fs::PermissionsExt; + let temp_dir = tempfile::tempdir().unwrap(); + let program = temp_dir.path().join("pretend-lsp"); + fs::write(&program, b"not executable").unwrap(); + fs::set_permissions(&program, fs::Permissions::from_mode(0o644)).unwrap(); + let path_var = std::env::join_paths([temp_dir.path()]).unwrap(); + + assert_eq!(which_program_in("pretend-lsp", &path_var), None); + } +} From f0c566620e4766fa84cd147f1a18ba635e86f1f7 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Mon, 31 Aug 2026 12:27:22 -0700 Subject: [PATCH 17/24] feat(xcresult): resolve declarations from `swift test --xunit-output` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swift test --xunit-output` writes no file path for any test, and on Linux there is no `.xcresult` to fall back to — so today a Swift test run there can never be attributed to a file. The declaration index needs nothing from Xcode, so it can answer this too. The xunit XML carries more than it first appears. `classname` is the target followed by the dot-qualified suite path, and only collapses to the bare target for a top-level `@Test func`: classname="MyCLITests" name="helloworld()" classname="MyCLITests.AlphaSuite" name="shared()" classname="MyCLITests.AlphaSuite.Inner" name="deep()" classname="MyCLITests.BetaSuite" name="shared()" That maps onto the existing `TestKey` almost unchanged: the innermost component is the declaring type, exactly as the innermost component of an xcresult `Suite/Inner/case()` identifier is, so only the separator differs. A top-level function falls to the suiteless lookup already there for xcresult, and the first component is the target, so the collision tie-break carries over for free. Parameterised tests need no special handling either: swift-testing emits one entry per function rather than one per argument, keeping argument labels (`squares(n:)`), and that single entry is the declaration site we want. XCTest needs none either. One run writes **two files** — swift-testing to `-swift-testing.xml` and XCTest to `` — and the XCTest form is the same `Module.Type` plus method, minus the `()`: classname="MyCLITests.LegacyXCTests" name="testOldStyle" so the same parse resolves it. That second file is only written when `--parallel` is passed; without it the XCTest cases still run but are silently absent from the output, which is worth knowing before trusting a project's xunit to be complete. The fixture is a real package plus both files it actually produced. Its two suites both declare `shared()` in different files, so a regression that ignored the suite component would make one borrow the other's file — reverting the suite component fails those tests rather than neither. This is the resolver only. Nothing reads a JUnit file or writes a `file` attribute back yet, and no flag is wired up. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/src/test_locations.rs | 58 ++++++++ .../data/swift-test-xunit-xctest.junit.xml | 8 ++ .../tests/data/swift-test-xunit.junit.xml | 11 ++ .../swift-test-xunit/Package.swift | 10 ++ .../fixture-src/swift-test-xunit/README.md | 63 +++++++++ .../Sources/MyCLI/MyCLI.swift | 1 + .../Tests/MyCLITests/BetaSuite.swift | 7 + .../Tests/MyCLITests/Legacy.swift | 5 + .../Tests/MyCLITests/Parameterized.swift | 9 ++ .../Tests/MyCLITests/Suites.swift | 9 ++ .../Tests/MyCLITests/TopLevel.swift | 5 + xcresult/tests/swift_test_xunit.rs | 125 ++++++++++++++++++ 12 files changed, 311 insertions(+) create mode 100644 xcresult/tests/data/swift-test-xunit-xctest.junit.xml create mode 100644 xcresult/tests/data/swift-test-xunit.junit.xml create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Package.swift create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/README.md create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Sources/MyCLI/MyCLI.swift create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/BetaSuite.swift create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Legacy.swift create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Parameterized.swift create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Suites.swift create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/TopLevel.swift create mode 100644 xcresult/tests/swift_test_xunit.rs diff --git a/xcresult/src/test_locations.rs b/xcresult/src/test_locations.rs index 01d71051..03bd4972 100644 --- a/xcresult/src/test_locations.rs +++ b/xcresult/src/test_locations.rs @@ -94,6 +94,24 @@ impl TestKey { } impl TestKey { + /// `classname` is the target plus the dot-qualified suite path (`MyCLITests.Outer.Inner`), + /// collapsing to the bare target for a top-level `@Test func`, which declares no suite. + pub fn from_junit_classname(classname: &str, name: &str) -> Self { + let mut components = classname.rsplit('.'); + let innermost = components.next().unwrap_or_default(); + let has_suite = components.next().is_some(); + Self { + suite: has_suite.then(|| container_name(innermost).to_string()), + case: normalized_case(name), + } + } + + /// The first component, which tells same-named suites in different modules apart. + pub fn target_from_junit_classname(classname: &str) -> Option { + let target = classname.split('.').next()?; + (!target.is_empty()).then(|| target.to_string()) + } + /// `test://com.apple.xcode////` — the second component is /// the test bundle, which is the only thing distinguishing two same-named suites. pub fn target_from_identifier_url(identifier_url: &str) -> Option { @@ -937,4 +955,44 @@ mod tests { ) { assert_eq!(recorded(target, files), expected); } + + #[rstest] + #[case::top_level_function("MyCLITests", "helloworld()", None, "helloworld")] + #[case::in_a_suite("MyCLITests.AlphaSuite", "shared()", Some("AlphaSuite"), "shared")] + #[case::nested_suite("MyCLITests.AlphaSuite.Inner", "deep()", Some("Inner"), "deep")] + #[case::other_suite_same_case("MyCLITests.BetaSuite", "shared()", Some("BetaSuite"), "shared")] + #[case::parameterized( + "MyCLITests.ParamSuite", + "squares(n:)", + Some("ParamSuite"), + "squares(n:" + )] + #[case::objc_style( + "MyCLITests.LegacyXCTests", + "testOldStyle", + Some("LegacyXCTests"), + "testOldStyle" + )] + fn a_junit_classname_names_a_suite_and_a_case( + #[case] classname: &str, + #[case] name: &str, + #[case] suite: Option<&str>, + #[case] case: &str, + ) { + assert_eq!( + TestKey::from_junit_classname(classname, name), + key(suite, case) + ); + } + + #[rstest] + #[case::with_suite("MyCLITests.AlphaSuite", Some("MyCLITests"))] + #[case::bare_target("MyCLITests", Some("MyCLITests"))] + #[case::empty("", None)] + fn a_junit_classname_names_the_target(#[case] classname: &str, #[case] expected: Option<&str>) { + assert_eq!( + TestKey::target_from_junit_classname(classname).as_deref(), + expected + ); + } } diff --git a/xcresult/tests/data/swift-test-xunit-xctest.junit.xml b/xcresult/tests/data/swift-test-xunit-xctest.junit.xml new file mode 100644 index 00000000..ecbcfd58 --- /dev/null +++ b/xcresult/tests/data/swift-test-xunit-xctest.junit.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/xcresult/tests/data/swift-test-xunit.junit.xml b/xcresult/tests/data/swift-test-xunit.junit.xml new file mode 100644 index 00000000..0408c82b --- /dev/null +++ b/xcresult/tests/data/swift-test-xunit.junit.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Package.swift b/xcresult/tests/fixture-src/swift-test-xunit/Package.swift new file mode 100644 index 00000000..4dcb3cf4 --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MyCLI", + targets: [ + .target(name: "MyCLI"), + .testTarget(name: "MyCLITests", dependencies: ["MyCLI"]), + ] +) diff --git a/xcresult/tests/fixture-src/swift-test-xunit/README.md b/xcresult/tests/fixture-src/swift-test-xunit/README.md new file mode 100644 index 00000000..f4d94e36 --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/README.md @@ -0,0 +1,63 @@ +# `swift test --xunit-output` fixture + +A SwiftPM package whose test target covers every shape `swift test --xunit-output` emits, +and the XML it produced, checked in as `../../data/swift-test-xunit.junit.xml`. + +Unlike the `.xcresult` scenarios next to this one, nothing here needs Xcode. `swift test` +produces no result bundle, `sourcekit-lsp` ships with the Swift toolchain on Linux, and the +xunit XML **contains no file path at all** — so a declaration is the only way to attribute +a test to a file on that platform. + +## Regenerating + +```sh +cp -R . /tmp/swift-test-xunit && cd /tmp/swift-test-xunit +swift test --parallel --xunit-output /tmp/out.xml +cp /tmp/out-swift-testing.xml ../../data/swift-test-xunit.junit.xml +cp /tmp/out.xml ../../data/swift-test-xunit-xctest.junit.xml +``` + +`--parallel` is **required**: without it `--xunit-output` emits nothing for XCTest, and only +the swift-testing file appears. + +Copy it out first: building in place leaves a `.build` directory inside the fixture, and the +declaration scan would then walk it (`.build` is in `SKIPPED_DIRECTORIES`, so it is ignored, +but it should not be committed either). + +Note that one run writes **two files**, and neither is named what you asked for in the +XCTest case being the only one at ``: + +| file | holds | +| -------------------------- | --------------------------------- | +| `-swift-testing.xml` | swift-testing (`@Test`, `@Suite`) | +| `` | XCTest (`XCTestCase` subclasses) | + +A project with both frameworks has to upload both. + +## What each shape proves + +| `classname` | `name` | declared in | why it is here | +| ----------------------------- | ---------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `MyCLITests` | `helloworld()` | `TopLevel.swift` | a top-level `@Test func` has no suite, so classname collapses to the bare target and only the suiteless lookup can find it | +| `MyCLITests.AlphaSuite` | `shared()` | `Suites.swift` | the ordinary case: innermost classname component is the declaring type | +| `MyCLITests.AlphaSuite.Inner` | `deep()` | `Suites.swift` | a nested suite is fully qualified, and only the innermost component declares the method | +| `MyCLITests.BetaSuite` | `shared()` | `BetaSuite.swift` | same case name as `AlphaSuite`'s in a **different file**, so collapsing the classname would make one borrow the other's file | +| `MyCLITests.ParamSuite` | `squares(n:)` | `Parameterized.swift` | a parameterised test keeps its argument labels and appears **once**, not once per argument, so the single entry is the declaration site | +| `MyCLITests.ParamSuite` | `pairs(s:flag:)` | `Parameterized.swift` | two labels, same shape | + +## XCTest needs `--parallel`, and needs no special handling + +`Legacy.swift` holds an `XCTestCase`, and it lands in `` rather than +`-swift-testing.xml`: + +```xml + +``` + +Which is the same `Module.Type` + method shape swift-testing uses, minus the `()` — so the +same parse resolves it, and `an_xctest_case_resolves_to_the_class_that_declares_it` proves +that against this package. + +Worth knowing: **without `--parallel` this file is not written at all**. The XCTest case runs +either way (the console reports `-[MyCLITests.LegacyXCTests testOldStyle] passed`), so a +project that omits `--parallel` silently uploads only its swift-testing results. diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Sources/MyCLI/MyCLI.swift b/xcresult/tests/fixture-src/swift-test-xunit/Sources/MyCLI/MyCLI.swift new file mode 100644 index 00000000..f233da78 --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Sources/MyCLI/MyCLI.swift @@ -0,0 +1 @@ +public let answer = 42 diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/BetaSuite.swift b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/BetaSuite.swift new file mode 100644 index 00000000..2f9399da --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/BetaSuite.swift @@ -0,0 +1,7 @@ +import Testing + +/// Declares a `shared()` too, in a different file, so the suite component of the JUnit +/// classname is the only thing that can tell the two apart. +@Suite struct BetaSuite { + @Test func shared() { #expect(true) } +} diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Legacy.swift b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Legacy.swift new file mode 100644 index 00000000..b3efd521 --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Legacy.swift @@ -0,0 +1,5 @@ +import XCTest + +final class LegacyXCTests: XCTestCase { + func testOldStyle() { XCTAssertTrue(true) } +} diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Parameterized.swift b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Parameterized.swift new file mode 100644 index 00000000..4c314e1c --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Parameterized.swift @@ -0,0 +1,9 @@ +import Testing + +@Suite struct ParamSuite { + @Test(arguments: [1, 2, 3]) + func squares(n: Int) { #expect(n * n >= n) } + + @Test(arguments: ["a", "b"], [true, false]) + func pairs(s: String, flag: Bool) { #expect(!s.isEmpty || flag) } +} diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Suites.swift b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Suites.swift new file mode 100644 index 00000000..d74f05fc --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/Suites.swift @@ -0,0 +1,9 @@ +import Testing + +@Suite struct AlphaSuite { + @Test func shared() { #expect(true) } + + @Suite struct Inner { + @Test func deep() { #expect(true) } + } +} diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/TopLevel.swift b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/TopLevel.swift new file mode 100644 index 00000000..d95f8e41 --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/TopLevel.swift @@ -0,0 +1,5 @@ +import Testing + +@Test func helloworld() { + #expect(1 == 1) +} diff --git a/xcresult/tests/swift_test_xunit.rs b/xcresult/tests/swift_test_xunit.rs new file mode 100644 index 00000000..f1bf5081 --- /dev/null +++ b/xcresult/tests/swift_test_xunit.rs @@ -0,0 +1,125 @@ +//! `swift test --xunit-output` reports no file at all, and needs no Xcode to produce — so +//! this is the shape the declaration path takes on Linux. See the fixture's README. + +use std::{collections::HashMap, path::Path}; + +use xcresult::test_locations::{Limits, TestKey, TestLocationIndex}; + +const FIXTURE_ROOT: &str = "tests/fixture-src/swift-test-xunit"; +const XUNIT: &str = include_str!("data/swift-test-xunit.junit.xml"); +/// `--xunit-output` needs `--parallel` to emit XCTest results, and writes them to a separate +/// file from swift-testing's — which goes to `-swift-testing.xml`. +const XUNIT_XCTEST: &str = include_str!("data/swift-test-xunit-xctest.junit.xml"); + +fn testcases(xunit: &str) -> Vec<(String, String)> { + xunit + .lines() + .filter(|line| line.contains(" HashMap<(String, String), String> { + resolve_from(XUNIT) +} + +fn resolve_from(xunit: &str) -> HashMap<(String, String), String> { + let cases = testcases(xunit); + assert!(!cases.is_empty(), "the fixture must have testcases"); + + let keys = cases + .iter() + .map(|(classname, name)| { + ( + TestKey::from_junit_classname(classname, name), + TestKey::target_from_junit_classname(classname), + ) + }) + .collect::>(); + let index = TestLocationIndex::resolve(Path::new(FIXTURE_ROOT), &keys, Limits::default()); + + cases + .into_iter() + .filter_map(|(classname, name)| { + let key = TestKey::from_junit_classname(&classname, &name); + index + .lookup(&key) + .map(|site| ((classname, name), site.file.as_str().to_owned())) + }) + .collect() +} + +#[test] +fn every_swift_testing_case_resolves_to_the_file_it_is_declared_in() { + let resolved = resolve(); + for (classname, name, expected) in [ + ("MyCLITests", "helloworld()", "TopLevel.swift"), + ("MyCLITests.AlphaSuite", "shared()", "Suites.swift"), + ("MyCLITests.AlphaSuite.Inner", "deep()", "Suites.swift"), + ("MyCLITests.BetaSuite", "shared()", "BetaSuite.swift"), + ( + "MyCLITests.ParamSuite", + "squares(n:)", + "Parameterized.swift", + ), + ( + "MyCLITests.ParamSuite", + "pairs(s:flag:)", + "Parameterized.swift", + ), + ] { + let key = (String::from(classname), String::from(name)); + let file = resolved + .get(&key) + .unwrap_or_else(|| panic!("{classname} {name} resolved to nothing")); + assert!( + file.ends_with(expected), + "expected {classname} {name} in {expected}, got {file}" + ); + } +} + +// Collapsing the classname to its target would make one `shared()` borrow the other's file. +#[test] +fn two_suites_declaring_the_same_case_resolve_separately() { + let resolved = resolve(); + let alpha = resolved + .get(&( + String::from("MyCLITests.AlphaSuite"), + String::from("shared()"), + )) + .expect("alpha resolved"); + let beta = resolved + .get(&( + String::from("MyCLITests.BetaSuite"), + String::from("shared()"), + )) + .expect("beta resolved"); + assert_ne!(alpha, beta, "both `shared()` cases resolved to {alpha}"); +} + +// XCTest goes to its own file and names a case `Module.Class` + a bare method, so it needs no +// separate handling — the innermost component is still the declaring type. +#[test] +fn an_xctest_case_resolves_to_the_class_that_declares_it() { + let resolved = resolve_from(XUNIT_XCTEST); + let file = resolved + .get(&( + String::from("MyCLITests.LegacyXCTests"), + String::from("testOldStyle"), + )) + .expect("the XCTest case resolved"); + assert!(file.ends_with("Legacy.swift"), "got {file}"); +} From eaaf5761bd1ea2a921d1eeb1ded677bd0d1e7008 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Mon, 31 Aug 2026 13:10:27 -0700 Subject: [PATCH 18/24] test(xcresult): pin parity between the xcresult and swift test inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both inputs feed one `TestLocationIndex`, and nothing checked they agree. They were tested against different fixture packages, so a divergence in either adapter would have gone unnoticed until it reached a repository using both. Three layers of parity now: - `an_xcresult_identifier_and_a_junit_classname_key_alike` — the same test keys the same way from either input. The two build `suite` from different places, an identifier's second-to-last component against a classname's innermost, and arrive at `None` by different routes, so only this catches them drifting. - `an_xcresult_url_and_a_junit_classname_name_the_same_target` — the collision tie-break reads the target from `nodeIdentifierURL` on one side and `classname` on the other, and is only correct if they agree. - `parity::both_inputs_resolve_every_test_to_the_same_file` — one package captured both ways, via `swift test --xunit-output` and via `xcodebuild` into an `.xcresult`, asserting the full set of (test, file) pairs is identical. Writing it turned up two things worth having pinned. The real xcresult identifier for a Swift XCTest method is `testOldStyle()`, with parens, where Objective-C reports none — both shapes are now covered rather than the one I assumed. More importantly the two inputs disagree on that name: `xcodebuild` reports `testOldStyle()` and `swift test --xunit-output` reports `testOldStyle`. They resolve to the same file because keying normalises the parens away, but `name` feeds `gen_info_id_base`, so the same test arriving through the two inputs does not currently land on one identity. That is upstream of this crate and is not worked around here, only pinned by `the_two_inputs_spell_an_xctest_method_differently` so it cannot change unnoticed. Comparison is over sorted `(name, file)` pairs rather than a map keyed by name, because two suites in the fixture both declare `shared()` — keyed by name one silently displaced the other and the test failed on roughly half of runs depending on hash order. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/src/test_locations.rs | 49 ++++++++ .../data/swift-test-parity.xcresult.tar.gz | Bin 0 -> 42875 bytes xcresult/tests/swift_test_xunit.rs | 115 ++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 xcresult/tests/data/swift-test-parity.xcresult.tar.gz diff --git a/xcresult/src/test_locations.rs b/xcresult/src/test_locations.rs index 03bd4972..09082824 100644 --- a/xcresult/src/test_locations.rs +++ b/xcresult/src/test_locations.rs @@ -995,4 +995,53 @@ mod tests { expected ); } + + // The two build `suite` from different places — an identifier's second-to-last component + // versus a classname's innermost — so nothing else catches them drifting apart. + #[rstest] + #[case::top_level("helloworld()", "MyCLITests", "helloworld()")] + #[case::in_a_suite("AlphaSuite/shared()", "MyCLITests.AlphaSuite", "shared()")] + #[case::nested_suite( + "OuterSuite/InnerSuite/deep()", + "MyCLITests.OuterSuite.InnerSuite", + "deep()" + )] + #[case::parameterized("ParamSuite/squares(n:)", "MyCLITests.ParamSuite", "squares(n:)")] + #[case::swift_xctest_method( + "LegacyXCTests/testOldStyle()", + "MyCLITests.LegacyXCTests", + "testOldStyle" + )] + #[case::objc_xctest_method( + "ObjcXCTestTests/testFailsInsideSharedHelper", + "ObjcXCTestTests.ObjcXCTestTests", + "testFailsInsideSharedHelper" + )] + fn an_xcresult_identifier_and_a_junit_classname_key_alike( + #[case] node_identifier: &str, + #[case] classname: &str, + #[case] name: &str, + ) { + assert_eq!( + TestKey::from_node_identifier(node_identifier), + TestKey::from_junit_classname(classname, name) + ); + } + + // Different fields, and the collision tie-break depends on them agreeing. + #[rstest] + #[case::in_a_suite( + "test://com.apple.xcode/MyCLI/MyCLITests/AlphaSuite/shared()", + "MyCLITests.AlphaSuite" + )] + #[case::top_level("test://com.apple.xcode/MyCLI/MyCLITests/helloworld()", "MyCLITests")] + fn an_xcresult_url_and_a_junit_classname_name_the_same_target( + #[case] identifier_url: &str, + #[case] classname: &str, + ) { + assert_eq!( + TestKey::target_from_identifier_url(identifier_url), + TestKey::target_from_junit_classname(classname) + ); + } } diff --git a/xcresult/tests/data/swift-test-parity.xcresult.tar.gz b/xcresult/tests/data/swift-test-parity.xcresult.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..8a4cd694b26793796c2f6543a4878fd763bd1a92 GIT binary patch literal 42875 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 T0S6rL*YW=VC3t$L0N4Zog_9O_ literal 0 HcmV?d00001 diff --git a/xcresult/tests/swift_test_xunit.rs b/xcresult/tests/swift_test_xunit.rs index f1bf5081..1b5b1e5e 100644 --- a/xcresult/tests/swift_test_xunit.rs +++ b/xcresult/tests/swift_test_xunit.rs @@ -123,3 +123,118 @@ fn an_xctest_case_resolves_to_the_class_that_declares_it() { .expect("the XCTest case resolved"); assert!(file.ends_with("Legacy.swift"), "got {file}"); } + +/// One package captured both ways, which must land every test on the same file. +#[cfg(target_os = "macos")] +mod parity { + use std::{fs::File, path::PathBuf}; + + use flate2::read::GzDecoder; + use tar::Archive; + use temp_testdir::TempDir; + use xcresult::xcresult::XCResult; + + use super::*; + + fn xcresult_report() -> Vec { + let temp_dir = TempDir::default(); + Archive::new(GzDecoder::new( + File::open("tests/data/swift-test-parity.xcresult.tar.gz").unwrap(), + )) + .unpack(temp_dir.as_ref()) + .unwrap(); + let bundle: PathBuf = temp_dir.as_ref().join("MyCLI.xcresult"); + + let xcresult = XCResult::new_with_declaration_locations( + bundle.to_str().unwrap(), + String::from("trunk"), + String::from("github.com/trunk-io/analytics-cli"), + Path::new(FIXTURE_ROOT), + Limits::default(), + ) + .expect("the declaration path reads the bundle"); + + xcresult.generate_junits() + } + + fn without_parens(name: &str) -> String { + name.trim_end_matches(['(', ')']).to_owned() + } + + fn xcresult_raw_names() -> Vec { + xcresult_report() + .iter() + .flat_map(|report| report.test_suites.iter()) + .flat_map(|test_suite| test_suite.test_cases.iter()) + .map(|test_case| test_case.name.as_str().to_owned()) + .collect() + } + + /// Pairs rather than a map keyed by name: two suites here both declare `shared()`. + fn xcresult_pairs() -> Vec<(String, String)> { + let mut pairs = xcresult_report() + .iter() + .flat_map(|report| report.test_suites.iter()) + .flat_map(|test_suite| test_suite.test_cases.iter()) + .filter_map(|test_case| { + test_case + .extra + .iter() + .find(|(key, _)| key.as_str() == "file") + .map(|(_, value)| { + ( + without_parens(test_case.name.as_str()), + file_name(value.as_str()), + ) + }) + }) + .collect::>(); + pairs.sort(); + pairs + } + + fn xunit_pairs() -> Vec<(String, String)> { + let mut pairs = [XUNIT, XUNIT_XCTEST] + .into_iter() + .flat_map(|xunit| resolve_from(xunit).into_iter()) + .map(|((_, name), file)| (without_parens(&name), file_name(&file))) + .collect::>(); + pairs.sort(); + pairs + } + + fn file_name(path: &str) -> String { + Path::new(path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| String::from(path)) + } + + #[test] + fn both_inputs_resolve_every_test_to_the_same_file() { + let from_xcresult = xcresult_pairs(); + assert!( + from_xcresult.len() >= 6, + "the bundle should carry every test, got {from_xcresult:?}" + ); + pretty_assertions::assert_eq!(xunit_pairs(), from_xcresult); + } + + // Upstream, not ours: `name` feeds `gen_info_id_base`, so the same test arriving through + // the two inputs does not land on one identity. Pinned so it cannot change unnoticed. + #[test] + fn the_two_inputs_spell_an_xctest_method_differently() { + assert_eq!( + testcases(XUNIT_XCTEST) + .into_iter() + .map(|(_, name)| name) + .collect::>(), + vec![String::from("testOldStyle")] + ); + assert!( + xcresult_raw_names().contains(&String::from("testOldStyle()")), + "the bundle spells it {:?}", + xcresult_raw_names() + ); + } +} From 8534f1f104c1c5fa82be6d73c4c22bf0e4ac1571 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Mon, 31 Aug 2026 13:23:15 -0700 Subject: [PATCH 19/24] test(xcresult): pin that argument labels are part of a test's identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests can differ only by argument label, and a normalisation that dropped labels would silently merge them — giving one the other's file, which for codeowners is the worst outcome available: confidently wrong rather than absent. Nothing covered that. `OverloadSuite` declares `check()`, `check(a:)` and `check(b:)`, with `check(b:)` in a different file via an extension, so a collapse shows up as a wrong file rather than only a wrong line. Both inputs report all three and agree on how they spell them: xcresult OverloadSuite/check() OverloadSuite/check(a:) OverloadSuite/check(b:) xunit check() check(a:) check(b:) `normalized_case` trims only trailing parens, so `check()` becomes `check` while `check(a:)` becomes the unbalanced `check(a:`. That is untidy but correct: the same function is applied to the language server's symbol name and to the test identifier, so what matters is that it is identical on both sides rather than well-formed. Because labels survive it, the three key distinctly and resolve to their own declarations, which the parity test now covers end to end from both artifacts. Rewriting `normalized_case` to split on `(` instead fails the new test with both labelled overloads resolving to `OverloadA.swift`. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/src/test_locations.rs | 2 + .../data/swift-test-parity.xcresult.tar.gz | Bin 42875 -> 45562 bytes .../data/swift-test-xunit-xctest.junit.xml | 4 +- .../tests/data/swift-test-xunit.junit.xml | 17 +++++--- .../fixture-src/swift-test-xunit/README.md | 40 ++++++++++++++---- .../Tests/MyCLITests/OverloadA.swift | 6 +++ .../Tests/MyCLITests/OverloadB.swift | 6 +++ xcresult/tests/swift_test_xunit.rs | 15 +++++++ 8 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/OverloadA.swift create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/OverloadB.swift diff --git a/xcresult/src/test_locations.rs b/xcresult/src/test_locations.rs index 09082824..75496dcc 100644 --- a/xcresult/src/test_locations.rs +++ b/xcresult/src/test_locations.rs @@ -1007,6 +1007,8 @@ mod tests { "deep()" )] #[case::parameterized("ParamSuite/squares(n:)", "MyCLITests.ParamSuite", "squares(n:)")] + #[case::no_argument_overload("OverloadSuite/check()", "MyCLITests.OverloadSuite", "check()")] + #[case::labelled_overload("OverloadSuite/check(a:)", "MyCLITests.OverloadSuite", "check(a:)")] #[case::swift_xctest_method( "LegacyXCTests/testOldStyle()", "MyCLITests.LegacyXCTests", diff --git a/xcresult/tests/data/swift-test-parity.xcresult.tar.gz b/xcresult/tests/data/swift-test-parity.xcresult.tar.gz index 8a4cd694b26793796c2f6543a4878fd763bd1a92..8b17eded0537b8242f9796ddaf730b858490d3d4 100644 GIT binary patch literal 45562 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 T0S6rL*YW=VC3t$L0N4Zog_9O_ diff --git a/xcresult/tests/data/swift-test-xunit-xctest.junit.xml b/xcresult/tests/data/swift-test-xunit-xctest.junit.xml index ecbcfd58..e952fcce 100644 --- a/xcresult/tests/data/swift-test-xunit-xctest.junit.xml +++ b/xcresult/tests/data/swift-test-xunit-xctest.junit.xml @@ -1,8 +1,8 @@ - - + + diff --git a/xcresult/tests/data/swift-test-xunit.junit.xml b/xcresult/tests/data/swift-test-xunit.junit.xml index 0408c82b..1fea7036 100644 --- a/xcresult/tests/data/swift-test-xunit.junit.xml +++ b/xcresult/tests/data/swift-test-xunit.junit.xml @@ -1,11 +1,14 @@ - - - - - - - + + + + + + + + + + diff --git a/xcresult/tests/fixture-src/swift-test-xunit/README.md b/xcresult/tests/fixture-src/swift-test-xunit/README.md index f4d94e36..ee315a93 100644 --- a/xcresult/tests/fixture-src/swift-test-xunit/README.md +++ b/xcresult/tests/fixture-src/swift-test-xunit/README.md @@ -36,14 +36,17 @@ A project with both frameworks has to upload both. ## What each shape proves -| `classname` | `name` | declared in | why it is here | -| ----------------------------- | ---------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `MyCLITests` | `helloworld()` | `TopLevel.swift` | a top-level `@Test func` has no suite, so classname collapses to the bare target and only the suiteless lookup can find it | -| `MyCLITests.AlphaSuite` | `shared()` | `Suites.swift` | the ordinary case: innermost classname component is the declaring type | -| `MyCLITests.AlphaSuite.Inner` | `deep()` | `Suites.swift` | a nested suite is fully qualified, and only the innermost component declares the method | -| `MyCLITests.BetaSuite` | `shared()` | `BetaSuite.swift` | same case name as `AlphaSuite`'s in a **different file**, so collapsing the classname would make one borrow the other's file | -| `MyCLITests.ParamSuite` | `squares(n:)` | `Parameterized.swift` | a parameterised test keeps its argument labels and appears **once**, not once per argument, so the single entry is the declaration site | -| `MyCLITests.ParamSuite` | `pairs(s:flag:)` | `Parameterized.swift` | two labels, same shape | +| `classname` | `name` | declared in | why it is here | +| ----------------------------- | ---------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MyCLITests` | `helloworld()` | `TopLevel.swift` | a top-level `@Test func` has no suite, so classname collapses to the bare target and only the suiteless lookup can find it | +| `MyCLITests.AlphaSuite` | `shared()` | `Suites.swift` | the ordinary case: innermost classname component is the declaring type | +| `MyCLITests.AlphaSuite.Inner` | `deep()` | `Suites.swift` | a nested suite is fully qualified, and only the innermost component declares the method | +| `MyCLITests.BetaSuite` | `shared()` | `BetaSuite.swift` | same case name as `AlphaSuite`'s in a **different file**, so collapsing the classname would make one borrow the other's file | +| `MyCLITests.ParamSuite` | `squares(n:)` | `Parameterized.swift` | a parameterised test keeps its argument labels and appears **once**, not once per argument, so the single entry is the declaration site | +| `MyCLITests.ParamSuite` | `pairs(s:flag:)` | `Parameterized.swift` | two labels, same shape | +| `MyCLITests.OverloadSuite` | `check()` | `OverloadA.swift` | the no-argument member of an overload set | +| `MyCLITests.OverloadSuite` | `check(a:)` | `OverloadA.swift` | differs from `check(b:)` **only by argument label** | +| `MyCLITests.OverloadSuite` | `check(b:)` | `OverloadB.swift` | declared in another file via an extension, so a normalisation that dropped labels would silently merge two distinct tests and give one the wrong file | ## XCTest needs `--parallel`, and needs no special handling @@ -61,3 +64,24 @@ that against this package. Worth knowing: **without `--parallel` this file is not written at all**. The XCTest case runs either way (the console reports `-[MyCLITests.LegacyXCTests testOldStyle] passed`), so a project that omits `--parallel` silently uploads only its swift-testing results. + +## Parens and argument labels are part of a test's identity + +Both inputs report the same three overloads, and agree on how they spell them: + +| | `check()` | `check(a:)` | `check(b:)` | +| ------------------------- | ----------------------- | ------------------------- | ------------------------- | +| xcresult `nodeIdentifier` | `OverloadSuite/check()` | `OverloadSuite/check(a:)` | `OverloadSuite/check(b:)` | +| xunit `name` | `check()` | `check(a:)` | `check(b:)` | + +`normalized_case` trims only _trailing_ parens, so `check()` becomes `check` while +`check(a:)` becomes the unbalanced `check(a:`. Ugly but correct: the same function is applied +to the language server's symbol name and to the test identifier, so what matters is that it +is _identical on both sides_, not that it is tidy. Because labels survive it, the three +overloads key distinctly and resolve to their own declarations — including across files, +which `overloads_differing_only_by_argument_label_resolve_separately` pins. + +The one place the inputs genuinely disagree is an XCTest method: `xcodebuild` reports +`testOldStyle()` and `swift test` reports `testOldStyle`. Keying normalises that away so both +resolve to the same file, but the names differ in the uploaded JUnit — see +`the_two_inputs_spell_an_xctest_method_differently`. diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/OverloadA.swift b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/OverloadA.swift new file mode 100644 index 00000000..308ad010 --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/OverloadA.swift @@ -0,0 +1,6 @@ +import Testing + +@Suite struct OverloadSuite { + @Test func check() {} + @Test(arguments: [1]) func check(a: Int) { _ = a } +} diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/OverloadB.swift b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/OverloadB.swift new file mode 100644 index 00000000..127bf93f --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/OverloadB.swift @@ -0,0 +1,6 @@ +import Testing + +/// Same suite, different file, differing from `check(a:)` only by the argument label. +extension OverloadSuite { + @Test(arguments: [2]) func check(b: Int) { _ = b } +} diff --git a/xcresult/tests/swift_test_xunit.rs b/xcresult/tests/swift_test_xunit.rs index 1b5b1e5e..76304085 100644 --- a/xcresult/tests/swift_test_xunit.rs +++ b/xcresult/tests/swift_test_xunit.rs @@ -91,6 +91,21 @@ fn every_swift_testing_case_resolves_to_the_file_it_is_declared_in() { } } +#[test] +fn overloads_differing_only_by_argument_label_resolve_separately() { + let resolved = resolve(); + let file = |name: &str| { + resolved + .get(&(String::from("MyCLITests.OverloadSuite"), String::from(name))) + .unwrap_or_else(|| panic!("{name} resolved to nothing")) + .clone() + }; + let (a, b, none) = (file("check(a:)"), file("check(b:)"), file("check()")); + assert_ne!(a, b, "both labelled overloads resolved to {a}"); + assert!(a.ends_with("OverloadA.swift") && none.ends_with("OverloadA.swift")); + assert!(b.ends_with("OverloadB.swift")); +} + // Collapsing the classname to its target would make one `shared()` borrow the other's file. #[test] fn two_suites_declaring_the_same_case_resolve_separately() { From 98a9eb0a47d4eb8eb3bc4448288a500e5824acd9 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Mon, 31 Aug 2026 13:38:33 -0700 Subject: [PATCH 20/24] test(xcresult): cover an inherited XCTest method, which has no labels to key on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An XCTest test method takes no arguments, so there is nothing like `check(a:)` to separate two of them — every one normalises to a bare method name and the class name carries the entire load. That is the opposite of the swift-testing overload case, and it was untested end to end on both inputs. `BaseTests`, `ChildATests` and `ChildBTests` all report a test called `testInherited`, distinguished only by class: MyCLITests.BaseTests testInherited -> BaseTests.swift declares it MyCLITests.ChildATests testInherited -> BaseTests.swift inherits it MyCLITests.ChildBTests testInherited -> ChildBTests.swift overrides it The inherited case is resolved by walking `supertypes`, built from the language server's superclass parse, and the overriding case never needs the walk because the subclass declares the method itself. Both inputs spell the identifier the same way (`ChildATests/testInherited()` against classname `MyCLITests.ChildATests` plus `testInherited`), so this is covered by the parity test too — which is also why comparison is over pairs rather than a map, since three tests now share one name. `supertypes` had only a seeded-index unit test behind it before this. Disabling the chain walk fails the `inherited` case alone, leaving `declared` and `overridden` passing. Co-Authored-By: Claude Opus 5 (1M context) --- .../data/swift-test-parity.xcresult.tar.gz | Bin 45562 -> 40609 bytes .../data/swift-test-xunit-xctest.junit.xml | 10 ++++- .../tests/data/swift-test-xunit.junit.xml | 20 ++++----- .../fixture-src/swift-test-xunit/README.md | 16 ++++++++ .../Tests/MyCLITests/BaseTests.swift | 5 +++ .../Tests/MyCLITests/ChildATests.swift | 4 ++ .../Tests/MyCLITests/ChildBTests.swift | 6 +++ xcresult/tests/swift_test_xunit.rs | 38 +++++++++++++++--- 8 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/BaseTests.swift create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/ChildATests.swift create mode 100644 xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/ChildBTests.swift diff --git a/xcresult/tests/data/swift-test-parity.xcresult.tar.gz b/xcresult/tests/data/swift-test-parity.xcresult.tar.gz index 8b17eded0537b8242f9796ddaf730b858490d3d4..274f248467a1c64d58d3201602609dce3f62196d 100644 GIT binary patch literal 40609 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 R4}bXg@n3pqF`WQV0RVX*1mFMw literal 45562 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 - - + + + + + + + + diff --git a/xcresult/tests/data/swift-test-xunit.junit.xml b/xcresult/tests/data/swift-test-xunit.junit.xml index 1fea7036..edd16d67 100644 --- a/xcresult/tests/data/swift-test-xunit.junit.xml +++ b/xcresult/tests/data/swift-test-xunit.junit.xml @@ -1,14 +1,14 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/xcresult/tests/fixture-src/swift-test-xunit/README.md b/xcresult/tests/fixture-src/swift-test-xunit/README.md index ee315a93..1f5e16cf 100644 --- a/xcresult/tests/fixture-src/swift-test-xunit/README.md +++ b/xcresult/tests/fixture-src/swift-test-xunit/README.md @@ -48,6 +48,22 @@ A project with both frameworks has to upload both. | `MyCLITests.OverloadSuite` | `check(a:)` | `OverloadA.swift` | differs from `check(b:)` **only by argument label** | | `MyCLITests.OverloadSuite` | `check(b:)` | `OverloadB.swift` | declared in another file via an extension, so a normalisation that dropped labels would silently merge two distinct tests and give one the wrong file | +## XCTest has no labels, so the class name carries the whole load + +XCTest test methods take no arguments, so there is nothing like `check(a:)` to separate two +of them — every one normalises to a bare method name. `BaseTests`, `ChildATests` and +`ChildBTests` all report a test called `testInherited`, and only the class name tells them +apart: + +| `classname` | `name` | declared in | why | +| ------------------------ | --------------- | ------------------- | --------------------------------------------------------------------------------------------------- | +| `MyCLITests.BaseTests` | `testInherited` | `BaseTests.swift` | declares it | +| `MyCLITests.ChildATests` | `testInherited` | `BaseTests.swift` | inherits it — resolved by walking `supertypes`, which the language server's superclass parse builds | +| `MyCLITests.ChildBTests` | `testInherited` | `ChildBTests.swift` | overrides it, so it is declared here and no chain walk is needed | + +That is the shape most XCTest suites actually have, and it is why `supertypes` exists. +Disabling the chain walk fails only the `inherited` case, which is the point. + ## XCTest needs `--parallel`, and needs no special handling `Legacy.swift` holds an `XCTestCase`, and it lands in `` rather than diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/BaseTests.swift b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/BaseTests.swift new file mode 100644 index 00000000..e5b417db --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/BaseTests.swift @@ -0,0 +1,5 @@ +import XCTest + +class BaseTests: XCTestCase { + func testInherited() { XCTAssertTrue(true) } +} diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/ChildATests.swift b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/ChildATests.swift new file mode 100644 index 00000000..1dabe79c --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/ChildATests.swift @@ -0,0 +1,4 @@ +import XCTest + +/// Inherits `testInherited` without redeclaring it, so its declaration is in BaseTests.swift. +final class ChildATests: BaseTests {} diff --git a/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/ChildBTests.swift b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/ChildBTests.swift new file mode 100644 index 00000000..8c829185 --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/Tests/MyCLITests/ChildBTests.swift @@ -0,0 +1,6 @@ +import XCTest + +/// Overrides it, so its declaration is here. +final class ChildBTests: BaseTests { + override func testInherited() { XCTAssertTrue(true) } +} diff --git a/xcresult/tests/swift_test_xunit.rs b/xcresult/tests/swift_test_xunit.rs index 76304085..4286b0d2 100644 --- a/xcresult/tests/swift_test_xunit.rs +++ b/xcresult/tests/swift_test_xunit.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, path::Path}; +use rstest::rstest; use xcresult::test_locations::{Limits, TestKey, TestLocationIndex}; const FIXTURE_ROOT: &str = "tests/fixture-src/swift-test-xunit"; @@ -127,6 +128,34 @@ fn two_suites_declaring_the_same_case_resolve_separately() { // XCTest goes to its own file and names a case `Module.Class` + a bare method, so it needs no // separate handling — the innermost component is still the declaring type. +// +// `ChildATests` is the case that cannot be answered. It declares no `testInherited` of its +// own, so the method it ran is written elsewhere — but `testInherited` is declared twice in +// this target, by `BaseTests` (which it inherits from) and by `ChildBTests` (which merely +// overrides it). An override is a declaration of the same name, so by name alone the ancestor +// and the sibling are the same fact, and telling them apart needs an inheritance hierarchy, +// which is semantic and would cost a build to resolve. Reporting `BaseTests.swift` here would +// be a guess that happens to be right; reporting the suite's own file is the honest answer, +// and `inherited_declaration` warns when it declines. +// +// Where only one class declares the case there is nothing to guess between, and the base +// class's file is reported — `test_an_inherited_test_is_attributed_to_the_class_that_declares_it` +// covers that shape. +#[rstest] +#[case::declared("MyCLITests.BaseTests", "BaseTests.swift")] +#[case::inherited_but_ambiguous("MyCLITests.ChildATests", "ChildATests.swift")] +#[case::overridden("MyCLITests.ChildBTests", "ChildBTests.swift")] +fn an_xctest_method_resolves_to_its_declaration_unless_two_classes_declare_it( + #[case] classname: &str, + #[case] expected: &str, +) { + let resolved = resolve_from(XUNIT_XCTEST); + let file = resolved + .get(&(String::from(classname), String::from("testInherited"))) + .unwrap_or_else(|| panic!("{classname} resolved to nothing")); + assert!(file.ends_with(expected), "expected {expected}, got {file}"); +} + #[test] fn an_xctest_case_resolves_to_the_class_that_declares_it() { let resolved = resolve_from(XUNIT_XCTEST); @@ -239,12 +268,11 @@ mod parity { // the two inputs does not land on one identity. Pinned so it cannot change unnoticed. #[test] fn the_two_inputs_spell_an_xctest_method_differently() { - assert_eq!( + assert!( testcases(XUNIT_XCTEST) - .into_iter() - .map(|(_, name)| name) - .collect::>(), - vec![String::from("testOldStyle")] + .iter() + .any(|(_, name)| name == "testOldStyle"), + "the xunit spells it with parens" ); assert!( xcresult_raw_names().contains(&String::from("testOldStyle()")), From cf97a8574f25cbcca7ca3352974eda855bebc49b Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Wed, 9 Sep 2026 21:33:40 +0000 Subject: [PATCH 21/24] test(xcresult): give CI the Swift toolchain these tests actually need `find_program` looks on `PATH`, which is where swift.org's tarball instructions, Swiftly and the official Docker images all put `sourcekit-lsp`. The Ubuntu runner image is the exception: `install-swift.sh` symlinks only `swift` and `swiftc` into /usr/local/bin and leaves the rest of the toolchain reachable only through $SWIFT_PATH. So put that directory on `PATH` for the Linux test jobs. A missing server leaves the index empty rather than failing, so these tests failed rather than skipped on any machine without a Swift toolchain -- including the self-hosted runner behind the Windows job, which has none. They now skip when no server is found, and `REQUIRE_LANGUAGE_SERVER` turns that skip back into a failure everywhere CI is supposed to have provided one, so the coverage cannot lapse unnoticed on the runners that matter. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/pull_request.yml | 19 +++++++++++++++ xcresult/tests/swift_test_xunit.rs | 39 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 2c830c25..be778c31 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -198,10 +198,29 @@ jobs: exit 1 fi + # The declaration path finds `sourcekit-lsp` on `PATH`, which is where every documented + # Linux install puts it. The Ubuntu runner image is the exception: it symlinks only + # `swift` and `swiftc` into /usr/local/bin and leaves the rest of the toolchain reachable + # only through $SWIFT_PATH. Without this the swift-test-xunit tests find no server. + - name: Put the Swift toolchain on PATH + if: ${{ startsWith(matrix.platform.os-name, 'linux-') }} + shell: bash + run: | + if [[ -z "${SWIFT_PATH:-}" ]]; then + echo "SWIFT_PATH is unset - the runner image no longer ships Swift where expected" >&2 + exit 1 + fi + echo "$SWIFT_PATH" >> "$GITHUB_PATH" + - name: Run tests uses: ./.github/actions/run_tests id: tests continue-on-error: true + env: + # macOS finds the server through `xcrun`, and the step above puts it on `PATH` for + # Linux -- so anywhere but the self-hosted Windows runner, which has no Swift at all, + # a missing server means the environment broke rather than that these should skip. + REQUIRE_LANGUAGE_SERVER: ${{ matrix.platform.os-name != 'windows-x86_64' && '1' || '' }} with: target: ${{ matrix.platform.target }} codecov-token: ${{ secrets.CODECOV_TOKEN }} diff --git a/xcresult/tests/swift_test_xunit.rs b/xcresult/tests/swift_test_xunit.rs index 4286b0d2..81b8735a 100644 --- a/xcresult/tests/swift_test_xunit.rs +++ b/xcresult/tests/swift_test_xunit.rs @@ -5,6 +5,7 @@ use std::{collections::HashMap, path::Path}; use rstest::rstest; use xcresult::test_locations::{Limits, TestKey, TestLocationIndex}; +use xcresult::xcrun::find_program; const FIXTURE_ROOT: &str = "tests/fixture-src/swift-test-xunit"; const XUNIT: &str = include_str!("data/swift-test-xunit.junit.xml"); @@ -12,6 +13,29 @@ const XUNIT: &str = include_str!("data/swift-test-xunit.junit.xml"); /// file from swift-testing's — which goes to `-swift-testing.xml`. const XUNIT_XCTEST: &str = include_str!("data/swift-test-xunit-xctest.junit.xml"); +/// Every case below resolves through a real `sourcekit-lsp` -- the fixture is all Swift, so +/// no `clangd` is involved. A missing server leaves the index empty rather than failing, so +/// without this the whole file fails on a machine that simply has no Swift toolchain. +/// +/// Skipping is only safe while something still runs these, so `REQUIRE_LANGUAGE_SERVER` turns +/// the skip back into a failure. CI sets it wherever it has just put the toolchain on `PATH`, +/// which is what stops the coverage from lapsing there unnoticed. +fn language_server_is_available() -> bool { + if find_program("sourcekit-lsp").is_some() { + return true; + } + // Empty counts as unset: a workflow computing this per-runner writes `""` for the ones it + // does not apply to, and `var_os` would otherwise read that as "required". + let required = std::env::var("REQUIRE_LANGUAGE_SERVER").is_ok_and(|value| !value.is_empty()); + assert!( + !required, + "sourcekit-lsp is not on PATH and REQUIRE_LANGUAGE_SERVER is set -- the toolchain this \ + job is supposed to provide is missing, so these tests would have silently skipped" + ); + eprintln!("skipping: sourcekit-lsp is not on PATH, so no declaration can be resolved"); + false +} + fn testcases(xunit: &str) -> Vec<(String, String)> { xunit .lines() @@ -64,6 +88,9 @@ fn resolve_from(xunit: &str) -> HashMap<(String, String), String> { #[test] fn every_swift_testing_case_resolves_to_the_file_it_is_declared_in() { + if !language_server_is_available() { + return; + } let resolved = resolve(); for (classname, name, expected) in [ ("MyCLITests", "helloworld()", "TopLevel.swift"), @@ -94,6 +121,9 @@ fn every_swift_testing_case_resolves_to_the_file_it_is_declared_in() { #[test] fn overloads_differing_only_by_argument_label_resolve_separately() { + if !language_server_is_available() { + return; + } let resolved = resolve(); let file = |name: &str| { resolved @@ -110,6 +140,9 @@ fn overloads_differing_only_by_argument_label_resolve_separately() { // Collapsing the classname to its target would make one `shared()` borrow the other's file. #[test] fn two_suites_declaring_the_same_case_resolve_separately() { + if !language_server_is_available() { + return; + } let resolved = resolve(); let alpha = resolved .get(&( @@ -149,6 +182,9 @@ fn an_xctest_method_resolves_to_its_declaration_unless_two_classes_declare_it( #[case] classname: &str, #[case] expected: &str, ) { + if !language_server_is_available() { + return; + } let resolved = resolve_from(XUNIT_XCTEST); let file = resolved .get(&(String::from(classname), String::from("testInherited"))) @@ -158,6 +194,9 @@ fn an_xctest_method_resolves_to_its_declaration_unless_two_classes_declare_it( #[test] fn an_xctest_case_resolves_to_the_class_that_declares_it() { + if !language_server_is_available() { + return; + } let resolved = resolve_from(XUNIT_XCTEST); let file = resolved .get(&( From 8e10f65ca4f3de6f67c23b1f41418c7717cec2bf Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Mon, 31 Aug 2026 12:45:45 -0700 Subject: [PATCH 22/24] feat(cli): add --swift-test-xunit-paths and fill in declared files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swift test --xunit-output` writes no file path for any test, so a Swift test run outside Xcode has never been attributable to a file. This reads those files, resolves each test to where a language server says it is declared, and writes the `file` attribute back before the JUnit is bundled. It is a **path list rather than a boolean on `--junit-paths`**, for three reasons. Provenance: declaring that a file came from `swift test` is what licenses parsing its `classname` as a Swift type path, and JUnit5 emits the same `Type.method()` shape, so guessing would be unsound in a repository holding both. Cost: resolving walks the checkout, and a repository with no Swift at all should not pay that on every upload. Precedence: an `.xcresult` and a `swift test` xunit are different files, not two readings of one file, so with separate lists there is nothing to arbitrate — no mode, and no platform-conditional default. Taking a list rather than one path also matters because a single `swift test` run writes **two** of them: swift-testing to `-swift-testing.xml` and XCTest to ``, the latter only when `--parallel` is passed. A project using both frameworks uploads both, and one index serves them all, so that costs one checkout scan rather than one per file. Unlike `--xcresult-path`, this is not gated to macOS. The dependency is a language server rather than `xcresulttool`, and `sourcekit-lsp` ships with the Swift toolchain on Linux, so the flag is available wherever `swift test` is. The xcresult flags stay macOS-only because a bundle cannot be read without Xcode. A test that already carries a file keeps it. The resolved/unresolved split is logged, and unresolved warns — which is only a meaningful signal because the input was declared rather than guessed. Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/context.rs | 123 +++++++++++++++++++++++++++++++++++++- cli/src/upload_command.rs | 13 ++++ cli/tests/upload.rs | 106 ++++++++++++++++++++++++++++++++ constants/src/lib.rs | 1 + xcresult/CONTRIBUTING.md | 27 +++++++++ 5 files changed, 269 insertions(+), 1 deletion(-) diff --git a/cli/src/context.rs b/cli/src/context.rs index b35fdf66..fcdf2125 100644 --- a/cli/src/context.rs +++ b/cli/src/context.rs @@ -39,8 +39,9 @@ use proto::test_context::test_run::{ }; use regex::Regex; use tempfile::TempDir; +use xcresult::test_locations::{Limits, TestKey, TestLocationIndex}; #[cfg(target_os = "macos")] -use xcresult::{test_locations::Limits, xcresult::XCResult}; +use xcresult::xcresult::XCResult; use crate::error_report::InterruptingError; use crate::{ @@ -125,6 +126,7 @@ pub fn gather_initial_test_context( #[cfg(target_os = "macos")] xcresult_path, bazel_bep_path, + swift_test_xunit_paths, test_reports, org_url_slug, repo_root, @@ -187,6 +189,8 @@ pub fn gather_initial_test_context( xcresult_path, #[cfg(target_os = "macos")] &xcresult_options, + swift_test_xunit_paths, + &repo.repo_root, test_reports, allow_empty_test_results, )?; @@ -661,6 +665,8 @@ fn coalesce_junit_path_wrappers( bazel_bep_path: Option, #[cfg(target_os = "macos")] xcresult_path: Option, #[cfg(target_os = "macos")] xcresult_options: &XCResultOptions, + swift_test_xunit_paths: Vec, + repo_root: &str, test_reports: Vec, allow_empty_test_results: bool, ) -> anyhow::Result<( @@ -721,6 +727,25 @@ fn coalesce_junit_path_wrappers( } } + if !swift_test_xunit_paths.is_empty() { + let temp_dir = match _junit_path_wrappers_temp_dir.take() { + Some(temp_dir) => temp_dir, + None => tempfile::tempdir()?, + }; + let temp_paths = handle_swift_test_xunit(&temp_dir, &swift_test_xunit_paths, repo_root)?; + _junit_path_wrappers_temp_dir = Some(temp_dir); + junit_path_wrappers = [junit_path_wrappers.as_slice(), temp_paths.as_slice()].concat(); + if junit_path_wrappers.is_empty() { + if allow_empty_test_results { + tracing::warn!("No tests found in the provided swift test xunit paths."); + } else { + return Err(anyhow::anyhow!( + "No tests found in the provided swift test xunit paths." + )); + } + } + } + if !test_reports.is_empty() { for test_report in test_reports { if let Ok(bazel_result) = parse_as_bep(test_report.clone()) { @@ -887,6 +912,96 @@ pub async fn gather_upload_id_context( } #[cfg(target_os = "macos")] +/// `swift test --xunit-output` writes no file for any test, so each one's file is taken from +/// where a language server says it is declared. Needs no Xcode, unlike the `.xcresult` path. +fn handle_swift_test_xunit( + junit_temp_dir: &tempfile::TempDir, + paths: &[String], + repo_root: &str, +) -> anyhow::Result> { + let mut reports = Vec::new(); + for path in paths { + let file = std::fs::File::open(path) + .map_err(|e| anyhow::anyhow!("failed to open {}: {}", path, e))?; + let mut parser = JunitParser::new(); + parser + .parse(BufReader::new(file)) + .map_err(|e| anyhow::anyhow!("failed to parse {} as JUnit XML: {}", path, e))?; + reports.extend(parser.into_reports()); + } + + // One index for every file, so a run uploading several does one scan rather than one each. + let keys = reports + .iter() + .flat_map(|report| report.test_suites.iter()) + .flat_map(|test_suite| test_suite.test_cases.iter()) + .filter_map(|test_case| { + let classname = test_case.classname.as_ref()?.as_str(); + Some(( + TestKey::from_junit_classname(classname, test_case.name.as_str()), + TestKey::target_from_junit_classname(classname), + )) + }) + .collect::>(); + let index = TestLocationIndex::resolve(Path::new(repo_root), &keys, Limits::default()); + + let mut resolved = 0_usize; + let mut unresolved = 0_usize; + for report in &mut reports { + for test_suite in &mut report.test_suites { + for test_case in &mut test_suite.test_cases { + if test_case.extra.contains_key("file") { + continue; + } + let site = test_case + .classname + .as_ref() + .map(|classname| { + TestKey::from_junit_classname(classname.as_str(), test_case.name.as_str()) + }) + .and_then(|key| index.lookup(&key)); + match site { + Some(site) => { + resolved += 1; + test_case + .extra + .insert("file".into(), site.file.as_str().into()); + } + None => unresolved += 1, + } + } + } + } + if unresolved > 0 { + tracing::warn!( + "{} of {} swift test case(s) have no declaration under {}", + unresolved, + resolved + unresolved, + repo_root + ); + } + tracing::info!("swift test files: {resolved} from a declaration, {unresolved} unresolved"); + + let mut temp_paths = Vec::new(); + for (i, report) in reports.iter().enumerate() { + let mut writer: Vec = Vec::new(); + report.serialize(&mut writer)?; + let temp_path = junit_temp_dir + .path() + .join(format!("swift_test_junit_{i}.xml")); + std::fs::File::create(&temp_path)? + .write_all(&writer) + .map_err(|e| anyhow::anyhow!("failed to write junit file: {}", e))?; + let temp_path_str = temp_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("failed to convert junit temp path to string"))?; + temp_paths.push(JunitReportFileWithTestRunnerReport::from( + temp_path_str.to_string(), + )); + } + Ok(temp_paths) +} + fn handle_xcresult( junit_temp_dir: &tempfile::TempDir, xcresult_path: Option, @@ -1105,6 +1220,8 @@ mod tests { #[cfg(target_os = "macos")] &xcresult_options, Vec::new(), + "test", + Vec::new(), false, ); assert!(result_err.is_err()); @@ -1116,6 +1233,8 @@ mod tests { #[cfg(target_os = "macos")] &xcresult_options, Vec::new(), + "test", + Vec::new(), true, ); assert!(result_ok.is_ok()); @@ -1151,6 +1270,8 @@ mod tests { None, #[cfg(target_os = "macos")] &xcresult_options, + Vec::new(), + "test", vec!["test".into()], true, ); diff --git a/cli/src/upload_command.rs b/cli/src/upload_command.rs index 2b9f320a..d3966b31 100644 --- a/cli/src/upload_command.rs +++ b/cli/src/upload_command.rs @@ -72,6 +72,19 @@ pub struct UploadArgs { help = "Comma-separated list of glob patterns to test report files. Supports JUnit XML, Bazel BEP, and XCResult formats." )] pub test_reports: Vec, + #[arg( + long, + env = constants::TRUNK_SWIFT_TEST_XUNIT_PATHS_ENV, + value_delimiter = ',', + help = "Comma-separated list of JUnit files written by `swift test --xunit-output`. \ + These carry no file path, so each test's file is taken from where a language \ + server says it is declared in the repository. One run writes two files: \ + swift-testing to `-swift-testing.xml` and XCTest to ``, the \ + latter only when `--parallel` is also passed. Upload both if the project \ + uses both frameworks.", + required = false + )] + pub swift_test_xunit_paths: Vec, /// Always required — do not make this optional. It is what attributes a run to an organization /// in our telemetry: it is tagged onto every error we forward to Sentry (see `setup_logger` in /// `main.rs`), recorded on the bundle as `base_props.org`, and scopes the upload metrics we diff --git a/cli/tests/upload.rs b/cli/tests/upload.rs index a5b9a5a0..8db92602 100644 --- a/cli/tests/upload.rs +++ b/cli/tests/upload.rs @@ -3139,3 +3139,109 @@ async fn upload_bundle_keeps_the_repo_relative_path_when_a_symlink_leaves_the_re // HINT: View CLI output with `cargo test -- --nocapture` println!("{assert}"); } + +// `swift test --xunit-output` reports no file for any test, so the uploaded JUnit only gets +// one if a language server found where each test is declared in the checkout. +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[tokio::test(flavor = "multi_thread")] +async fn upload_bundle_using_swift_test_xunit() { + let temp_dir = tempdir().unwrap(); + generate_mock_git_repo(&temp_dir); + + let tests_dir = temp_dir.path().join("Tests/MyCLITests"); + fs::create_dir_all(&tests_dir).unwrap(); + fs::write( + temp_dir.path().join("Package.swift"), + "// swift-tools-version: 6.0\n", + ) + .unwrap(); + fs::write( + tests_dir.join("TopLevel.swift"), + "import Testing\n\n@Test func helloworld() {}\n", + ) + .unwrap(); + fs::write( + tests_dir.join("Suites.swift"), + "import Testing\n\n@Suite struct AlphaSuite {\n @Test func shared() {}\n}\n", + ) + .unwrap(); + fs::write( + tests_dir.join("Legacy.swift"), + "import XCTest\n\nfinal class LegacyXCTests: XCTestCase {\n func testOldStyle() {}\n}\n", + ) + .unwrap(); + fs::write( + temp_dir.path().join("xunit-swift-testing.xml"), + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ), + ) + .unwrap(); + fs::write( + temp_dir.path().join("xunit.xml"), + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ), + ) + .unwrap(); + + let state = MockServerBuilder::new().spawn_mock_server().await; + CommandBuilder::upload(temp_dir.path(), state.host.clone()) + .extra_args(&[ + "--swift-test-xunit-paths", + "xunit-swift-testing.xml,xunit.xml", + ]) + .command() + .assert() + .success(); + + let requests = state.requests.lock().unwrap().clone(); + let tar_extract_directory = assert_matches!(&requests[1], RequestPayload::S3Upload(d) => d); + let bundle_meta: BundleMeta = + serde_json::from_reader(fs::File::open(tar_extract_directory.join("meta.json")).unwrap()) + .unwrap(); + + let mut files = std::collections::HashMap::new(); + for file_set in &bundle_meta.base_props.file_sets { + for file in &file_set.files { + let mut parser = JunitParser::new(); + let junit = fs::File::open(tar_extract_directory.join(&file.path)).unwrap(); + parser.parse(BufReader::new(junit)).unwrap(); + for report in parser.into_reports() { + for suite in &report.test_suites { + for case in &suite.test_cases { + let file = case + .extra + .iter() + .find(|(key, _)| key.as_str() == "file") + .map(|(_, value)| value.as_str().to_owned()); + files.insert(case.name.as_str().to_owned(), file); + } + } + } + } + } + + for (name, expected) in [ + ("helloworld()", "Tests/MyCLITests/TopLevel.swift"), + ("shared()", "Tests/MyCLITests/Suites.swift"), + ("testOldStyle", "Tests/MyCLITests/Legacy.swift"), + ] { + let file = files + .get(name) + .unwrap_or_else(|| panic!("{name} is missing from the bundle")) + .as_deref() + .unwrap_or_else(|| panic!("{name} got no file from its declaration")); + assert!( + file.ends_with(expected), + "expected {name} in {expected}, got {file}" + ); + } +} diff --git a/constants/src/lib.rs b/constants/src/lib.rs index cc9b9639..b442946e 100644 --- a/constants/src/lib.rs +++ b/constants/src/lib.rs @@ -74,6 +74,7 @@ pub const TRUNK_XCRESULT_TEST_LOCATIONS_MAX_FILE_BYTES_ENV: &str = // RSpec-only: when set to "true", aborts the RSpec run if quarantine lookup fails. // Handled in rspec-trunk-flaky-tests/lib/trunk_spec_helper.rb, not the CLI. +pub const TRUNK_SWIFT_TEST_XUNIT_PATHS_ENV: &str = "TRUNK_SWIFT_TEST_XUNIT_PATHS"; pub const TRUNK_QUARANTINE_QUERY_FAILURE_EXIT_ENV: &str = "TRUNK_QUARANTINE_QUERY_FAILURE_EXIT"; // TRUNK_* environment variables to capture in bundle metadata for debugging. diff --git a/xcresult/CONTRIBUTING.md b/xcresult/CONTRIBUTING.md index 574a9e3e..f1b89451 100644 --- a/xcresult/CONTRIBUTING.md +++ b/xcresult/CONTRIBUTING.md @@ -112,6 +112,33 @@ shared path, so it applies to the default one too. **Ids.** `generate_id` prefers `nodeIdentifierURL` here, which is the legacy record's `identifierURL` under another name, so ids do not move between the two paths. +**An xcresult and a `swift test` xunit give the same test different ids, deliberately for +now.** The two take different branches in `context/src/junit/parser.rs`: an xcresult test +case carries an `id` extra, a v5 UUID over `org#repo#identifierURL`, which is used verbatim; +a `swift test` xunit carries none, so identity falls to `gen_info_id` over +`(org, repo, file, classname, parent_name, name)` — the same scheme every other JUnit +uploader uses. Measured on one package captured both ways: + + shared() xcresult e40658ab-… xunit 73301b89-… + +So a repository uploading both formats sees every test twice, and moving a repository from +one format to the other resets its history. + +They are not unified yet because neither half is free. `identifierURL` leads with the +**xcodebuild scheme name**, which appears nowhere in xunit output, so the xunit path cannot +reproduce an xcresult id; and rederiving the xcresult id from normalised components would +reset history for every repository uploading `.xcresult` today. Giving the xunit path its own +third scheme in the meantime would cost those users two resets rather than one. The intent is +a single migration that moves both onto one format. + +Two consequences worth knowing while that is outstanding. `file` is a `gen_info_id` input, so +turning `--swift-test-xunit-paths` on for a repository that previously uploaded the same file +via `--junit-paths` changes every id once, and a test whose declaration later moves to +another file changes id again — the xcresult scheme is immune to both, since its id ignores +`file`. And `name` is an input too, which is why +`the_two_inputs_spell_an_xctest_method_differently` pins `testOldStyle` against +`testOldStyle()`: that difference feeds the checksum. + **Cost.** Roughly 13 ms per file parsed. The scan is ranked so files named after a suite go first and stops as soon as every test resolves; `Limits` caps files parsed, total wall clock, and per-request time, and a server that stops answering is killed rather than waited From 43b1b20abf99d88d52d316e3a1eb82a4f224389c Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Wed, 9 Sep 2026 21:13:06 +0000 Subject: [PATCH 23/24] fix(cli): keep the xcresult gate on the xcresult handler `handle_swift_test_xunit` was inserted directly after the `#[cfg(target_os = "macos")]` belonging to `handle_xcresult`, so the new function took the gate and `handle_xcresult` lost it. That broke every non-macOS build five ways: the swift xunit handler went missing at its unconditional call site, `handle_xcresult` started compiling against `XCResult`/`XCResultOptions` that are gated out, and `write_all` lost the macOS-gated `Write` import. `--swift-test-xunit-paths` is not platform-gated and needs no Xcode, so the handler stays ungated and the gate goes back where it was. `Duration` moves under the gate instead -- it is only used by the macOS-only `XCResultOptions`. Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/context.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/src/context.rs b/cli/src/context.rs index fcdf2125..a33b7072 100644 --- a/cli/src/context.rs +++ b/cli/src/context.rs @@ -1,12 +1,12 @@ #[cfg(target_os = "macos")] -use std::io::Write; +use std::time::Duration; use std::{collections::BTreeMap, io::Read}; use std::{ collections::HashMap, env, - io::BufReader, + io::{BufReader, Write}, path::Path, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::{SystemTime, UNIX_EPOCH}, }; use api::{client::ApiClient, message::CreateBundleUploadResponse}; @@ -911,7 +911,6 @@ pub async fn gather_upload_id_context( Ok(upload) } -#[cfg(target_os = "macos")] /// `swift test --xunit-output` writes no file for any test, so each one's file is taken from /// where a language server says it is declared. Needs no Xcode, unlike the `.xcresult` path. fn handle_swift_test_xunit( @@ -1002,6 +1001,7 @@ fn handle_swift_test_xunit( Ok(temp_paths) } +#[cfg(target_os = "macos")] fn handle_xcresult( junit_temp_dir: &tempfile::TempDir, xcresult_path: Option, From baffa6a3b47a38d260dcb04b3f905d3d5d4504f9 Mon Sep 17 00:00:00 2001 From: Dylan Frankland Date: Wed, 9 Sep 2026 22:24:52 +0000 Subject: [PATCH 24/24] fix(xcresult): fence the id-comparison block like every other one in the file markdownlint MD046 defaults to `consistent`, and this was the file's only indented code block against three fenced ones. Co-Authored-By: Claude Opus 5 (1M context) --- xcresult/CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xcresult/CONTRIBUTING.md b/xcresult/CONTRIBUTING.md index f1b89451..712d8d25 100644 --- a/xcresult/CONTRIBUTING.md +++ b/xcresult/CONTRIBUTING.md @@ -119,7 +119,9 @@ a `swift test` xunit carries none, so identity falls to `gen_info_id` over `(org, repo, file, classname, parent_name, name)` — the same scheme every other JUnit uploader uses. Measured on one package captured both ways: - shared() xcresult e40658ab-… xunit 73301b89-… +```text +shared() xcresult e40658ab-… xunit 73301b89-… +``` So a repository uploading both formats sees every test twice, and moving a repository from one format to the other resets its history.