Skip to content
Merged
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
13 changes: 13 additions & 0 deletions xcresult/tests/data/test-crash-in-dependency.junit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="xcresult: CrashInDependency-Package" tests="2" failures="2" errors="0">
<testsuite name="CrashInDependencyTests.CrashInDependencyTests" tests="1" disabled="0" errors="0" failures="1">
<testcase name="testCrashesInsideDependency()" classname="CrashInDependencyTests" timestamp="2026-08-17T21:32:22.255+00:00" id="c905c0b7-41b1-5b8d-9d0a-e822c9fa1a10">
<failure message="Crash: xctest at crashInsideDependency()"/>
</testcase>
</testsuite>
<testsuite name="CrashInDependencyTests.TeardownFailureTests" tests="1" disabled="0" errors="0" failures="1">
<testcase name="failsAfterItsOwnFrameIsGone()" classname="TeardownFailureTests" timestamp="2026-08-17T21:32:22.255+00:00" id="fbe8da22-5c10-5739-a04b-180a7f1bf271">
<failure message="TeardownFailureTrait.swift:19: Issue recorded: the dependency&apos;s trait failed the test after its body returned"/>
</testcase>
</testsuite>
</testsuites>
Binary file not shown.
8 changes: 8 additions & 0 deletions xcresult/tests/data/test-dependency-raises-failure.junit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="xcresult: DependencyRaisesFailure-Package" tests="1" failures="1" errors="0">
<testsuite name="DependencyRaisesFailureTests.DependencyRaisesFailureTests" tests="1" disabled="0" errors="0" failures="1">
<testcase name="failsInsideDependency()" classname="DependencyRaisesFailureTests" timestamp="2026-08-17T21:30:41.708+00:00" id="6ed95be9-15ec-5133-84c7-2af0bd4bc487" file="/tmp/xcresult-fixtures/dependency-raises-failure/Tests/DependencyRaisesFailureTests/DependencyRaisesFailureTests.swift">
<failure message="FixtureSupport.swift:15: Issue recorded: recorded from the dependency&apos;s own source"/>
</testcase>
</testsuite>
</testsuites>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="xcresult: DependencyRaisesFailure-Package" tests="1" failures="1" errors="0">
<testsuite name="DependencyRaisesFailureTests.DependencyRaisesFailureTests" tests="1" disabled="0" errors="0" failures="1">
<testcase name="failsInsideDependency()" classname="DependencyRaisesFailureTests" timestamp="2026-08-17T21:30:41.708+00:00" id="6ed95be9-15ec-5133-84c7-2af0bd4bc487">
<failure message="FixtureSupport.swift:15: Issue recorded: recorded from the dependency&apos;s own source"/>
</testcase>
</testsuite>
</testsuites>
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="xcresult: InRepoHelperRaisesFailure-Package" tests="1" failures="1" errors="0">
<testsuite name="InRepoHelperRaisesFailureTests.InRepoHelperRaisesFailureTests" tests="1" disabled="0" errors="0" failures="1">
<testcase name="failsInsideHelper()" classname="InRepoHelperRaisesFailureTests" timestamp="2026-08-17T21:31:43.127+00:00" id="bccf79c6-64a4-591c-8391-6c52d31b9574" file="/tmp/xcresult-fixtures/in-repo-helper-raises-failure/Tests/InRepoHelperRaisesFailureTests/InRepoHelperRaisesFailureTests.swift">
<failure message="FailureHelper.swift:11: Issue recorded: recorded from a helper in the test target"/>
</testcase>
</testsuite>
</testsuites>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="xcresult: InRepoHelperRaisesFailure-Package" tests="1" failures="1" errors="0">
<testsuite name="InRepoHelperRaisesFailureTests.InRepoHelperRaisesFailureTests" tests="1" disabled="0" errors="0" failures="1">
<testcase name="failsInsideHelper()" classname="InRepoHelperRaisesFailureTests" timestamp="2026-08-17T21:31:43.127+00:00" id="bccf79c6-64a4-591c-8391-6c52d31b9574" file="/tmp/xcresult-fixtures/in-repo-helper-raises-failure/Tests/InRepoHelperRaisesFailureTests/FailureHelper.swift">
<failure message="FailureHelper.swift:11: Issue recorded: recorded from a helper in the test target"/>
</testcase>
</testsuite>
</testsuites>
Binary file not shown.
8 changes: 8 additions & 0 deletions xcresult/tests/data/test-objc-xctest.junit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="xcresult: ObjcXCTest-Package" tests="1" failures="1" errors="0">
<testsuite name="ObjcXCTestTests.ObjcXCTestTests" tests="1" disabled="0" errors="0" failures="1">
<testcase name="testFailsInsideSharedHelper" classname="ObjcXCTestTests" timestamp="2026-08-17T21:33:52.107+00:00" time="0.900" id="25b434ab-e5a5-50d0-81d6-5850d251427a" file="/tmp/xcresult-fixtures/objc-xctest/Tests/ObjcXCTestTests/ObjcXCTestTests.m">
<failure message="FailureHelper.m:8: failed - raised from the shared Objective-C helper"/>
</testcase>
</testsuite>
</testsuites>
8 changes: 8 additions & 0 deletions xcresult/tests/data/test-objc-xctest.legacy.junit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="xcresult: ObjcXCTest-Package" tests="1" failures="1" errors="0">
<testsuite name="ObjcXCTestTests.ObjcXCTestTests" tests="1" disabled="0" errors="0" failures="1">
<testcase name="testFailsInsideSharedHelper" classname="ObjcXCTestTests" timestamp="2026-08-17T21:33:52.107+00:00" time="0.900" id="25b434ab-e5a5-50d0-81d6-5850d251427a">
<failure message="FailureHelper.m:8: failed - raised from the shared Objective-C helper"/>
</testcase>
</testsuite>
</testsuites>
Binary file not shown.
8 changes: 8 additions & 0 deletions xcresult/tests/data/test-toplevel-swift-testing.junit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="xcresult: ToplevelSwiftTesting-Package" tests="1" failures="1" errors="0">
<testsuite name="ToplevelSwiftTestingTests" tests="1" disabled="0" errors="0" failures="1">
<testcase name="failsInsideHelperWithoutASuite()" classname="failsInsideHelperWithoutASuite()" timestamp="2026-08-17T21:34:21.783+00:00" id="30730010-eab3-52d6-8f55-56aa6e3f7eb8" file="/tmp/xcresult-fixtures/toplevel-swift-testing/Tests/ToplevelSwiftTestingTests/ToplevelSwiftTestingTests.swift">
<failure message="FailureHelper.swift:9: Issue recorded: raised from the in-repo helper"/>
</testcase>
</testsuite>
</testsuites>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="xcresult: ToplevelSwiftTesting-Package" tests="1" failures="1" errors="0">
<testsuite name="ToplevelSwiftTestingTests" tests="1" disabled="0" errors="0" failures="1">
<testcase name="failsInsideHelperWithoutASuite()" classname="failsInsideHelperWithoutASuite()" timestamp="2026-08-17T21:34:21.783+00:00" id="30730010-eab3-52d6-8f55-56aa6e3f7eb8" file="/tmp/xcresult-fixtures/toplevel-swift-testing/Tests/ToplevelSwiftTestingTests/FailureHelper.swift">
<failure message="FailureHelper.swift:9: Issue recorded: raised from the in-repo helper"/>
</testcase>
</testsuite>
</testsuites>
Binary file not shown.
7 changes: 7 additions & 0 deletions xcresult/tests/fixture-src/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# SwiftPM and SourceKit build in place if the packages are opened in an editor;
# regeneration always builds in a copy under /tmp, so nothing here is ever needed.
.build/
Package.resolved
DerivedData/
*.xcresult/
__pycache__/
94 changes: 94 additions & 0 deletions xcresult/tests/fixture-src/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# xcresult fixture sources

