Skip to content

Reject stub fixture bundles at the verify gate (fixes #454) - #470

Merged
tylervick merged 2 commits into
mainfrom
tylervick/verify-gate-454
Aug 14, 2026
Merged

tylervick merged 2 commits into
mainfrom
tylervick/verify-gate-454

Conversation

@tylervick

@tylervick tylervick commented Aug 14, 2026 •

Copy link
Copy Markdown
Member

Fixes #454.

The hole

The fixture verify gate — duplicated inline in all four workflows that
generate or restore fixtures — asked only whether each .xcresult had an
Info.plist. During the #453 review a runner's simulator
vanished mid-prepareTestResults.sh (Unable to find a device matching the provided destination specifier), the script's || true swallowed it, and
xcodebuild still left a stub bundle behind: Info.plist, a Data directory,
zero tests. It passed the gate. The suite then failed downstream with a
confusing error instead of failing fast at generation — and the stub was one
step from being saved to the fixture cache under a valid key, where it
would have poisoned every hit until the key rotated. That last part is the
reason this is worth a gate rather than a better error message.

The gate

scripts/verify_fixtures.sh asserts real test data per bundle. Note the shape
of the failure: xcresulttool get test-results summary exits 0 on a stub
and reports "totalTestCount": 0, so the count is the assertion, not the exit
status. The command is the modern surface ModernResultReader already reads
through, so the gate needs no toolchain the tool itself does not.

One script, called from all four workflows, because every one of those copies
stands in front of something that outlives its own run: test.yml,
toolchain-drift.yml and pages.yml all write the shared fixture cache, and
pages-release.yml publishes an immutable per-tag render. pages.yml is the
worst of them — it restores, generates and post-job saves all three bundles
under the key test.yml restores from, on every merge to main, while
checking only TestResults. It checks every bundle before
exiting, so a run names every offender rather than the first, and it takes
bundle paths as arguments so a contributor can run it straight after
prepareTestResults.sh (now documented in CONTRIBUTING).

Evidence

Run locally against three bundles built for the purpose:

### REAL bundle (xcodebuild test, 2 passing tests)
Real.xcresult: 2 tests
EXIT=0

### STUB bundle — the incident reproduced: test-without-building against a
### device id that does not exist, leaving Info.plist + empty Data/
::error::stub fixture: Stub.xcresult contains no tests (totalTestCount=0) — generation did not complete, so this bundle must not be trusted or cached
EXIT=1

### MIXED — real + stub + absent, one run naming every offender
Real.xcresult: 2 tests
::error::stub fixture: Stub.xcresult contains no tests (totalTestCount=0) — …
::error::missing or incomplete fixture: Nope.xcresult
EXIT=1

### CORRUPT — bundle whose summary command fails outright
::error::unreadable fixture: Corrupt.xcresult — xcresulttool could not summarize it
Error: item missing for id 0~RI-jeBjOT2eh…
EXIT=1

The old gate accepts every one of those stubs; each has an Info.plist.

Ordering vs the cache save

  • test.yml needs no reordering. actions/cache saves in its post-job step
    under post-if: "success()" (checked at the pinned SHA), so a failing gate
    before swift test already blocks the poisoned save.
  • toolchain-drift.yml now verifies in its own step. Generation is allowed
    to fail tests, so folding the check into it conflated "generation blew up"
    with "generation produced empty bundles" — separate faults in the issue that
    workflow files. The suite, the render and Save fixtures to cache all hang
    off the verify step now instead of off generation, so a stub neither produces
    confusing downstream failures nor reaches the cache. The drift report gains a
    fixtures contain test data row and a fault line pointing at toolchain-drift/test fixture verify gate accepts stub bundles — assert non-empty test data #454.
  • pages.yml verifies all three bundles, not just the one it renders.
    It post-job saves all three under the key test.yml restores from, so
    checking only TestResults would let a stub Sanity/Retry bundle reach the
    shared cache on any merge to main and fail every PR run until the key
    rotates. Same post-if: success() property as test.yml: failing the gate
    also blocks the save, and blocks publishing an empty demo from a cache hit
    that restored nothing.
  • pages-release.yml verifies just TestResults, the only bundle it
    publishes. No cache here to poison, and failing a release over a stub bundle
    nothing in the job reads would be a false blocker — but the render is pushed
    immutably under /v/<tag>/, so a stub that got through would be a
    permanently hosted empty demo. No version-skew hazard: a tag push runs the
    workflow file as it existed at the tag, and that job's checkout takes the tag
    with no ref: override, so the workflow and scripts/verify_fixtures.sh
    always travel together.

Riders from the #453 review

  • The Expected Failure doc comment now says what the bundle actually
    contains. TestResults.xcresult has two such rows, not one —
    testExpectedFailure and SwiftTestingSuite.knownIssue, both in the
    SampleAppUnitTests target, so prepareTestResults.sh's
    -skip-testing:SampleAppUITests/RetryTests does not exclude the second.
    Measured with xcresulttool get test-results tests (21 rows; {'Passed': 12, 'Failed': 6, 'Expected Failure': 2, 'Skipped': 1}), which is also what
    CoreTests' all - skipped - 2 has encoded all along. The comment's second
    half — "renders it as .unknown (blank icon); the redesign's status-icon
    work needs a real case" — was stale: Redesign the report UI #439 landed and gave the status its own
    Status.expectedFailure and expected-failure glyph.
  • The programmatic PNG pins its renderer to scale 1.
    UIGraphicsImageRenderer(size:) takes points and defaults to the screen
    scale, so on a Retina simulator the attachment was 16×16 or 24×24, not the
    8×8 its comment promises. (Sample app compiles: TEST BUILD SUCCEEDED.)

That second one changes the sample app, so it rotates the fixture cache key —
which means this PR's test.yml run regenerates from scratch and exercises the
new gate end to end against real bundles rather than restored ones.

shellcheck and zizmor --min-severity low are clean; actionlint reports
nothing in the four workflow files touched here (its two findings are
pre-existing, in release.yml and test-artifacts.yml). SHA pins untouched.

Summary by CodeRabbit

  • Bug Fixes

    • Improved validation of test result fixtures to prevent incomplete or empty results from being used in automated checks, reports, and demos.
    • Improved toolchain drift reporting by clearly indicating fixture verification outcomes.
  • Documentation

    • Added contributor guidance for identifying and validating incomplete test result fixtures.
  • Tests

    • Updated sample test fixtures and expected-failure documentation.
    • Ensured generated image attachments maintain consistent 8×8 pixel dimensions.

@tylervick tylervick added this to the 4.0 milestone Aug 14, 2026
@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bd8f363-86b8-41bd-93f3-5b9c77c4856f

📥 Commits

Reviewing files that changed from the base of the PR and between 8cbaa6c and b038c76.

📒 Files selected for processing (5)
  • .github/workflows/pages-release.yml
  • .github/workflows/pages.yml
  • .github/workflows/toolchain-drift.yml
  • XCTestHTMLReportSampleApp/SampleAppUnitTests/SampleAppUnitTests.swift
  • scripts/verify_fixtures.sh
🚧 Files skipped from review as they are similar to previous changes (3)
  • .github/workflows/toolchain-drift.yml
  • scripts/verify_fixtures.sh
  • XCTestHTMLReportSampleApp/SampleAppUnitTests/SampleAppUnitTests.swift

📝 Walkthrough

Walkthrough

The pull request adds shared validation for XCTest result bundles. CI verifies that fixtures are readable and contain non-zero test data before running tests, generating reports, rendering the site, or saving caches. Documentation and sample fixtures are updated.

Changes

Fixture verification

Layer / File(s) Summary
Fixture bundle validation
scripts/verify_fixtures.sh
Validates bundle structure, xcresulttool readability, numeric totalTestCount, and non-zero test data.
Workflow verification gates
.github/workflows/test.yml, .github/workflows/toolchain-drift.yml, .github/workflows/pages.yml, .github/workflows/pages-release.yml
Runs shared fixture verification before downstream steps. Verification results gate tests, reports, site rendering, and cache saving. Drift reports include verification outcomes.
Fixture documentation and generation
CONTRIBUTING.md, XCTestHTMLReportSampleApp/SampleAppUnitTests/SampleAppUnitTests.swift
Documents incomplete fixtures, clarifies expected-failure fixture records, and fixes PNG generation to scale 1.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to b038c

The change rejects empty or unreadable fixture bundles before downstream use and cache save, preventing invalid fixtures from being accepted; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant prepareTestResults.sh
  participant verify_fixtures.sh
  participant xcresulttool
  participant TestSuite
  participant ReportBuilder
  participant FixtureCache
  GitHubActions->>prepareTestResults.sh: Generate fixtures
  GitHubActions->>verify_fixtures.sh: Verify bundles
  verify_fixtures.sh->>xcresulttool: Read test-results summary
  xcresulttool-->>verify_fixtures.sh: Return totalTestCount
  verify_fixtures.sh-->>GitHubActions: Return verification status
  GitHubActions->>TestSuite: Run tests after verification
  GitHubActions->>ReportBuilder: Generate reports after verification
  GitHubActions->>FixtureCache: Save verified fixtures
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy #454 by validating non-zero test counts and gating tests, rendering, and fixture-cache saves on successful verification.
Out of Scope Changes check ✅ Passed All changes support fixture validation and its workflow integration; the documentation and PNG updates are explicitly permitted by #454.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: rejecting stub fixture bundles during verification, and it references the related issue.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tylervick/verify-gate-454

Comment @coderabbitai help to get the list of available commands.

@tylervick

Copy link
Copy Markdown
Member Author

CI-side evidence, test (auto) → Verify fixtures (18m1s job, gate itself ~3s), against fixtures regenerated from scratch on this run:

TestResults.xcresult: 21 tests
SanityResults.xcresult: 1 tests
RetryResults.xcresult: 4 tests

Same on test (modern). All checks green. The toolchain-drift.yml half cannot be exercised from a branch — schedule/dispatch only run from the default branch — so it is reviewed by reading; its step wiring is the same script behind a continue-on-error step that the cache save, the suite and the render now all hang off.

tylervick and others added 2 commits August 13, 2026 23:45
The gate shared by `test.yml` and `toolchain-drift.yml` asked only whether each
`.xcresult` had an `Info.plist`. When a runner's simulator vanished mid-run
("Unable to find a device matching the provided destination specifier"), the
`|| true` in `prepareTestResults.sh` swallowed the failure and `xcodebuild`
still left a stub bundle behind — `Info.plist`, a `Data` directory, no tests.
That stub passed the gate, failed the suite later with a confusing error
instead of failing fast at generation, and came one step from being SAVED to
the fixture cache under a valid key, where it would have poisoned every
restore until the key rotated. The cache is the dangerous half.

`scripts/verify_fixtures.sh` now asserts real test data per bundle.
`xcresulttool get test-results summary` exits 0 on a stub and reports
`"totalTestCount": 0`, so the count is the assertion, not the exit status; the
command is the modern surface `ModernResultReader` already reads through, so
the gate needs no toolchain the tool does not. Both workflows call the one
script — the two inline copies could drift apart, and one of them is the gate
in front of the cache — and every bundle is checked before it exits, so a run
names every offender rather than the first.

Verified against a real bundle (accepted: `Real.xcresult: 2 tests`), against a
stub reproduced the way the incident produced one, by pointing `xcodebuild
test-without-building` at a device id that does not exist (rejected, exit 1,
naming the bundle), and against a corrupted bundle whose summary command fails
outright (rejected, with xcresulttool's own error echoed).

In `toolchain-drift.yml` verification becomes its own step. Generation is
allowed to fail tests, so folding the check into it conflated "generation blew
up" with "generation produced empty bundles" — distinct faults in the issue
that workflow files. The suite, the render and the cache save now hang off the
verify step instead of off generation. `test.yml` needs no reordering:
`actions/cache` saves post-job under `post-if: success()`, so a failing gate
already blocks the poisoned save.

Also from the #453 review: the `Expected Failure` doc comment is scoped to
`TestResults.xcresult`, since `RetryTests.testInUnknownState` records one too
and lands in `RetryResults.xcresult`; and the programmatic PNG pins its
renderer to scale 1, because `UIGraphicsImageRenderer(size:)` takes points and
would emit a 16×16 or 24×24 attachment on a Retina simulator instead of the
8×8 its comment promises.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The verify gate this PR added covered test.yml and toolchain-drift.yml, but
main had four inline copies, not two. The two left behind were the ones that
mattered most.

pages.yml was the hole: it restores, generates and post-job saves all three
bundles under the same key test.yml restores from, on every merge to main,
while its inline gate checked only TestResults for an Info.plist. A stub from
an incident like #454 on a merge run passed that gate, published an empty demo,
and then poisoned the shared cache for every PR run until the key rotated. It
now runs the shared script over all three bundles — not just the one it
renders, because it writes all three. Failing there also blocks the save, since
the cache action's post step runs under `post-if: success()`.

pages-release.yml has no cache to poison, but it pushes an immutable per-tag
render, so a stub becomes a permanently hosted empty demo under /v/<tag>/. It
verifies just TestResults, the only bundle it publishes: failing a release over
a stub Sanity or Retry bundle nothing in the job reads would be a false
blocker. No version-skew risk — a tag push runs the workflow file as it existed
at the tag and that job checks out the tag with no ref: override, so the
workflow and the script always travel together.

Zero inline copies remain, so the script's own header no longer undercounts its
callers.

Also corrects the Expected-Failure doc comment, which the rider that rewrote it
left wrong in both halves. TestResults.xcresult carries two Expected Failure
rows, not one: testExpectedFailure and SwiftTestingSuite.knownIssue, both in the
SampleAppUnitTests target, so prepareTestResults.sh's
-skip-testing:SampleAppUITests/RetryTests does not exclude the second. Measured
with `xcresulttool get test-results tests` — 21 rows, {'Passed': 12, 'Failed':
6, 'Expected Failure': 2, 'Skipped': 1} — which is what CoreTests' `all -
skipped - 2` already encoded. The comment's second half was stale besides: #439
landed and gave the status its own Status.expectedFailure and glyph, so it no
longer renders as .unknown.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@tylervick
tylervick force-pushed the tylervick/verify-gate-454 branch from 8cbaa6c to b038c76 Compare August 14, 2026 06:46
@tylervick

Copy link
Copy Markdown
Member Author

Review follow-up pushed as b038c76, rebased onto d8764fe (#471). 10/10 checks green.

F1/F2 — the two un-migrated gates. main had four inline copies, not two, and the
two left behind were the ones that mattered. pages.yml restores, generates and post-job
saves all three bundles under the key test.yml restores from, on every merge, while
checking only TestResults for an Info.plist — so a #454-shaped stub on a merge run
published an empty demo and poisoned the shared cache. It now runs the shared script over
all three bundles, since it writes all three. pages-release.yml verifies just
TestResults, the only bundle it publishes: no cache to poison there, and failing a release
over a stub Sanity/Retry bundle nothing in the job reads would be a false blocker.

Zero inline copies remain (grep -rn "Info.plist" .github/workflows/ → no matches).

No version-skew hazard on the release path: a tag push runs the workflow file as it existed
at the tag, and publish-version's checkout takes the tag with no ref: override, so the
workflow and scripts/verify_fixtures.sh always travel together.

Neither changed workflow runs on a PR (pages.yml is push-to-main, pages-release.yml is
tag-push), so their CI coverage here is the actions job (actionlint + zizmor, green). Both
exact commands were run locally instead — passing on real fixtures, exit 1 on a stub and on
an absent bundle.

F3/F4 — the Expected-Failure comment. TestResults.xcresult carries two such rows,
not one: testExpectedFailure and SwiftTestingSuite.knownIssue, both in the
SampleAppUnitTests target, so -skip-testing:SampleAppUITests/RetryTests does not exclude
the second. Measured, not inferred:

21 test-case rows
histogram: {'Passed': 12, 'Failed': 6, 'Expected Failure': 2, 'Skipped': 1}
  SampleAppUnitTests / testExpectedFailure()  -> Expected Failure
  SwiftTestingSuite  / knownIssue()           -> Expected Failure

which is what CoreTests' all - skipped - 2 already encoded. The second sentence was
stale besides — #439 landed, so the status has its own Status.expectedFailure and
expected-failure glyph rather than rendering as .unknown.

Gate output from this run. The sample-app edit rotates the fixture cache key, so both
legs missed cache (Cache not found for input keys: fixtures-xcode26.6-…) and regenerated
from scratch — the gate ran against real bundles, not restored ones:

TestResults.xcresult: 21 tests
SanityResults.xcresult: 1 tests
RetryResults.xcresult: 4 tests

Rebase. Clean onto d8764fe; the merged toolchain-drift.yml has #471's mkdir -p
removal intact (only its "restoring the mkdir would hide a regression" comment remains) and
the verify step plus all three dependents — suite, tool, cache save — still hanging off it.

shellcheck / actionlint / zizmor --min-severity low clean on all four workflows; zero
uses: lines touched.

@tylervick
tylervick merged commit 49bb7d8 into main Aug 14, 2026
10 checks passed
@tylervick
tylervick deleted the tylervick/verify-gate-454 branch August 14, 2026 07:05
tylervick added a commit that referenced this pull request Aug 14, 2026
…ation faults, drift artifacts (refs #478)

The Xcode 27 beta leg reported eight differential failures and a red suite. None
of it was a reader regression. The sample app is pre-scene UIKit, the iOS 27 SDK
promotes a missing scene manifest from a runtime warning to a launch trap, and
the app died on launch -- taking `SampleAppUnitTests` with it, since the unit
tests are hosted in it. Twelve of the fixture's twenty-one rows never existed,
and every downstream failure was arithmetic on that hole.

Four changes, because they are one story: the beta leg has to measure this
project rather than a bundle that was never produced, and nothing in the chain
should have let a 10-of-21 bundle through in silence.

**The sample app adopts UIScene.** A `SceneDelegate`, a scene manifest naming
`Main` through `UISceneStoryboardFile`, and `UIMainStoryboardFile` retired since
it is superseded. The five app-level lifecycle stubs go with it: scenes deliver
those transitions to the scene delegate, so keeping them would leave five
methods nothing calls, and they were empty template comments. Fixtures
regenerated on stable and the suite run on both legs: 21/1/4 test counts,
byte-for-byte the same shape as before the change, 150 tests green on `auto` and
on `modern`, `DifferentialTests` included.

**The verify gate learns each bundle's shape.** `totalTestCount > 0` passed a
bundle missing 57% of the suite -- ten is a healthy count for a bundle whose
expected count is ten, so no global floor can catch it. The floors are now per
bundle (21/1/4, with the sources for each documented at the table), a rejection
names the bundle and prints observed-versus-expected, and a bundle nobody
declared keeps the old `> 0` rule. `pages.yml` and `pages-release.yml` inherit
this for free; both already call the shared script.

**Two new faults, on both backends by construction.** `xchtmlreport` ran against
the truncated beta bundle, printed "Report successfully created", and exited 0.
Now `emptyPlannedTestable` fires on a test target present with no test rows
beneath it, and `systemFailure` on the `System Failures` group. Both are read
off `ParsedResult` -- the model both readers produce -- so there is one
implementation and it cannot drift between backends.

The shapes were measured, not guessed. Reproducing the crash on Xcode 26.2 by
trapping in the host app's `didFinishLaunchingWithOptions` yields a bundle both
readers agree on: a group named and identified `System Failures` sitting
directly under the testable. They stop agreeing one level down -- modern carries
the crash row, XCResultKit drops it (an `ActionTestSummary` is not one of the
`ActionTestMetadata` entries `subtests` decodes) -- which is exactly why the
bucket is the marker and its rows are not. Against that bundle the tool now
exits 3 on both readers, where it exited 0 on both before.

The cardinal rule holds: a bundle carrying no testable for a target is
structural absence, which is what `-only-testing` produces and what
`SanityResults` and `RetryResults` are, and is never a fault. Verified directly
-- every fixture, through every backend the toolchain runs, reports neither
fault.

**The drift workflow uploads its bundles.** This triage was done from job logs
because `toolchain-drift.yml` uploaded nothing and `gh run download` found no
artifacts, and the one open question -- whether XCTest stopped recording
`file:line` in failure titles on 27 or `xcresulttool` stopped emitting it --
cannot be answered without a bundle. Bundles now upload on any failing signal,
named per leg per the #470 convention, at five days' retention since they are
~50 MB and this runs twice a week. The filed issue points at them, which matters
as much as the upload: nobody knew there was nothing to download.

Not touched: the `.auto` preference, since legacy was not at fault and flipping
it would have produced the identical 10-row report.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
tylervick added a commit that referenced this pull request Aug 14, 2026
…ncation fault and its canary, drift artifacts (refs #478) (#479)

* Make beta runs measure reality: UIScene, a partial-bundle gate, truncation faults, drift artifacts (refs #478)

The Xcode 27 beta leg reported eight differential failures and a red suite. None
of it was a reader regression. The sample app is pre-scene UIKit, the iOS 27 SDK
promotes a missing scene manifest from a runtime warning to a launch trap, and
the app died on launch -- taking `SampleAppUnitTests` with it, since the unit
tests are hosted in it. Twelve of the fixture's twenty-one rows never existed,
and every downstream failure was arithmetic on that hole.

Four changes, because they are one story: the beta leg has to measure this
project rather than a bundle that was never produced, and nothing in the chain
should have let a 10-of-21 bundle through in silence.

**The sample app adopts UIScene.** A `SceneDelegate`, a scene manifest naming
`Main` through `UISceneStoryboardFile`, and `UIMainStoryboardFile` retired since
it is superseded. The five app-level lifecycle stubs go with it: scenes deliver
those transitions to the scene delegate, so keeping them would leave five
methods nothing calls, and they were empty template comments. Fixtures
regenerated on stable and the suite run on both legs: 21/1/4 test counts,
byte-for-byte the same shape as before the change, 150 tests green on `auto` and
on `modern`, `DifferentialTests` included.

**The verify gate learns each bundle's shape.** `totalTestCount > 0` passed a
bundle missing 57% of the suite -- ten is a healthy count for a bundle whose
expected count is ten, so no global floor can catch it. The floors are now per
bundle (21/1/4, with the sources for each documented at the table), a rejection
names the bundle and prints observed-versus-expected, and a bundle nobody
declared keeps the old `> 0` rule. `pages.yml` and `pages-release.yml` inherit
this for free; both already call the shared script.

**Two new faults, on both backends by construction.** `xchtmlreport` ran against
the truncated beta bundle, printed "Report successfully created", and exited 0.
Now `emptyPlannedTestable` fires on a test target present with no test rows
beneath it, and `systemFailure` on the `System Failures` group. Both are read
off `ParsedResult` -- the model both readers produce -- so there is one
implementation and it cannot drift between backends.

The shapes were measured, not guessed. Reproducing the crash on Xcode 26.2 by
trapping in the host app's `didFinishLaunchingWithOptions` yields a bundle both
readers agree on: a group named and identified `System Failures` sitting
directly under the testable. They stop agreeing one level down -- modern carries
the crash row, XCResultKit drops it (an `ActionTestSummary` is not one of the
`ActionTestMetadata` entries `subtests` decodes) -- which is exactly why the
bucket is the marker and its rows are not. Against that bundle the tool now
exits 3 on both readers, where it exited 0 on both before.

The cardinal rule holds: a bundle carrying no testable for a target is
structural absence, which is what `-only-testing` produces and what
`SanityResults` and `RetryResults` are, and is never a fault. Verified directly
-- every fixture, through every backend the toolchain runs, reports neither
fault.

**The drift workflow uploads its bundles.** This triage was done from job logs
because `toolchain-drift.yml` uploaded nothing and `gh run download` found no
artifacts, and the one open question -- whether XCTest stopped recording
`file:line` in failure titles on 27 or `xcresulttool` stopped emitting it --
cannot be answered without a bundle. Bundles now upload on any failing signal,
named per leg per the #470 convention, at five days' retention since they are
~50 MB and this runs twice a week. The filed issue points at them, which matters
as much as the upload: nobody knew there was nothing to download.

Not touched: the `.auto` preference, since legacy was not at fault and flipping
it would have produced the identical 10-row report.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Withdraw emptyPlannedTestable and give systemFailure a canary (refs #478)

Review of #479 produced a counterexample I reproduced here before touching
anything: on an unmodified sample app,

    xcodebuild test-without-building ... \
      -skip-testing:SampleAppUITests/RetryTests \
      -skip-testing:SampleAppUnitTests/SampleAppUnitTests \
      -skip-testing:SampleAppUnitTests/SwiftTestingSuite

produces a bundle where every test that was planned to run ran -- and at
a59640e `xchtmlreport` exits 3 on it under the legacy reader and 0 under
modern. Legacy keeps a zero-row `Selected tests` group for the still-selected
unit target; the modern node tree drops the bundle node entirely. One
implementation, two inputs, two answers.

**`emptyPlannedTestable` is withdrawn.** Being read off the shared model made
it one implementation, which is not the same as one behaviour: the structure
underneath differs between backends, so the rule was observable on legacy only
-- the one-backend field the model doctrine exists to catch -- and on the
default reader it faulted ordinary practice. Quarantining a flaky suite and
sharding a target across CI jobs both produce that shape, and `--lenient` is
all-or-nothing, so a user had no way to suppress just this one. Its residual
value was near zero: the #478 partial bundle is caught by the per-bundle floors
in `verify_fixtures.sh`, and the launch trap by `systemFailure`, which fires on
both readers. Gone with it: the kind, its `validate()` arm, the now-unused
`ParsedTestable.allTestCases`, its tests, and its release-note claim.

`testSelectedTargetWithNoTestRowsIsNotAFault` pins the counterexample so the
rule cannot come back by accident.

**`systemFailure` gets the canary the review asked for.** It keys on a display
name, and the review confirmed there is no structural alternative on either
surface: modern's node is `{"name": "System Failures", "nodeType": "Test
Suite"}`, legacy's an ordinary `ActionTestSummaryGroup`. So it fails open -- an
Xcode that renames the bucket disarms the fault, healthy fixtures keep passing,
and nothing reddens. A recursive search of `Xcode.app/Contents` finds the
literal in no binary and no `.strings` table (the crash message `Early
unexpected exit` is in `XCTHarness`, so the search does work), which says the
string is composed at run time -- weak evidence against localization, none at
all against a rename.

So the name is re-measured rather than trusted. `prepareTestResults.sh` now
generates a fourth fixture, `CrashResults.xcresult`, by setting
`TEST_RUNNER_XCHR_TRAP_AT_LAUNCH` -- xcodebuild's own channel to the launched
process, which for a hosted unit test is the host app -- and the sample app's
`AppDelegate` traps in `didFinishLaunchingWithOptions`. `SampleAppUnitTests` is
hosted in it and dies with it: the #478 failure reproduced, not simulated.
`SystemFailureCanaryTests` then asserts both readers still find the bucket by
name in that bundle and that the tool faults on both, and names every group it
did find when it fails, so the failure says what Apple renamed it to. Verified
by flipping the constant: three tests, four assertions, red on both readers.

The canary reaches a new toolchain before a release does, because
`toolchain-drift` regenerates fixtures and runs the suite on the beta leg.

**Also from the review.** The release note no longer says the two readers
"behave identically"; the rule is implemented once, and the two still word the
fault differently because each names the most specific node its own document
holds. The `AppDelegate` scene-configuration comment claimed a rename of
`UISceneConfigurationName` "stops compiling" -- the opposite of what happens:
the name is matched at run time, an unmatched configuration has no delegate
class and no storyboard, and the app comes up as a blank window. The drift
upload comment now says it reads the `failure` subset of the six signals
`Report drift` reads, not the same set. The floor table's comment now admits it
keys on basename alone.

Re-verified on Xcode 26.2 / iPhone 17 Pro Max (iOS 26.2), fixtures regenerated
from scratch by the updated script:

- 3 healthy fixtures x 3 readers x 2 rendering modes -> exit 0, zero faults,
  18/18.
- `CrashResults.xcresult` -> exit 3 on both readers (legacy names the bucket,
  modern the crash row).
- The `-skip-testing` counterexample -> exit 0 on both readers, where legacy
  gave exit 3 before.
- `swift test` on `auto` and on `modern`: 151 tests, 3 skipped, 0 failures.
- `verify_fixtures.sh` green on all four bundles; shellcheck, actionlint,
  zizmor `--min-severity low`, swiftformat `--lint` (0/88) and swiftlint
  (0 serious, no new warnings) all clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

toolchain-drift/test fixture verify gate accepts stub bundles — assert non-empty test data

1 participant