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/src/test_locations.rs b/xcresult/src/test_locations.rs index 01d71051..75496dcc 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,95 @@ 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 + ); + } + + // 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::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", + "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 00000000..274f2484 Binary files /dev/null and b/xcresult/tests/data/swift-test-parity.xcresult.tar.gz differ 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..c243c073 --- /dev/null +++ b/xcresult/tests/data/swift-test-xunit-xctest.junit.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + 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..edd16d67 --- /dev/null +++ b/xcresult/tests/data/swift-test-xunit.junit.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + 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..1f5e16cf --- /dev/null +++ b/xcresult/tests/fixture-src/swift-test-xunit/README.md @@ -0,0 +1,103 @@ +# `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 | +| `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 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 +`-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. + +## 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/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/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/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/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/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/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/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..81b8735a --- /dev/null +++ b/xcresult/tests/swift_test_xunit.rs @@ -0,0 +1,322 @@ +//! `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 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"); +/// `--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"); + +/// 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() + .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() { + if !language_server_is_available() { + return; + } + 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}" + ); + } +} + +#[test] +fn overloads_differing_only_by_argument_label_resolve_separately() { + if !language_server_is_available() { + return; + } + 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() { + if !language_server_is_available() { + return; + } + 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. +// +// `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, +) { + if !language_server_is_available() { + return; + } + 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() { + if !language_server_is_available() { + return; + } + 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}"); +} + +/// 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!( + testcases(XUNIT_XCTEST) + .iter() + .any(|(_, name)| name == "testOldStyle"), + "the xunit spells it with parens" + ); + assert!( + xcresult_raw_names().contains(&String::from("testOldStyle()")), + "the bundle spells it {:?}", + xcresult_raw_names() + ); + } +}