The `.xcresult` bundles in `../data/` are captured from the SwiftPM packages here.
Each package exists to reproduce one shape in which the file we report for a failed
test used to be a vendored dependency's rather than the test's own — see the commit
"fix(xcresult): attribute a failure to the test's own frame, not a dependency's".

Nothing in this directory is compiled by `cargo`; it is source for `regenerate.sh`,
checked in so the bundles can be rebuilt rather than being opaque binaries.

## Regenerating

Requires **macOS with Xcode** (`xcodebuild` and `xcrun xcresulttool`).

```sh
./regenerate.sh # every scenario
./regenerate.sh objc-xctest # just one
```

For each scenario the script copies the package to `/tmp/xcresult-fixtures/`, turns
its `Dependency` directory into a git repository if it has one, runs

```sh
xcodebuild test -scheme <Package>-Package -destination 'platform=macOS' \
-derivedDataPath DerivedData -resultBundlePath <Package>.xcresult
```

(nonzero exit is the expected outcome — these tests are meant to fail), strips the
bundle with `prune-bundle.py`, dumps the failure summaries with
`dump-failure-summaries.py`, checks them with `verify-failure-summaries.py`, and
only then packages the bundle into `../data/test-<scenario>.xcresult.tar.gz`.

Pruning is not an optimization; without it these are unshippable. Xcode 26 writes
about 95MB of dyld shared-cache symbolication data into every result bundle, none
of it referenced from the invocation record — a scenario whose actual test data is
under 100KB produces a 96MB bundle. `prune-bundle.py` walks the object graph from
the root (whose own id lives in `Info.plist`, not in any object) and deletes the
`Data/` entries nothing reached; for these scenarios that is 9-11 objects kept out
of ~1570 files. `regenerate.sh` captures both `xcresulttool` outputs the crate
reads — `get object --legacy` and `get test-results tests` — before and after, and
fails if pruning changed either, so the saving is verified rather than assumed.

