Hive Hive
Sign in

fix(cli): recover or fail on test targets that enumerate present-but-empty

GitHub issue · Closed

Metadata
Source
tuist/tuist #11558
Updated
Jul 5, 2026
Domains
Testing
Details

Why

A suite-granularity shard plan could silently drop test targets. A test plan with 70 targets produced a shard plan covering only 12 — and the count swung run to run (as low as 2, as high as 69), so most targets’ tests simply never ran.

Root cause

The plan is built from xcodebuild -enumerate-tests, which boots each test bundle to discover its tests. When a runner can’t launch, xcodebuild still lists the target as a target node with no test children, and records the failure in the enumeration’s top-level errors array:

"errors": [ "UITests01-Runner encountered an error. (Underlying Error: Early unexpected exit, operation never finished bootstrapping …)" ],
"values": [{ "kind": "plan", "children": [
{ "kind": "target", "name": "UITests01" }, // present, ZERO children
{ "kind": "target", "name": "AppTests", "children": [ // booted fine
{ "kind": "class", "name": "LoginTests",} ] } ]}]

XCTestEnumerator decoded only values and ignored errors, so a boot-failed target was indistinguishable from a genuinely empty one — ShardPlanService classified it “empty” and excluded it. No throw fired (the target is present, not absent, so the prior absent-only recovery never ran). The run-to-run variance is just which runners fail to launch each time.

Reproduced locally. A 20-target iOS fixture (10 unit + 10 UI tests, one scheme) enumerated 12× back-to-back: all 20 targets present every run, but the number returned present-but-empty swung 0 → 15 of 20 on identical inputs — the same pattern at small scale. Findings:

  • The trigger is the test runner failing to bootstrap under boot contention ("…Early unexpected exit, operation never finished bootstrapping…"). It hits unit-test targets too, not just UI tests.
  • On a real boot failure the errors array is populated (errors count ≈ empty count every run) → the classification below is sound against real failures, not just the deterministic stand-in.
  • Re-enumerating a dropped target in isolation (-only-testing <target>) recovers it cleanly (suites found, 0 errors) — one-at-a-time booting removes the contention.

A secondary gap surfaced in the same investigation: top-level Swift Testing @Test functions (no enclosing @Suite) enumerate as kind == "test" directly under the target, which the parser (class-only) also missed.

Fix

  • XCTestEnumerator returns a new XCTestEnumeration { targets, errors } that surfaces xcodebuild’s errors, and now collects a target’s direct class and test children (top-level @Test functions), using the enumerated names verbatim — both are valid -only-testing identifiers (verified empirically; free functions need the trailing ()).
  • ShardPlanService re-enumerates, in isolation, every expected module the bulk pass produced no suites for — present-but-empty as well as absent. The isolated pass arbitrates:
    • suites found → recovered into the plan;
    • present-but-empty with no errors → genuinely empty → excluded;
    • otherwise (errors, or still missing) → failure → throws modulesFailedToEnumerate (logging the underlying xcodebuild error) instead of silently shipping an incomplete plan.

The throw is the hard guarantee: a flaky run now either recovers a complete plan or fails loudly (CI retries), never silently skips the bulk of the tests.

Tests

Updated the existing suite-granularity tests for the new return type/semantics, plus new coverage:

  • enumerator: captures top-level @Test functions (and still ignores nested class-test methods); surfaces errors.
  • planner: a module reported empty-with-errors is recovered when an isolated pass succeeds; a module empty-with-errors on every pass throws.

All scoped TuistUnitTests (XCTestEnumeratorTests, ShardPlanServiceTests, ShardServiceTests) pass locally.

Alternatives considered

  • Constrain xcodebuild’s parallelism (-parallel-testing-enabled NO): tested on the repro loop — did not help (drops stayed 0→15/20). The contention is at the simulator runner-boot level, not xcodebuild’s worker cloning, so that flag isn’t the lever.
  • The only effective concurrency control is targets-per-invocation — i.e. chunking the bulk call via -only-testing. This PR’s recovery is exactly that at the limit (K=1, proven 100% reliable in the repro), applied reactively to the targets that failed. A proactive small-batch enumeration (cap K per call from the start) is a reasonable alternative that may scale better than bulk-then-recover for very large plans, at the cost of being slower on healthy ones; easy to switch to if preferred. The throw bound keeps either approach safe.
  • Static enumeration (no boot) is the durable fix: reading the test metadata from the built .xctest binaries (Mach-O ObjC metadata for XCTest + Swift Testing’s test-content section; MachOKit is already a dependency) discovers tests with zero runner boots, eliminating the bootstrap flakiness entirely and making this recovery machinery a thin safety net. Out of scope here; worth a follow-up.

🤖 Generated with Claude Code

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments
T
tuist[bot] Jun 29, 2026

🛠️ Tuist Run Report 🛠️

Tests 🧪
Scheme Status Cache hit rate Tests Skipped Ran Commit
TuistAcceptanceTests 82 % 119 0 119 502e89c98
TuistUnitTests 83 % 3048 4 3044 502e89c98
Flaky Tests ⚠️
  • TuistUnitTests: 3 flaky tests (View all)
Test case Module Suite
parseTestStatuses_returnsCorrectStatuses() TuistXCResultServiceTests XCResultServiceTests
parseTestWithCustomLabelXCResult() TuistXCResultServiceTests XCResultServiceTests
parseTestXCResult() TuistXCResultServiceTests XCResultServiceTests
Builds 🔨
Scheme Status Duration Commit
TuistUnitTests 3m 0.3s 502e89c98