feat(testing): Support reusable prepared test artifacts#475
feat(testing): Support reusable prepared test artifacts#475cameroncooke wants to merge 11 commits into
Conversation
commit: |
Keep source-only xcodebuild arguments out of the generated test-without-building phase and sort final test failures independently of emission order.
Share per-test environment setup and cleanup across snapshot suites, cache suite-level simulator builds, and normalize volatile output so fixtures remain stable. Update non-environmental fixtures and add coverage for cleanup and normalization behavior.
Prevent session defaults and selectors from leaking into prepared test runs, remove a useless simulator state initializer, and align volatile snapshot fixtures across CLI and MCP.
Keep only-testing and skip-testing arguments in the prepared test phase while retaining session-default conflict filtering.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ae6d5ba. Configure here.
| platform, | ||
| logPrefix: `${platform} Device Build`, | ||
| logPrefix: `${platform} Device ${resolved.logLabel}`, | ||
| deviceId: params.buildForTesting ? params.deviceId : undefined, |
There was a problem hiding this comment.
Session device blocks generic builds
Medium Severity
Adding deviceId to the build_device schema makes session deviceId merge into every buildForTesting call. A session device then forces a device-specific destination, so the supported generic build-for-testing path becomes unreachable without clearing session defaults, and portable products can be built for the wrong device.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit ae6d5ba. Configure here.
| "name": "Apple Watch Series 11 (46mm)", | ||
| "simulatorId": "<UUID>", | ||
| "state": "<SIM_STATE>", | ||
| "isAvailable": true, |
There was a problem hiding this comment.
watchOS runtime versions are baked into the fixture instead of normalized
These runtime values retain literal watchOS versions (watchOS 27.0, watchOS 26.5, watchOS 26.4) while iOS runtimes are normalized to iOS <VERSION>, so the fixture will break whenever the installed watchOS simulators differ.
Evidence
json-normalize.tsline 94-96 only rewrites theruntimekey whenisIosRuntimeLabel(result)matches/^iOS \d+(?:\.\d+)*$/.- watchOS labels do not match that regex, so
watchOS 27.0/26.5/26.4pass through unnormalized. - The hunk adds watchOS entries with concrete version numbers while iOS entries use the
iOS <VERSION>placeholder, mixing normalized and ad-hoc volatile values in the same fixture. simulatorIdandstateare normalized to<UUID>/<SIM_STATE>, confirming volatile fields are expected to be placeholders.
Identified by Warden xcodebuildmcp-snapshot-fixture-review · YMN-XD9
| "testFailures": [ | ||
| { | ||
| "suite": "CalculatorAppTests", | ||
| "test": "testCalculatorServiceFailure", | ||
| "message": "XCTAssertEqual failed: (\"0\") is not equal to (\"999\") - This test should fail - display should be 0, not 999", | ||
| "location": "<ROOT>/example_projects/spm/Tests/TestLibTests/SimpleTests.swift:49" | ||
| }, | ||
| { | ||
| "suite": "(Unknown Suite)", | ||
| "test": "test", | ||
| "message": "Expectation failed: Bool(false)\nTest failed", | ||
| "location": "SimpleTests.swift:57" | ||
| }, | ||
| { | ||
| "suite": "CalculatorAppTests", | ||
| "test": "testCalculatorServiceFailure", | ||
| "message": "XCTAssertEqual failed: (\"0\") is not equal to (\"999\") - This test should fail - display should be 0, not 999", | ||
| "location": "<TMPDIR>/spm/Tests/TestLibTests/SimpleTests.swift:49" |
There was a problem hiding this comment.
testFailures entries reordered in fixture instead of sorted in normalizer
The two testFailures entries were swapped in this fixture, but testFailures is normalized only by normalizeDiagnosticTestFailures (a plain map) with no stable sort, unlike testCases which is sorted in code — so reordering here patches volatile ordering ad hoc and risks a flaky snapshot.
Evidence
- In
json-normalize.ts,normalizeValuehandleskey === 'testCases'vianormalizeTestCases, which sorts withtestCaseSortKeyfor stability. - The
key === 'testFailures'branch callsnormalizeDiagnosticTestFailures, which onlyitems.map(normalizeDiagnosticTestFailure)and never sorts, so emitted order is preserved verbatim. - The hunk swaps the
CalculatorAppTests/testCalculatorServiceFailureand(Unknown Suite)/testentries, matching the sortedtestCasesorder, indicating the underlying failure order is volatile. - No code-level sort was added for
testFailures, so the reorder is a fixture-only patch.
Also found at 2 additional locations
src/snapshot-tests/__fixtures__/cli/text/macos/test--failure.txt:19-28src/snapshot-tests/__fixtures__/mcp/json/macos/test--failure.json:36-48
Identified by Warden xcodebuildmcp-snapshot-fixture-review · KAK-HHB
| "x": 61, | ||
| "y": 490 |
There was a problem hiding this comment.
Unexplained long-press coordinate change unrelated to prepared-test feature
This UI-automation long-press fixture's x/y coordinates change (201,437 → 61,490) but the PR only adds prepared-test build/test artifacts; confirm this reflects an intentional behavior change and is not a coordinate drift patched to make the snapshot pass.
Evidence
- PR scope is
buildForTesting/testProductsPathbuild+test artifacts, no UI-automation behavior changes described. normalize.tsnormalizes elementRef, UUIDs, hashes, etc., but has no rule for rawx/yaction coordinates, so these values are hardcoded into the fixture rather than normalized in code.- The changed
x/yare the only diff in this file and have no connection to prepared-test artifacts, suggesting an ad-hoc value update.
Also found at 1 additional location
src/snapshot-tests/__fixtures__/cli/json/ui-automation/touch--success.json:14-15
Identified by Warden xcodebuildmcp-snapshot-fixture-review · THM-PL3
| "<REF>|tap|button|Dictate||Dictate", | ||
| "<REF>|tap|button|Dictate||Dictate" | ||
| ], | ||
| "count": 99999, |
There was a problem hiding this comment.
Sibling UI-automation capture fixtures store literal count 28 that cannot match the unconditional count→99999 normalizer
The swipe--success.json capture fixture records the normalized 99999 count placeholder (line 31), but eight other UI-automation capture fixtures (tap, gesture, key-press, long-press, touch, wait-for-ui, key-sequence, snapshot-ui) still record literal count: 28. normalizeNumber in json-normalize.ts forces capture.count to 99999 unconditionally, and snapshot comparison is an exact string match against the raw stored fixture. A stored count: 28 therefore cannot be the output of the current normalizer, so those sibling fixtures are stale/misaligned — they were not regenerated when the count-normalization rule was applied (as swipe was), meaning npm run test:snapshots will not stay aligned for those scenarios.
Evidence
normalizeNumber(json-normalize.ts:109-111) returns99999wheneverkey === 'count' && path.includes('capture'), with no other condition.json-normalize.test.tsconfirms this: capture counts of240and7both normalize to99999.expectMatchesFixtureinfixture-io.tscomparesactual !== expectedagainst the raw fixture file (no re-normalization on read), so the stored value must already equal the normalizer output.swipe--success.json:31stores"count": 99999(matching the rule), but grep under__fixtures__/cli/json/ui-automationshows tap, gesture, key-press, long-press, touch, wait-for-ui, key-sequence, and snapshot-ui all still store"count": 28, which the current normalizer would never emit.
Identified by Warden xcodebuildmcp-snapshot-fixture-review · Q6L-SQB
| "label": "0" | ||
| }, | ||
| { | ||
| "action": "touch", | ||
| "elementRef": "e8", | ||
| "label": "7" | ||
| "label": "0" | ||
| }, |
There was a problem hiding this comment.
UI-automation fixture label change unrelated to prepared-test-artifacts feature
This tap--success-verbose.json label flip from "7" to "0" is unrelated to the PR's prepared-test-artifacts changes and looks like a fixture patched to match new output rather than an intentional, in-scope behavior change.
Evidence
- PR scope is
buildForTesting/testProductsPathbuild and test workflows; nothing touches UI-automation tap output. - The hunk changes accessibility
labelvalues forelementRef"e8" from "7" to "0" on bothlongPressandtouchactions. - Surrounding elements (e10 "C", e11 "±", e12 "%") keep stable labels, so this is a targeted value change, not a structural normalization.
- No corresponding code normalization for these labels is present, so the changed value is baked directly into the fixture.
Identified by Warden xcodebuildmcp-snapshot-fixture-review · WJX-V2J


Why
Agents and CI workflows need to prepare tests once and execute them later without falling back to raw
xcodebuild. The existing build tools always finished with a normal build action, so they could not expose the reusable products produced bybuild-for-testing.What Changed
buildForTestingand optionaltestProductsPathinputs..xctestproductsbundles and discovered.xctestrunfiles as structured build artifacts..xctestproductsbundles or advanced.xctestruninputs without rebuilding, while returning the new.xcresultproduced by the run.Fixes #450