Three details that are easy to get wrong:

- **The dependency has to be a git repository.** A `.package(path:)` dependency is
built in place; only a git URL is checked out into
`DerivedData/SourcePackages/checkouts/`, and that path _is_ the shape being
reproduced. `regenerate.sh` creates the repository in the working copy so the
checked-in sources stay a plain directory.
- **Absolute paths are baked into a bundle at capture time** and end up in the
expected JUnit XML, which is why capture happens in a fixed directory. Set
`FIXTURE_WORK_DIR` to move it, and expect every `file` attribute to change.
- **Recapturing changes the timestamps** in the expected JUnit XML even when
nothing else moves. Diff with timestamps normalized to confirm that is all that
changed before saving.

The expected JUnit XML is _not_ regenerated automatically. After recapturing, run
`cargo test -p xcresult`, read the diff, check each `file` attribute against the
table below, and only then save the new output over `../data/test-*.junit.xml`. A
`file` that lands on a dependency path is a bug to report, not output to snapshot.

## 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 |

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
file. The legacy path sees just the workspace document location — the file the
failure was _raised_ from — so where that is a dependency it now reports no file,
and where it is an in-repo helper it still reports the helper. `objc-xctest` has no
legacy file for an unrelated reason: the fallback is keyed by test-case name and
Xcode spells the Objective-C one `-[Suite testCase]`, which never matches the
`Suite.testCase` key the lookup builds.

To read what a captured bundle actually contains, run `dump-failure-summaries.py`
against it — the dump is derived from the bundle, so it is not checked in.

## Why not the older SnapshotTesting fixture

