Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions xcresult/src/test_locations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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<String> {
let target = classname.split('.').next()?;
(!target.is_empty()).then(|| target.to_string())
}

/// `test://com.apple.xcode/<scheme>/<target>/<suite>/<case>` — 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<String> {
Expand Down Expand Up @@ -776,4 +794,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)
);
}
}
Binary file not shown.
14 changes: 14 additions & 0 deletions xcresult/tests/data/swift-test-xunit-xctest.junit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>

<testsuites>
<testsuite name="TestResults" errors="0" tests="4" failures="0" time="0.202756709">
<testcase classname="MyCLITests.BaseTests" name="testInherited" time="0.05068575">
</testcase>
<testcase classname="MyCLITests.LegacyXCTests" name="testOldStyle" time="0.050689625">
</testcase>
<testcase classname="MyCLITests.ChildBTests" name="testInherited" time="0.0506905">
</testcase>
<testcase classname="MyCLITests.ChildATests" name="testInherited" time="0.050690834">
</testcase>
</testsuite>
</testsuites>
14 changes: 14 additions & 0 deletions xcresult/tests/data/swift-test-xunit.junit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="TestResults" errors="0" tests="9" failures="0" skipped="0" time="0.001223417">
<testcase classname="MyCLITests.AlphaSuite.Inner" name="deep()" time="0.000609833" />
<testcase classname="MyCLITests.AlphaSuite" name="shared()" time="0.000663375" />
<testcase classname="MyCLITests.ParamSuite" name="squares(n:)" time="0.00069175" />
<testcase classname="MyCLITests.ParamSuite" name="pairs(s:flag:)" time="0.000711375" />
<testcase classname="MyCLITests.BetaSuite" name="shared()" time="0.000742042" />
<testcase classname="MyCLITests" name="helloworld()" time="0.000865667" />
<testcase classname="MyCLITests.OverloadSuite" name="check(a:)" time="0.000674875" />
<testcase classname="MyCLITests.OverloadSuite" name="check()" time="0.000672042" />
<testcase classname="MyCLITests.OverloadSuite" name="check(b:)" time="0.000733667" />
</testsuite>
</testsuites>
10 changes: 10 additions & 0 deletions xcresult/tests/fixture-src/swift-test-xunit/Package.swift
Original file line number Diff line number Diff line change
@@ -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"]),
]
)
103 changes: 103 additions & 0 deletions xcresult/tests/fixture-src/swift-test-xunit/README.md
Original file line number Diff line number Diff line change
@@ -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 `<name>`:

| file | holds |
| -------------------------- | --------------------------------- |
| `<name>-swift-testing.xml` | swift-testing (`@Test`, `@Suite`) |
| `<name>` | 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 `<name>` rather than
`<name>-swift-testing.xml`:

```xml
<testcase classname="MyCLITests.LegacyXCTests" name="testOldStyle" />
```

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`.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
public let answer = 42
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import XCTest

class BaseTests: XCTestCase {
func testInherited() { XCTAssertTrue(true) }
}
Original file line number Diff line number Diff line change
@@ -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) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import XCTest

/// Inherits `testInherited` without redeclaring it, so its declaration is in BaseTests.swift.
final class ChildATests: BaseTests {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import XCTest

/// Overrides it, so its declaration is here.
final class ChildBTests: BaseTests {
override func testInherited() { XCTAssertTrue(true) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import XCTest

final class LegacyXCTests: XCTestCase {
func testOldStyle() { XCTAssertTrue(true) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Testing

@Suite struct OverloadSuite {
@Test func check() {}
@Test(arguments: [1]) func check(a: Int) { _ = a }
}
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
@@ -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) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Testing

@Suite struct AlphaSuite {
@Test func shared() { #expect(true) }

@Suite struct Inner {
@Test func deep() { #expect(true) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Testing

@Test func helloworld() {
#expect(1 == 1)
}
Loading
Loading