feat(xcresult): resolve test files from declarations, and stop dropping nested suites - #1178
Draft
dfrankland wants to merge 5 commits into
Draft
feat(xcresult): resolve test files from declarations, and stop dropping nested suites#1178dfrankland wants to merge 5 commits into
dfrankland wants to merge 5 commits into
Conversation
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
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) <noreply@anthropic.com>
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 `<testsuite>` — the tests were simply missing from the upload, silently. JUnit has no nested `<testsuite>`, 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 `<testsuite>` 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 `<testsuite>`, 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) <noreply@anthropic.com>
…ndle 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) <noreply@anthropic.com>
dfrankland
force-pushed
the
dylan/xcresult-declaration-test-locations
branch
from
August 29, 2026 19:37
e3a2385 to
4f4dea0
Compare
…gression net
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) <noreply@anthropic.com>
…ler'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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Verified on macOS (Xcode
xcresulttool24514) — see What running it on a Mac found.Five commits. The first two are independent, so the second can be cherry-picked on its own; then two of test coverage, then a fix for a pre-existing bug the coverage exposed.
1.
feat(xcresult): resolve test files from declarations, not failuresAn
.xcresultrecords where a failure was raised, never where a test is declared — there is no per-test declaration site anywhere in the bundle, and a passing test's summary is 638 bytes with no path at all. Everything infile_attribution.rsis therefore a proxy, and a failure raised inside a helper attributes the test to the helper's file, which is where codeowners are resolved from.Behind
--use-experimental-xcresult-test-locations(envTRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS, hidden onupload), ask a language server instead.textDocument/documentSymbolover the checkout names the type containing each method, which is exactly theSuite/casepair an xcresult identifier already gives us.This is not a new class of dependency — it is the same shape as what we ship today (shell out to an Xcode tool, parse structured output), and
sourcekit-lsp/clangdship in the Command Line Tools as well as Xcode, whereasxcresulttoolships only in Xcode.documentSymbolrather thanworkspace/symbolis deliberate: the latter is index-backed and makes the server index the checkout, while this parses one file on demand and needs neither an index nor a build.It also changes which
xcresulttoolcalls we makeget test-results testsget object --legacy(wholeActionsInvocationRecord)get test-results summaryget object --id <summaryRef>per failurefile_attribution.rscascadedocumentSymbolover the checkoutThe per-test summary fetch is unbounded — one timed-out test has been measured producing 6 GB of JSON at a 48 GB peak footprint, because we fetch the whole object to read
failure_summaries.values.first(). The declaration path cannot reach that object at all, so this is structural rather than a bound.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, so such a test falls back to the modern API's ownsourceLocation, vetted against the same vendored-path rules infile_attribution.rs. That is why this is a flag and not a replacement.New files
xcresult/src/lsp.rs— minimal LSP JSON-RPC client. Reader thread +recv_timeout; 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 dead server. Repliesnulltoclient/registerCapability/workspace/configurationinstead of ignoring them.xcresult/src/test_locations.rs— the(suite, case) → file:lineindex: ObjC±prefixes,Class(Category)normalisation, suiteless top-level tests, superclass chaining, and a checkout scan ranked so files named after a suite go first.The file list comes from a checkout scan, not the build log. The build log gives exact target ownership, but reading it costs a legacy
get objectcall — the thing this path exists to avoid. The trade is that two same-named suites in different modules can collide.2.
fix(xcresult): stop dropping nested test suitesA suite nested inside another suite, and every test it declared, was silently discarded. The traversal took only a suite's direct
Test Casechildren, so aTest Suitechild was never visited.The symptom is worse than losing tests. Against the new fixture the pre-fix traversal emits:
The inner suite's two tests are dropped and its failure goes with them, so a run containing a failing test reports a clean bill of health.
JUnit has no nested
<testsuite>, so a nested suite is now flattened into one of its own under a dot-qualified name (Bundle.Outer.Inner) — the convention the bundle prefix already used. The change is additive: an outer suite with no direct cases still emits its empty<testsuite>exactly as before, and the inner ones appear alongside it.This is on the shared traversal, so it applies to the default path, not just the new flag.
3.
test(xcresult): cover a nested suite and passing tests with a real bundleAdds the
nested-and-passingscenario, which closes both fixture gaps at once — see Fixtures.4.
test(xcresult): run every bundle through the declaration path as a regression netEverything the flag was not designed around, checked for perturbation — see Every other bundle.
5.
fix(xcresult): read a copy of the bundle instead of migrating the caller'sA pre-existing bug on the shared path, found because the regression net made two tests read one fixture.
xcresulttoolmigrates a bundle that predatesdatabase.sqlite3in place on first read, so today the uploader either writes into a build artifact it was only asked to read, or fails outright when it cannot:Data+Info.plist)database.sqlite3written inexit 64, no JUnitRead-only artifact mounts are ordinary in CI, so this is a real failure mode, not a theoretical one. It is also why two concurrent readers of one bundle race.
Both constructors now copy into a
TempDirand read that. 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 that takes well under a second on a 64 MB bundle. This is on the shared path, so the default path is fixed too, not just the flag.test_reading_a_bundle_neither_writes_to_it_nor_needs_it_writablepins both halves; it fails without the copy.Testing
cargo test -p xcresult— 117 tests pass on macOS (74 unit, 43 integration), plus the CLI's own suite.The split is deliberate, because the parts that genuinely need macOS are narrower than they look:
src/test_locations.rs— symbol mapping, inheritance walk (including a cycle), category normalisation, superclass regex, scan ranking and caps, LSP wire framing (byte-vs-char, lowercase header, truncation).src/xcresult.rs— suite flattening and the attribution join, against a cannedTestsvalue and a seeded index (TestLocationIndex::declaring, test-only).tests/xcresult.rs— the macOS tests that actually drivesourcekit-lspandclangdover the checked-in packages intests/fixture-src/.What the declaration path is proven to do
Each of these drives a real language server over a real bundle. The last two columns are
what the existing paths report for the same test, so the comparison is against what we
ship rather than against nothing.
..._give_a_crashed_test_its_filefatalErrorin a dependency with zero call-stack frames; and a trait failure raised after the test's own frame is gone..._give_a_passing_test_its_file..._prefer_the_tests_own_file_over_a_vendored_dependencySourcePackages/checkouts/..._resolve_an_objc_test_through_clangdXCTFailin a shared category..._prefer_the_tests_own_file_over_an_in_repo_helper..._find_a_top_level_swift_testing_function@Test funcfailed by a helper..._keep_ids_and_timestamps_identical_to_the_legacy_pathSo the declaration path matches the experimental failure-summary path wherever that path
can name a file at all, beats the legacy path everywhere, and is the only one of the
three that can name a file for a crash with no stack or for a test that passed.
It is not a superset, in two directions
Worth being precise, because the flag's fallback is what is supposed to cover the gap:
class_addMethod,+testInvocations) has no declaration to find. It is meant to fall back to the modern API'ssourceLocation— but that field, while in the schema, is emitted in none of the 14 bundles here, so the fallback never fires and such a test gets no file, where the failure-summary path reads the call stack and would name one. The declaration path never issuesget object, so it cannot see a stack; the failure path never scans the checkout, so it cannot see a declaration. Genuinely disjoint.declarationsis aHashMap<TestKey, DeclarationSite>, so the loser is silently 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.generate_junitsnow logs the split —N from a declaration, N from the fallback, N unresolved— so whether the fallback ever fires on real bundles is answerable rather than assumed.If the collision starts to matter, the fix is already in hand and does not need the call stack:
nodeIdentifierURListest://com.apple.xcode/<scheme>/<target>/<suite>/<case>, so it names the target, and it is already parsed for ids. Ranking candidate files by whether their path matches the target disambiguates modules for passing and failing tests alike. Disambiguating from the failure instead would not: the modern API gives only the failure message'sFile.swift:12:basename, that basename is where the failure was raised (usually a helper, matching neither candidate), and a passing test has no failure message at all — so the signal is absent exactly where the collision is most likely to go unnoticed.The passing-test case asserts each test's status alongside its file, so a fixture that
drifted to all-failing could not keep it green while proving nothing.
Every other bundle, as a regression net
The five scenarios above were built to exercise this path, which says nothing about the
bundles that were not.
test_the_declaration_flag_moves_the_file_and_nothing_elserunsevery bundle in the suite through both paths and asserts they agree on suite, name, id,
status and timestamp —
fileis the one thing allowed to move and the one thing notcompared. That is cheaper and far less brittle than checking in a near-duplicate of every
expected JUnit differing only where the flag is meant to differ.
It is not vacuous: reverting the
startTimerounding below fails all twelve cases, sothe bug that originally showed up on one fixture would now be caught on every one of them.
upload_bundle_using_xcresultin the CLI is parameterised over the flag too, so it iscovered end-to-end through argument parsing and the upload, not only at the crate boundary.
Each case unpacks its own copy of its bundle, which is load-bearing:
xcresulttoolmigrates a bundle in place on first read, so a second concurrent reader of a freshly
unpacked one races to create its
database.sqlite3and fails withSharing the existing fixtures made
test_complex_xcresult_with_valid_pathfail underparallelism until each case got its own copy.
Fixtures
Both gaps that were open are now closed by
nested-and-passing, captured throughregenerate.shlike every other scenario (93MB of unreferenced symbolication data pruned to 0; 30KB checked in). Its inner@Suiteis 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.Its shape is structural rather than a failure, which is the one thing
verify-failure-summaries.pycannot express, so it is checked by a siblingverify-test-structure.pythat asserts the nested suite, the pass/fail split, and the presence of thenodeIdentifierURLids derive from.What running it on a Mac found
Three things were flagged as unverifiable off macOS. All three were checked; two held, and the third turned up a real bug.
nodeIdentifierURLis emitted. Parsed out of the tree of every bundle intests/data/: 546 test cases, 0 missing it. The silent fallback tonodeIdentifier— which would have given every xcresult test case in the product a new identity — is never reached. The new fixture's structural verifier now pins this so it cannot regress unnoticed.startTimeis Unix epoch seconds, not an Apple reference-date offset.test1.xcresultreports1727723571.159→2024-09-30 19:12:51Z, and the legacy record's own string for the same run is"2024-09-30T12:12:51.159-0700".But the conversion was lossy, which is what the equivalence test actually caught:
(start_time.fract() * 1e9) as u32extracts nanoseconds from an f64 whose ULP at epoch magnitude is 238 ns, so those digits were float noise. Both sources carry milliseconds —xcresulttoolprints three decimals and the legacy parser reads%.3f— so this now rounds to the millisecond viafrom_timestamp_millis. Ids were byte-identical throughout; only the timestamps moved.No snapshot diffs. Every existing expected JUnit was left untouched, because no pre-existing fixture had a nested suite.
The five LSP-driving tests passed first run.
sourcekit-lspandclangdboth resolve under the installed Xcode; no debugging was needed.A compile break the test suite could not see
coalesce_junit_path_wrapperswas refactored to take anXCResultOptions, but the unit tests insidecli/src/context.rsstill passed the old eight arguments.cargo test -p xcresultnever compiles them, so this survived — CI would have failed. Fixed in the commit that introduced it, which now builds and passes on its own.Known warts
conflicts_withand an env var set tofalse. The flag is incompatible with--use-experimental-failure-summary(which tunes a code path this one does not run), enforced by clap rather than a runtime warning. clap correctly ignores a default-sourced value for conflicts, but treats an env-supplied value as present regardless of what it says — soTRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS=falsetogether with--use-experimental-failure-summaryis a hard conflict error. Unset the variable to roll back rather than setting it tofalse. Documented inCONTRIBUTING.md.context/src/junit/parser.rsis in this diff and is unrelated.trunk fmtrunscargo fmtworkspace-wide, and that file is not rustfmt-clean onmainunder the pinned toolchain. Reverting it is unstable — anyone runningtrunk fmtre-applies it.trunk check's clippy still cannot run to completion, for an environmental reason rather than a code one:--all-targets --all-featurespulls inrb-sys, which fails withFailed to setup stable API. Confirmed to fail identically onmain.cargo clippy -p xcresult -p trunk-analytics-cli --all-targetsis clean, andtrunk fmtis clean on every changed file.classnameon a nested test names the outer suite (OuterSuite, notOuterSuite.InnerSuite), because it is the first component of the identifier. Pre-existing behaviour on the shared traversal that only becomes visible now that nested tests are emitted at all; it does not feed the id, which is a UUIDv5 overorg#repo#identifierURL. Left alone deliberately.Docs
xcresult/CONTRIBUTING.mdcovers suite flattening as a stated purpose of the crate, the new flag and--repo-root, what the declaration path buys and where it is worse, the clap wart, the id-stability argument, cost and limits, and the fixture coverage above.tests/fixture-src/README.mddocuments the new scenario and which verifier enforces it.🤖 Generated with Claude Code