`../data/test-swift-snapshot-testing.xcresult.tar.gz` looks like it covers the
first scenario and does not: `assertSnapshot` takes `filePath: StaticString =
#filePath`, which is evaluated at the _call site_, so its `fileName` already points
at the test's own source. It passes with or without the fix. A helper that wants to
report its own location — which is what a trait or a page object does — has to build
the `SourceLocation` inside its body, which is what these fixtures do.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
name: "FixtureSupport",
platforms: [.macOS(.v13)],
products: [
.library(name: "FixtureSupport", targets: ["FixtureSupport"])
],
targets: [
.target(name: "FixtureSupport")
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/// Kills the test process from inside the dependency.
public func crashInsideDependency() -> Never {
fatalError("the dependency crashed the test process")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Testing

/// A trait that fails the test *after* its body has returned, so the test's own
/// frame is already off the stack when the issue is recorded — the shape of a
/// snapshot-verification or screenshot-diffing trait that checks its work in
/// teardown. Every file the failure summary offers is then the dependency's.
public struct TeardownFailureTrait: TestTrait, TestScoping {
public func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: () async throws -> Void
) async throws {
try await function()
Issue.record(
Comment(rawValue: "the dependency's trait failed the test after its body returned"),
sourceLocation: SourceLocation(
fileID: #fileID,
filePath: #filePath,
line: #line,
column: #column
)
)
}
}

extension Trait where Self == TeardownFailureTrait {
public static var teardownFailure: Self { .init() }
}
18 changes: 18 additions & 0 deletions xcresult/tests/fixture-src/crash-in-dependency/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// swift-tools-version: 6.0
import PackageDescription

// See `dependency-raises-failure/Package.swift` for why the dependency is a git
// URL rather than a path.
let package = Package(
name: "CrashInDependency",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "./Dependency", branch: "main")
],
targets: [
.testTarget(
name: "CrashInDependencyTests",
dependencies: [.product(name: "FixtureSupport", package: "Dependency")]
)
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import XCTest

import FixtureSupport

final class CrashInDependencyTests: XCTestCase {
/// The process dies inside the dependency, so the failure summary Xcode
/// records has no file at all — not even a wrong one.
func testCrashesInsideDependency() {
crashInsideDependency()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Testing

import FixtureSupport

@Suite
struct TeardownFailureTests {
/// The test body succeeds and the dependency's trait fails it afterwards, so
/// the test's own frame is gone by the time the failure is recorded: every
/// file source is a dependency path and none of them may be reported.
@Test(.teardownFailure)
func failsAfterItsOwnFrameIsGone() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
name: "FixtureSupport",
platforms: [.macOS(.v13)],
products: [
.library(name: "FixtureSupport", targets: ["FixtureSupport"])
],
targets: [
.target(name: "FixtureSupport")
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Testing

/// Records a failure against whatever test is running, attributed to *this* file.
///
/// `#filePath` in a function body is the file the function is defined in, so the
/// issue is raised from the dependency's source rather than from the caller's —
/// the same shape as a snapshot-testing assertion helper, a mocking framework, or
/// a page object that reports the failure at its own location.
public func recordIssueFromDependency(_ message: String) {
Issue.record(
Comment(rawValue: message),
sourceLocation: SourceLocation(
fileID: #fileID,
filePath: #filePath,
line: #line,
column: #column
)
)
}
20 changes: 20 additions & 0 deletions xcresult/tests/fixture-src/dependency-raises-failure/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// swift-tools-version: 6.0
import PackageDescription

// `Dependency` is referenced by git URL rather than by path so SPM checks it out
// into `DerivedData/SourcePackages/checkouts/Dependency` — the vendored location
// whose paths the fixture exists to reproduce. `regenerate.sh` turns the checked-in
// `Dependency` directory into a git repository before building.
let package = Package(
name: "DependencyRaisesFailure",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "./Dependency", branch: "main")
],
targets: [
.testTarget(
name: "DependencyRaisesFailureTests",
dependencies: [.product(name: "FixtureSupport", package: "Dependency")]
)
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Testing

import FixtureSupport

@Suite
struct DependencyRaisesFailureTests {
/// Every file the failure summary offers — `fileName`, the source code
/// context's location, and the innermost call-stack frame — points into the
/// dependency's checkout. Only the test's own call-stack frame names this file.
@Test
func failsInsideDependency() {
recordIssueFromDependency("recorded from the dependency's own source")
}
}
Loading
Loading