From b8607ae203fb863182bac9145b5e90c30ddf1fb8 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 7 May 2026 13:28:08 +0100 Subject: [PATCH] feat(testing): Add default xcresult bundles for test tools Create workspace-scoped result bundle paths for simulator, device, and macOS test commands when callers do not provide one. This makes test artifacts consistently available in text and structured output while preserving explicit result bundle paths. Extend workspace filesystem cleanup so managed result bundles are pruned safely without touching user-created bundles. --- CHANGELOG.md | 1 + .../device/__tests__/test_device.test.ts | 2 + .../cli/device/test--error-compiler.txt | 1 + .../__fixtures__/cli/device/test--failure.txt | 1 + .../__fixtures__/cli/device/test--success.txt | 3 +- .../cli/macos/test--error-compiler.txt | 1 + .../cli/macos/test--error-wrong-scheme.txt | 1 + .../__fixtures__/cli/macos/test--failure.txt | 1 + .../__fixtures__/cli/macos/test--success.txt | 3 +- .../cli/simulator/test--failure.txt | 1 + .../cli/simulator/test--success.txt | 3 +- .../cli/swift-package/test--success.txt | 2 +- .../json/device/test--error-compiler.json | 8 +- .../json/device/test--failure.json | 3 +- .../json/device/test--success.json | 3 +- .../json/macos/test--error-compiler.json | 8 +- .../json/macos/test--error-wrong-scheme.json | 8 +- .../json/macos/test--failure.json | 3 +- .../json/macos/test--success.json | 3 +- .../json/simulator/test--failure.json | 3 +- .../json/simulator/test--success.json | 3 +- .../mcp/device/test--error-compiler.txt | 1 + .../__fixtures__/mcp/device/test--failure.txt | 1 + .../__fixtures__/mcp/device/test--success.txt | 3 +- .../mcp/macos/test--error-compiler.txt | 1 + .../mcp/macos/test--error-wrong-scheme.txt | 1 + .../__fixtures__/mcp/macos/test--failure.txt | 1 + .../__fixtures__/mcp/macos/test--success.txt | 3 +- .../mcp/simulator/test--failure.txt | 1 + .../mcp/simulator/test--success.txt | 3 +- .../mcp/swift-package/test--success.txt | 2 +- src/snapshot-tests/normalize.ts | 4 +- src/utils/__tests__/log-paths.test.ts | 1 + .../simulator-test-execution.test.ts | 2 +- .../__tests__/snapshot-normalize.test.ts | 13 + src/utils/__tests__/test-common.test.ts | 264 +++++++++++++++++- .../workspace-filesystem-lifecycle.test.ts | 103 +++++++ src/utils/log-paths.ts | 3 + src/utils/result-bundle-path.ts | 52 ++++ src/utils/simulator-test-execution.ts | 7 +- src/utils/test-common.ts | 33 ++- src/utils/workspace-filesystem-lifecycle.ts | 120 +++++++- src/utils/xcodebuild-domain-results.ts | 7 +- 43 files changed, 646 insertions(+), 42 deletions(-) create mode 100644 src/utils/result-bundle-path.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 52cb0ab7b..6aafbdcfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added workspace-scoped default xcresult bundles for simulator, device, and macOS test tools so test artifacts are available in structured and text output even when callers do not pass `-resultBundlePath`. - Added opt-in MCP server idle shutdown via `XCODEBUILDMCP_MCP_IDLE_TIMEOUT_MS`, allowing unused MCP server processes to gracefully exit after a configured idle period ([#394](https://github.com/getsentry/XcodeBuildMCP/issues/394)). ### Fixed diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts index 82bc702e3..c41a0126e 100644 --- a/src/mcp/tools/device/__tests__/test_device.test.ts +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -167,6 +167,8 @@ describe('test_device plugin', () => { 'never', '-derivedDataPath', computeScopedDerivedDataPath('/path/to/project.xcodeproj'), + '-resultBundlePath', + expect.stringContaining('/result-bundles/test_device_'), 'test', ]); }); diff --git a/src/snapshot-tests/__fixtures__/cli/device/test--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/device/test--error-compiler.txt index 970c7994b..224314860 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/test--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/test--error-compiler.txt @@ -19,4 +19,5 @@ Compiler Errors (1): example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33:42 ❌ Test failed. (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_device__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/device/test--failure.txt b/src/snapshot-tests/__fixtures__/cli/device/test--failure.txt index 1d9ffb45d..25f199327 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/test--failure.txt @@ -51,4 +51,5 @@ IntentionalFailureTests example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286 ❌ tests failed, passed, skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_device__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/device/test--success.txt b/src/snapshot-tests/__fixtures__/cli/device/test--success.txt index 982b0cfae..20d8372c0 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/test--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/test--success.txt @@ -14,5 +14,6 @@ Discovered 1 test(s): CalculatorAppTests/CalculatorAppTests/testAddition Running tests (1 completed, 0 failures, 0 skipped) -✅ 1 test passed, 0 skipped (⏱️ ) +✅ 1 test passed, 0 failed, 0 skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_device__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/test--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/macos/test--error-compiler.txt index 5c83e7d6d..7821e1375 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/test--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/test--error-compiler.txt @@ -20,4 +20,5 @@ Compiler Errors (1): example_projects/macOS/MCPTest/MCPTestApp.swift:20:42 ❌ Test failed. (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/test--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/cli/macos/test--error-wrong-scheme.txt index 103e729be..bf22f9ece 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/test--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/test--error-wrong-scheme.txt @@ -12,4 +12,5 @@ Errors (1): ✗ The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. ❌ Test failed. (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/test--failure.txt b/src/snapshot-tests/__fixtures__/cli/macos/test--failure.txt index 28ba26e31..301f586d1 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/test--failure.txt @@ -28,4 +28,5 @@ MCPTestTests example_projects/macOS/MCPTestTests/MCPTestTests.swift:11 ❌ tests failed, passed, skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/test--success.txt b/src/snapshot-tests/__fixtures__/cli/macos/test--success.txt index b30dae773..0b1f6dede 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/test--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/test--success.txt @@ -16,5 +16,6 @@ Discovered 2 test(s): Running tests (1 completed, 0 failures, 0 skipped) Running tests (2 completed, 0 failures, 0 skipped) -✅ 2 tests passed, 0 skipped (⏱️ ) +✅ 2 tests passed, 0 failed, 0 skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/test--failure.txt b/src/snapshot-tests/__fixtures__/cli/simulator/test--failure.txt index 7a0afe079..961239d2c 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/test--failure.txt @@ -36,4 +36,5 @@ IntentionalFailureTests example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286 ❌ tests failed, passed, skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_sim__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/test--success.txt b/src/snapshot-tests/__fixtures__/cli/simulator/test--success.txt index 1538ea015..0e0a625b6 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/test--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/test--success.txt @@ -14,5 +14,6 @@ Discovered 1 test(s): CalculatorAppTests/CalculatorAppTests/testAddition Running tests (1 completed, 0 failures, 0 skipped) -✅ 1 test passed, 0 skipped (⏱️ ) +✅ 1 test passed, 0 failed, 0 skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_sim__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/swift-package/test--success.txt b/src/snapshot-tests/__fixtures__/cli/swift-package/test--success.txt index ef7acec15..5cefd1817 100644 --- a/src/snapshot-tests/__fixtures__/cli/swift-package/test--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/swift-package/test--success.txt @@ -8,5 +8,5 @@ Running tests (0 completed, 0 failures, 0 skipped) Running tests (1 completed, 0 failures, 0 skipped) -✅ 1 test passed, 0 skipped (⏱️ ) +✅ 1 test passed, 0 failed, 0 skipped (⏱️ ) └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log diff --git a/src/snapshot-tests/__fixtures__/json/device/test--error-compiler.json b/src/snapshot-tests/__fixtures__/json/device/test--error-compiler.json index 2f9766e9e..be0cc4c0c 100644 --- a/src/snapshot-tests/__fixtures__/json/device/test--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/device/test--error-compiler.json @@ -20,11 +20,17 @@ "summary": { "status": "FAILED", "durationMs": 1234, + "counts": { + "passed": 0, + "failed": 0, + "skipped": 0 + }, "target": "device" }, "artifacts": { "deviceId": "", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log", + "xcresultPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_device__pid.xcresult" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/json/device/test--failure.json b/src/snapshot-tests/__fixtures__/json/device/test--failure.json index 6a549c92d..d694e3fb1 100644 --- a/src/snapshot-tests/__fixtures__/json/device/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/device/test--failure.json @@ -27,7 +27,8 @@ }, "artifacts": { "deviceId": "", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log", + "xcresultPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_device__pid.xcresult" }, "tests": { "discovered": { diff --git a/src/snapshot-tests/__fixtures__/json/device/test--success.json b/src/snapshot-tests/__fixtures__/json/device/test--success.json index e98ad4f70..5dc894811 100644 --- a/src/snapshot-tests/__fixtures__/json/device/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/device/test--success.json @@ -29,7 +29,8 @@ }, "artifacts": { "deviceId": "", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log", + "xcresultPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_device__pid.xcresult" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/json/macos/test--error-compiler.json b/src/snapshot-tests/__fixtures__/json/macos/test--error-compiler.json index 4f5d345fb..a6521794b 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/test--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/macos/test--error-compiler.json @@ -19,10 +19,16 @@ "summary": { "status": "FAILED", "durationMs": 1234, + "counts": { + "passed": 0, + "failed": 0, + "skipped": 0 + }, "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log", + "xcresultPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/json/macos/test--error-wrong-scheme.json b/src/snapshot-tests/__fixtures__/json/macos/test--error-wrong-scheme.json index 97bc2596c..494cc28b0 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/test--error-wrong-scheme.json +++ b/src/snapshot-tests/__fixtures__/json/macos/test--error-wrong-scheme.json @@ -16,10 +16,16 @@ "summary": { "status": "FAILED", "durationMs": 1234, + "counts": { + "passed": 0, + "failed": 0, + "skipped": 0 + }, "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log", + "xcresultPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/macos/test--failure.json b/src/snapshot-tests/__fixtures__/json/macos/test--failure.json index 03e9540e5..ffdbc19e6 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/macos/test--failure.json @@ -24,7 +24,8 @@ "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log", + "xcresultPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult" }, "tests": { "discovered": { diff --git a/src/snapshot-tests/__fixtures__/json/macos/test--success.json b/src/snapshot-tests/__fixtures__/json/macos/test--success.json index 470a91df3..01f82f0fa 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/macos/test--success.json @@ -27,7 +27,8 @@ "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log", + "xcresultPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json b/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json index c088f083c..3eb1aa905 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json @@ -25,7 +25,8 @@ "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log", + "xcresultPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_sim__pid.xcresult" }, "tests": { "discovered": { diff --git a/src/snapshot-tests/__fixtures__/json/simulator/test--success.json b/src/snapshot-tests/__fixtures__/json/simulator/test--success.json index 08db99782..ada1fae84 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/test--success.json @@ -27,7 +27,8 @@ "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log", + "xcresultPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_sim__pid.xcresult" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/mcp/device/test--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/device/test--error-compiler.txt index cd0a8c7c2..d00ef7687 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/test--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/test--error-compiler.txt @@ -19,4 +19,5 @@ Errors (1): /example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33 ❌ Test failed. (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_device__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/device/test--failure.txt b/src/snapshot-tests/__fixtures__/mcp/device/test--failure.txt index 983acb4c2..41805834f 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/test--failure.txt @@ -26,4 +26,5 @@ Test Failures (2): /example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286 ❌ tests failed, passed, skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_device__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/device/test--success.txt b/src/snapshot-tests/__fixtures__/mcp/device/test--success.txt index eefb3f2dc..0165555ff 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/test--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/test--success.txt @@ -13,5 +13,6 @@ Discovered 1 test(s): CalculatorAppTests/CalculatorAppTests/testAddition -✅ 1 test passed, 0 skipped (⏱️ ) +✅ 1 test passed, 0 failed, 0 skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_device__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/test--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/macos/test--error-compiler.txt index 35e6c2635..8a26848ee 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/test--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/test--error-compiler.txt @@ -20,4 +20,5 @@ Errors (1): /example_projects/macOS/MCPTest/MCPTestApp.swift:20 ❌ Test failed. (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/test--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/mcp/macos/test--error-wrong-scheme.txt index 103e729be..bf22f9ece 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/test--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/test--error-wrong-scheme.txt @@ -12,4 +12,5 @@ Errors (1): ✗ The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. ❌ Test failed. (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/test--failure.txt b/src/snapshot-tests/__fixtures__/mcp/macos/test--failure.txt index b3b83e14f..8222e1703 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/test--failure.txt @@ -22,4 +22,5 @@ Test Failures (2): MCPTestTests.swift:11 ❌ tests failed, passed, skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/test--success.txt b/src/snapshot-tests/__fixtures__/mcp/macos/test--success.txt index e519c8a1f..a17c9d713 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/test--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/test--success.txt @@ -14,5 +14,6 @@ Discovered 2 test(s): MCPTestTests/MCPTestTests/appNameIsCorrect MCPTestTests/MCPTestsXCTests/testAppNameIsCorrect -✅ 2 tests passed, 0 skipped (⏱️ ) +✅ 2 tests passed, 0 failed, 0 skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_macos__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt index 38bd56887..f74680d25 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt @@ -31,4 +31,5 @@ Test Failures (3): /example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286 ❌ tests failed, passed, skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_sim__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/test--success.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/test--success.txt index a1ca41f92..8db8f9c8c 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/test--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/test--success.txt @@ -13,5 +13,6 @@ Discovered 1 test(s): CalculatorAppTests/CalculatorAppTests/testAddition -✅ 1 test passed, 0 skipped (⏱️ ) +✅ 1 test passed, 0 failed, 0 skipped (⏱️ ) + ├ Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/result-bundles/test_sim__pid.xcresult └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/swift-package/test--success.txt b/src/snapshot-tests/__fixtures__/mcp/swift-package/test--success.txt index 0ad98e1a4..8db02e9bf 100644 --- a/src/snapshot-tests/__fixtures__/mcp/swift-package/test--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/swift-package/test--success.txt @@ -6,5 +6,5 @@ Platform: Swift Package Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData -✅ 1 test passed, 0 skipped (⏱️ ) +✅ 1 test passed, 0 failed, 0 skipped (⏱️ ) └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log diff --git a/src/snapshot-tests/normalize.ts b/src/snapshot-tests/normalize.ts index aae605798..9ef1894af 100644 --- a/src/snapshot-tests/normalize.ts +++ b/src/snapshot-tests/normalize.ts @@ -10,6 +10,7 @@ const UUID_REGEX = /[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}- const DURATION_REGEX = /\d+\.\d+s\b/g; const PID_NUMBER_REGEX = /(pid:\s*)\d+/gi; const PID_FILENAME_SUFFIX_REGEX = /_pid\d+(?:_[0-9a-f]{8})?\.log/g; +const XCRESULT_FILENAME_PID_SUFFIX_REGEX = /_pid\d+_[0-9a-f]{8}\.xcresult/g; const HELPER_PID_FILENAME_SUFFIX_REGEX = /_(?:helperpid\d+_ownerpid\d+|ownerpid\d+)_[0-9a-f]{8}\.log/g; const PID_JSON_REGEX = /"pid"\s*:\s*\d+/g; @@ -129,7 +130,7 @@ export function normalizeSnapshotOutput(text: string): string { '', ); normalized = normalized.replace( - /(\/Library\/Developer\/XcodeBuildMCP\/workspaces\/[^/]+)-[0-9a-f]{12}(?=\/logs\/)/g, + /(\/Library\/Developer\/XcodeBuildMCP\/workspaces\/[^/]+)-[0-9a-f]{12}(?=\/(?:logs|result-bundles)\/)/g, '$1-', ); normalized = normalized.replace( @@ -160,6 +161,7 @@ export function normalizeSnapshotOutput(text: string): string { normalized = normalized.replace(PID_NUMBER_REGEX, '$1'); normalized = normalized.replace(HELPER_PID_FILENAME_SUFFIX_REGEX, '_pid.log'); normalized = normalized.replace(PID_FILENAME_SUFFIX_REGEX, '_pid.log'); + normalized = normalized.replace(XCRESULT_FILENAME_PID_SUFFIX_REGEX, '_pid.xcresult'); normalized = normalized.replace(PID_JSON_REGEX, '"pid" : '); normalized = normalized.replace(PROCESS_ID_REGEX, 'Process ID: '); normalized = normalized.replace(PROCESS_INLINE_PID_REGEX, 'process '); diff --git a/src/utils/__tests__/log-paths.test.ts b/src/utils/__tests__/log-paths.test.ts index 6b1288c87..d437d3d18 100644 --- a/src/utils/__tests__/log-paths.test.ts +++ b/src/utils/__tests__/log-paths.test.ts @@ -25,6 +25,7 @@ describe('log paths', () => { state: path.join(appDir, 'workspaces', 'workspace-a', 'state'), locks: path.join(appDir, 'workspaces', 'workspace-a', 'locks'), derivedData: path.join(appDir, 'workspaces', 'workspace-a', 'DerivedData'), + resultBundles: path.join(appDir, 'workspaces', 'workspace-a', 'result-bundles'), logRetention: { lockDir: path.join(appDir, 'workspaces', 'workspace-a', 'locks', 'log-retention.lock'), markerPath: path.join( diff --git a/src/utils/__tests__/simulator-test-execution.test.ts b/src/utils/__tests__/simulator-test-execution.test.ts index f3170a499..aeb351906 100644 --- a/src/utils/__tests__/simulator-test-execution.test.ts +++ b/src/utils/__tests__/simulator-test-execution.test.ts @@ -84,7 +84,7 @@ describe('createSimulatorTwoPhaseExecutionPlan', () => { expect(plan.usesExactSelectors).toBe(true); }); - it('keeps resultBundlePath out of build-for-testing args and includes it for test-without-building', () => { + it('includes resultBundlePath only in the simulator test execution phase', () => { const plan = createSimulatorTwoPhaseExecutionPlan({ extraArgs: ['-resultBundlePath', '/tmp/UserProvided.xcresult'], }); diff --git a/src/utils/__tests__/snapshot-normalize.test.ts b/src/utils/__tests__/snapshot-normalize.test.ts index 33b1acb34..fe1c66c23 100644 --- a/src/utils/__tests__/snapshot-normalize.test.ts +++ b/src/utils/__tests__/snapshot-normalize.test.ts @@ -60,6 +60,19 @@ describe('normalizeSnapshotOutput tilde handling', () => { ); }); + it('normalizes workspace-scoped result bundle paths', () => { + const input = + 'Result Bundle: /Library/Developer/XcodeBuildMCP/workspaces/Weather-abc123def456/result-bundles/test_macos_2026-05-07T09-58-46-123Z_pid1234_abcd1234.xcresult\n'; + + const result = normalizeSnapshotOutput(input); + + expect(result).toContain( + '/Library/Developer/XcodeBuildMCP/workspaces/Weather-/result-bundles/test_macos__pid.xcresult', + ); + expect(result).not.toContain('Weather-abc123def456'); + expect(result).not.toContain('abcd1234'); + }); + it('normalizes workspace-scoped XcodeBuildMCP DerivedData hashes', () => { const input = 'Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/Weather-abc123def456/DerivedData/CalculatorApp-22d700c6d603\n'; diff --git a/src/utils/__tests__/test-common.test.ts b/src/utils/__tests__/test-common.test.ts index 9006bca12..99d99e44a 100644 --- a/src/utils/__tests__/test-common.test.ts +++ b/src/utils/__tests__/test-common.test.ts @@ -1,11 +1,19 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ChildProcess } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createTestExecutor, resolveTestProgressEnabled } from '../test-common.ts'; import type { CommandExecutor, CommandResponse } from '../command.ts'; import { DefaultStreamingExecutionContext } from '../execution/index.ts'; import type { AnyFragment } from '../../types/domain-fragments.ts'; import type { TestPreflightResult } from '../test-preflight.ts'; import { XcodePlatform } from '../xcode.ts'; +import { + getWorkspaceFilesystemLayout, + setXcodeBuildMCPAppDirOverrideForTests, +} from '../log-paths.ts'; +import { setRuntimeInstanceForTests } from '../runtime-instance.ts'; function createSuccessfulCommandResponse(): CommandResponse { return { @@ -94,6 +102,36 @@ describe('resolveTestProgressEnabled', () => { }); describe('createTestExecutor', () => { + let tempAppDir: string; + + beforeEach(() => { + tempAppDir = mkdtempSync(join(tmpdir(), 'xcodebuildmcp-result-bundles-')); + setXcodeBuildMCPAppDirOverrideForTests(tempAppDir); + setRuntimeInstanceForTests({ + instanceId: 'result-bundle-test', + pid: process.pid, + workspaceKey: 'workspace-a', + }); + }); + + afterEach(() => { + setXcodeBuildMCPAppDirOverrideForTests(null); + setRuntimeInstanceForTests(null); + rmSync(tempAppDir, { recursive: true, force: true }); + }); + + function expectDefaultResultBundlePath(command: readonly string[], toolName: string): string { + const resultBundleArgIndex = command.indexOf('-resultBundlePath'); + expect(resultBundleArgIndex).toBeGreaterThan(-1); + const resultBundlePath = command[resultBundleArgIndex + 1]; + expect(resultBundlePath).toEqual( + expect.stringContaining(getWorkspaceFilesystemLayout('workspace-a').resultBundles), + ); + expect(resultBundlePath).toEqual(expect.stringContaining(`${toolName}_`)); + expect(resultBundlePath).toEqual(expect.stringMatching(/\.xcresult$/u)); + return resultBundlePath!; + } + it('emits RUN_TESTS before test-without-building starts in two-phase simulator execution', async () => { const emitted: AnyFragment[] = []; const actions: string[] = []; @@ -148,4 +186,228 @@ describe('createTestExecutor', () => { expect(runTestsIndex).toBeGreaterThan(-1); expect(finalSummaryIndex).toBeGreaterThan(runTestsIndex); }); + + it('injects a workspace-scoped default result bundle path for macOS test commands', async () => { + const commands: string[][] = []; + const executor: CommandExecutor = async (command) => { + commands.push(command); + return createSuccessfulCommandResponse(); + }; + + const executeTest = createTestExecutor(executor, { + toolName: 'test_macos', + target: 'macos', + request: { + scheme: 'Weather', + projectPath: 'Weather.xcodeproj', + configuration: 'Debug', + platform: XcodePlatform.macOS, + }, + }); + + const result = await executeTest( + { + projectPath: 'Weather.xcodeproj', + scheme: 'Weather', + configuration: 'Debug', + platform: XcodePlatform.macOS, + }, + new DefaultStreamingExecutionContext(), + ); + + expect(commands).toHaveLength(1); + const resultBundlePath = expectDefaultResultBundlePath(commands[0]!, 'test_macos'); + expect(commands[0]!.at(-1)).toBe('test'); + expect(result.artifacts.xcresultPath).toBe(resultBundlePath); + }); + + it('returns a structured test-result when default result bundle path resolution fails', async () => { + const layout = getWorkspaceFilesystemLayout('workspace-a'); + mkdirSync(layout.root, { recursive: true }); + writeFileSync(layout.resultBundles, 'not a directory'); + const executor = vi.fn(); + + const executeTest = createTestExecutor(executor, { + toolName: 'test_macos', + target: 'macos', + request: { + scheme: 'Weather', + projectPath: 'Weather.xcodeproj', + configuration: 'Debug', + platform: XcodePlatform.macOS, + }, + }); + + const result = await executeTest( + { + projectPath: 'Weather.xcodeproj', + scheme: 'Weather', + configuration: 'Debug', + platform: XcodePlatform.macOS, + }, + new DefaultStreamingExecutionContext(), + ); + + expect(executor).not.toHaveBeenCalled(); + expect(result.kind).toBe('test-result'); + expect(result.didError).toBe(true); + expect(result.summary.status).toBe('FAILED'); + expect(result.diagnostics.rawOutput?.join('\n')).toContain( + 'Unable to create writable result bundle directory', + ); + }); + + it('injects a workspace-scoped default result bundle path for device test commands', async () => { + const commands: string[][] = []; + const executor: CommandExecutor = async (command) => { + commands.push(command); + return createSuccessfulCommandResponse(); + }; + + const executeTest = createTestExecutor(executor, { + toolName: 'test_device', + target: 'device', + request: { + scheme: 'Weather', + projectPath: 'Weather.xcodeproj', + configuration: 'Debug', + platform: XcodePlatform.iOS, + deviceId: 'DEVICE-123', + }, + }); + + const result = await executeTest( + { + projectPath: 'Weather.xcodeproj', + scheme: 'Weather', + configuration: 'Debug', + platform: XcodePlatform.iOS, + deviceId: 'DEVICE-123', + }, + new DefaultStreamingExecutionContext(), + ); + + expect(commands).toHaveLength(1); + const resultBundlePath = expectDefaultResultBundlePath(commands[0]!, 'test_device'); + expect(commands[0]!.at(-1)).toBe('test'); + expect(result.artifacts.xcresultPath).toBe(resultBundlePath); + }); + + it('does not surface a result bundle when simulator build-for-testing fails before tests run', async () => { + const commands: string[][] = []; + const executor: CommandExecutor = async (command, _logPrefix, _useShell, opts) => { + commands.push(command); + opts?.onStderr?.( + 'Writing error result bundle to /var/folders/test/ResultBundle_2026-05-07_09-38-0016.xcresult\n', + ); + return { + success: false, + output: '', + process: { pid: 12345 } as ChildProcess, + exitCode: 65, + }; + }; + + const executeTest = createTestExecutor(executor, { + preflight: createPreflight(), + toolName: 'test_sim', + target: 'simulator', + request: { + scheme: 'Weather', + projectPath: 'Weather.xcodeproj', + configuration: 'Debug', + platform: XcodePlatform.iOSSimulator, + }, + }); + + const result = await executeTest( + { + projectPath: 'Weather.xcodeproj', + scheme: 'Weather', + configuration: 'Debug', + simulatorId: 'A2C64636-37E9-4B68-B872-E7F0A82A5670', + platform: XcodePlatform.iOSSimulator, + }, + new DefaultStreamingExecutionContext(), + ); + + expect(commands).toHaveLength(1); + expect(commands[0]).not.toContain('-resultBundlePath'); + expect(result.artifacts.xcresultPath).toBeUndefined(); + }); + + it('injects the default result bundle only into the simulator test execution phase', async () => { + const commands: string[][] = []; + const executor: CommandExecutor = async (command) => { + commands.push(command); + return createSuccessfulCommandResponse(); + }; + + const executeTest = createTestExecutor(executor, { + preflight: createPreflight(), + toolName: 'test_sim', + target: 'simulator', + request: { + scheme: 'Weather', + projectPath: 'Weather.xcodeproj', + configuration: 'Debug', + platform: XcodePlatform.iOSSimulator, + }, + }); + + const result = await executeTest( + { + projectPath: 'Weather.xcodeproj', + scheme: 'Weather', + configuration: 'Debug', + simulatorId: 'A2C64636-37E9-4B68-B872-E7F0A82A5670', + platform: XcodePlatform.iOSSimulator, + }, + new DefaultStreamingExecutionContext(), + ); + + expect(commands).toHaveLength(2); + expect(commands[0]).not.toContain('-resultBundlePath'); + expect(commands[0]!.at(-1)).toBe('build-for-testing'); + const testResultBundlePath = expectDefaultResultBundlePath(commands[1]!, 'test_sim'); + expect(commands[1]!.at(-1)).toBe('test-without-building'); + expect(result.artifacts.xcresultPath).toBe(testResultBundlePath); + }); + + it('preserves a user-supplied result bundle path instead of injecting a default', async () => { + const commands: string[][] = []; + const executor: CommandExecutor = async (command) => { + commands.push(command); + return createSuccessfulCommandResponse(); + }; + + const executeTest = createTestExecutor(executor, { + toolName: 'test_macos', + target: 'macos', + request: { + scheme: 'Weather', + projectPath: 'Weather.xcodeproj', + configuration: 'Debug', + platform: XcodePlatform.macOS, + }, + }); + + const result = await executeTest( + { + projectPath: 'Weather.xcodeproj', + scheme: 'Weather', + configuration: 'Debug', + platform: XcodePlatform.macOS, + extraArgs: ['-quiet', '-resultBundlePath=/tmp/User Provided.xcresult'], + }, + new DefaultStreamingExecutionContext(), + ); + + expect(commands).toHaveLength(1); + expect(commands[0]).toContain('-quiet'); + expect(commands[0]).toContain('-resultBundlePath'); + expect(commands[0]).toContain('/tmp/User Provided.xcresult'); + expect(commands[0]).not.toContain(getWorkspaceFilesystemLayout('workspace-a').resultBundles); + expect(result.artifacts.xcresultPath).toBe('/tmp/User Provided.xcresult'); + }); }); diff --git a/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts b/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts index 3d0d96ef2..43da41fac 100644 --- a/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts +++ b/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts @@ -15,6 +15,7 @@ import { getWorkspaceFilesystemLayout, setXcodeBuildMCPAppDirOverrideForTests, } from '../log-paths.ts'; +import { getResultBundleCompletionMarkerPath } from '../result-bundle-path.ts'; import { writeDaemonRegistryEntry } from '../../daemon/daemon-registry.ts'; import { setRuntimeInstanceForTests } from '../runtime-instance.ts'; import { @@ -36,6 +37,17 @@ function managedXcodebuildLogName(name = 'build_sim'): string { return `${name}_2026-05-02T12-00-00-000Z_pid123_abcdef12.log`; } +function managedResultBundleName(name = 'test', pid = 123): string { + return `${name}_2026-05-02T12-00-00-000Z_pid${pid}_abcdef12.xcresult`; +} + +function writeResultBundleWithMtime(bundlePath: string, mtimeMs: number): void { + mkdirSync(bundlePath, { recursive: true }); + writeFileSync(path.join(bundlePath, 'Info.plist'), 'stub'); + const mtime = new Date(mtimeMs); + utimesSync(bundlePath, mtime, mtime); +} + function createTrackedChild(pid: number, onKill: () => void): ChildProcess { const child = new EventEmitter() as ChildProcess; Object.defineProperty(child, 'pid', { value: pid, configurable: true }); @@ -251,6 +263,97 @@ describe('workspace filesystem lifecycle', () => { expect(existsSync(knownLog)).toBe(false); }); + it('prunes only managed result bundles from workspace result-bundles', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const managedBundle = path.join(layout.resultBundles, managedResultBundleName('test')); + const unknownBundle = path.join(layout.resultBundles, 'manual-user-bundle.xcresult'); + writeResultBundleWithMtime(managedBundle, now - 4 * 24 * 60 * 60 * 1000); + writeResultBundleWithMtime(unknownBundle, now - 4 * 24 * 60 * 60 * 1000); + + const result = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'manual', + now, + force: true, + minVisibleMs: 0, + }); + + expect(result).toMatchObject({ scanned: 1, deleted: 1 }); + expect(existsSync(managedBundle)).toBe(false); + expect(existsSync(unknownBundle)).toBe(true); + }); + + it('applies maxFiles overflow pruning to managed result bundles', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const oldBundle = path.join(layout.resultBundles, managedResultBundleName('test_old')); + const newBundle = path.join(layout.resultBundles, managedResultBundleName('test_new')); + writeResultBundleWithMtime(oldBundle, now - 2 * 24 * 60 * 60 * 1000); + writeResultBundleWithMtime(newBundle, now - 1 * 24 * 60 * 60 * 1000); + + const result = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'manual', + now, + force: true, + minVisibleMs: 0, + maxFiles: 1, + }); + + expect(result).toMatchObject({ scanned: 2, deleted: 1 }); + expect(existsSync(oldBundle)).toBe(false); + expect(existsSync(newBundle)).toBe(true); + }); + + it('protects live managed result bundles until their completion marker exists', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const liveBundle = path.join( + layout.resultBundles, + managedResultBundleName('test_live', process.pid), + ); + writeResultBundleWithMtime(liveBundle, now - 4 * 24 * 60 * 60 * 1000); + + const result = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'manual', + now, + force: true, + minVisibleMs: 0, + }); + + expect(result).toMatchObject({ scanned: 1, deleted: 0 }); + expect(existsSync(liveBundle)).toBe(true); + }); + + it('prunes completed managed result bundles even when the owner process is still alive', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const completedBundle = path.join( + layout.resultBundles, + managedResultBundleName('test_completed', process.pid), + ); + writeResultBundleWithMtime(completedBundle, now - 4 * 24 * 60 * 60 * 1000); + writeFileWithMtime( + getResultBundleCompletionMarkerPath(completedBundle), + 'completed', + now - 4 * 24 * 60 * 60 * 1000, + ); + + const result = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'manual', + now, + force: true, + minVisibleMs: 0, + }); + + expect(result).toMatchObject({ scanned: 1, deleted: 1 }); + expect(existsSync(completedBundle)).toBe(false); + expect(existsSync(getResultBundleCompletionMarkerPath(completedBundle))).toBe(false); + }); + it('cooldowns repeat schedule calls for the same workspace', () => { vi.useFakeTimers(); try { diff --git a/src/utils/log-paths.ts b/src/utils/log-paths.ts index 2e0c3273b..234042b01 100644 --- a/src/utils/log-paths.ts +++ b/src/utils/log-paths.ts @@ -22,6 +22,7 @@ export interface WorkspaceFilesystemLayout { state: string; locks: string; derivedData: string; + resultBundles: string; logRetention: LogRetentionPaths; filesystemLifecycle: WorkspaceFilesystemLifecyclePaths; simulatorLaunchOsLogRegistryDir: string; @@ -53,6 +54,7 @@ export function getWorkspaceFilesystemLayout(workspaceKey: string): WorkspaceFil const state = path.join(root, 'state'); const locks = path.join(root, 'locks'); const derivedData = path.join(root, 'DerivedData'); + const resultBundles = path.join(root, 'result-bundles'); return { workspaceKey: normalizedWorkspaceKey, @@ -61,6 +63,7 @@ export function getWorkspaceFilesystemLayout(workspaceKey: string): WorkspaceFil state, locks, derivedData, + resultBundles, logRetention: { lockDir: path.join(locks, 'log-retention.lock'), markerPath: path.join(state, 'log-retention', 'last-cleanup'), diff --git a/src/utils/result-bundle-path.ts b/src/utils/result-bundle-path.ts new file mode 100644 index 000000000..e0ccb22ed --- /dev/null +++ b/src/utils/result-bundle-path.ts @@ -0,0 +1,52 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { log } from './logger.ts'; +import { getWorkspaceFilesystemLayout } from './log-paths.ts'; +import { formatLogTimestamp, shortRandomSuffix } from './log-naming.ts'; +import { getRuntimeInstanceIfConfigured } from './runtime-instance.ts'; +import { workspaceKeyForRoot } from './workspace-identity.ts'; + +const RESULT_BUNDLE_COMPLETION_MARKER_SUFFIX = '.xcodebuildmcp-completed'; + +function resolveWorkspaceKey(): string { + return getRuntimeInstanceIfConfigured()?.workspaceKey ?? workspaceKeyForRoot(process.cwd()); +} + +export function getResultBundleCompletionMarkerPath(resultBundlePath: string): string { + return `${resultBundlePath}${RESULT_BUNDLE_COMPLETION_MARKER_SUFFIX}`; +} + +export function createDefaultResultBundlePath(toolName: string): string { + const resultBundleDir = getWorkspaceFilesystemLayout(resolveWorkspaceKey()).resultBundles; + + try { + fs.mkdirSync(resultBundleDir, { recursive: true, mode: 0o700 }); + fs.accessSync(resultBundleDir, fs.constants.W_OK); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Unable to create writable result bundle directory at ${resultBundleDir}: ${message}`, + ); + } + + return path.join( + resultBundleDir, + `${toolName}_${formatLogTimestamp()}_pid${process.pid}_${shortRandomSuffix()}.xcresult`, + ); +} + +export function markResultBundlePathCompleted(resultBundlePath: string | undefined): void { + if (!resultBundlePath) { + return; + } + + try { + if (!fs.existsSync(resultBundlePath) || !fs.statSync(resultBundlePath).isDirectory()) { + return; + } + fs.writeFileSync(getResultBundleCompletionMarkerPath(resultBundlePath), `${Date.now()}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('warn', `Unable to mark result bundle completed at ${resultBundlePath}: ${message}`); + } +} diff --git a/src/utils/simulator-test-execution.ts b/src/utils/simulator-test-execution.ts index af7d88762..4fbbfcb98 100644 --- a/src/utils/simulator-test-execution.ts +++ b/src/utils/simulator-test-execution.ts @@ -63,14 +63,11 @@ export function createSimulatorTwoPhaseExecutionPlan(params: { const selectedTestArgs = parsedArgs.selectorArgs; const usesExactSelectors = selectedTestArgs.length > 0; const resultBundlePath = params.resultBundlePath ?? parsedArgs.resultBundlePath; + const resultBundleArgs = resultBundlePath ? ['-resultBundlePath', resultBundlePath] : []; return { buildArgs: [...parsedArgs.remainingArgs, ...selectedTestArgs], - testArgs: [ - ...parsedArgs.remainingArgs, - ...selectedTestArgs, - ...(resultBundlePath ? ['-resultBundlePath', resultBundlePath] : []), - ], + testArgs: [...parsedArgs.remainingArgs, ...selectedTestArgs, ...resultBundleArgs], usesExactSelectors, ...(resultBundlePath ? { resultBundlePath } : {}), }; diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index 50a1a2ccb..689cc0315 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -16,7 +16,11 @@ import { getDefaultCommandExecutor } from './command.ts'; import { type TestPreflightResult } from './test-preflight.ts'; import { createSimulatorTwoPhaseExecutionPlan } from './simulator-test-execution.ts'; -import { findResultBundlePathArg } from './result-bundle-args.ts'; +import { parseResultBundlePathArgs } from './result-bundle-args.ts'; +import { + createDefaultResultBundlePath, + markResultBundlePathCompleted, +} from './result-bundle-path.ts'; import type { BuildTarget, @@ -135,11 +139,16 @@ export function createTestExecutor( } try { + const parsedResultBundleArgs = parseResultBundlePathArgs(params.extraArgs); + const shouldUseDefaultResultBundlePath = !parsedResultBundleArgs.resultBundlePath; + const resultBundlePath = + parsedResultBundleArgs.resultBundlePath ?? createDefaultResultBundlePath(toolName); + if (shouldUseTwoPhaseSimulatorExecution) { const executionPlan = createSimulatorTwoPhaseExecutionPlan({ extraArgs: params.extraArgs, preflight: options.preflight, - resultBundlePath: undefined, + resultBundlePath, }); const buildForTestingResult = await executeXcodeBuildCommand( @@ -162,6 +171,7 @@ export function createTestExecutor( started.stderrLines, buildForTestingResult.content, ), + includeDetectedXcresult: false, preflight: options.preflight, request: options.request, }); @@ -185,6 +195,9 @@ export function createTestExecutor( started.pipeline, ); + if (shouldUseDefaultResultBundlePath) { + markResultBundlePathCompleted(executionPlan.resultBundlePath); + } emitXcresultFailures(started.pipeline); return createTestDomainResult({ @@ -201,8 +214,13 @@ export function createTestExecutor( }); } + const singlePhaseParams: SharedTestExecutorParams = { + ...params, + extraArgs: [...parsedResultBundleArgs.remainingArgs, '-resultBundlePath', resultBundlePath], + }; + const singlePhaseResult = await executeXcodeBuildCommand( - params, + singlePhaseParams, platformOptions, params.preferXcodebuild, 'test', @@ -211,17 +229,16 @@ export function createTestExecutor( started.pipeline, ); + if (shouldUseDefaultResultBundlePath) { + markResultBundlePathCompleted(resultBundlePath); + } emitXcresultFailures(started.pipeline); return createTestDomainResult({ started, succeeded: !singlePhaseResult.isError, target, - artifacts: createXcodebuildTestArtifacts( - params, - started, - findResultBundlePathArg(params.extraArgs), - ), + artifacts: createXcodebuildTestArtifacts(params, started, resultBundlePath), fallbackErrorMessages: getFallbackErrorMessages( started.stderrLines, singlePhaseResult.content, diff --git a/src/utils/workspace-filesystem-lifecycle.ts b/src/utils/workspace-filesystem-lifecycle.ts index 13521197a..5489c2202 100644 --- a/src/utils/workspace-filesystem-lifecycle.ts +++ b/src/utils/workspace-filesystem-lifecycle.ts @@ -14,6 +14,7 @@ import { log } from './logging/index.ts'; import { getRuntimeInstance, getRuntimeInstanceIfConfigured } from './runtime-instance.ts'; import { tryAcquireFsLock } from './fs-lock.ts'; import { isPidAlive } from './process-liveness.ts'; +import { getResultBundleCompletionMarkerPath } from './result-bundle-path.ts'; export const WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; export const WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_FILES = 10_000; @@ -37,6 +38,10 @@ const XCODEBUILD_LOG_NAME_PATTERN = new RegExp( const SIMULATOR_LOG_NAME_PATTERN = new RegExp( `^.+_${ISO_TIMESTAMP_PATTERN}_(?:helperpid\\d+_)?ownerpid\\d+_${SUFFIX_PATTERN}\\.log$`, ); +const RESULT_BUNDLE_NAME_PATTERN = new RegExp( + `^[A-Za-z0-9][A-Za-z0-9_-]*_${ISO_TIMESTAMP_PATTERN}_pid\\d+_${SUFFIX_PATTERN}\\.xcresult$`, +); +const RESULT_BUNDLE_OWNER_PID_PATTERN = /_pid(\d+)_/u; const XCODE_IDE_CALL_TOOL_TRANSIENT_DIR = path.join('xcode-ide', 'call-tool'); const XCODE_IDE_CALL_TOOL_OWNER_DIR_PATTERN = /^ownerpid(\d+)_/u; @@ -88,6 +93,7 @@ interface ResolvedWorkspaceFilesystemLifecycleOptions { logDir: string; markerPath: string; lockDir: string; + resultBundleDir: string | null; now: number; maxAgeMs: number; maxFiles: number; @@ -142,6 +148,7 @@ function resolveOptions( options.lockDir ?? layout?.filesystemLifecycle.lockDir ?? path.join(logDir, FALLBACK_LOCK_DIR_NAME), + resultBundleDir: layout?.resultBundles ?? null, now: options.now ?? Date.now(), maxAgeMs: options.maxAgeMs ?? WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_AGE_MS, maxFiles: options.maxFiles ?? WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_FILES, @@ -192,6 +199,15 @@ function isXcodeBuildMCPManagedLogName(fileName: string): boolean { return XCODEBUILD_LOG_NAME_PATTERN.test(fileName) || SIMULATOR_LOG_NAME_PATTERN.test(fileName); } +function isXcodeBuildMCPManagedResultBundleName(fileName: string): boolean { + return RESULT_BUNDLE_NAME_PATTERN.test(fileName); +} + +function getManagedResultBundleOwnerPid(fileName: string): number | null { + const pid = Number(fileName.match(RESULT_BUNDLE_OWNER_PID_PATTERN)?.[1]); + return Number.isInteger(pid) && pid > 0 ? pid : null; +} + async function deleteFile(filePath: string): Promise { try { await fs.unlink(filePath); @@ -293,6 +309,98 @@ async function pruneKnownLogDirectory( return { scanned, deleted }; } +async function hasResultBundleCompletionMarker(bundlePath: string): Promise { + try { + const markerStat = await fs.stat(getResultBundleCompletionMarkerPath(bundlePath)); + return markerStat.isFile(); + } catch { + return false; + } +} + +async function isProtectedResultBundleDirectory( + bundle: RetainedLogFile, + options: ResolvedWorkspaceFilesystemLifecycleOptions, +): Promise { + if (options.now - bundle.mtimeMs < options.minVisibleMs) { + return true; + } + + const ownerPid = getManagedResultBundleOwnerPid(bundle.name); + if (ownerPid && isPidAlive(ownerPid) && !(await hasResultBundleCompletionMarker(bundle.path))) { + return true; + } + + return false; +} + +async function pruneKnownResultBundleDirectory( + options: ResolvedWorkspaceFilesystemLifecycleOptions, +): Promise<{ scanned: number; deleted: number }> { + if (!options.resultBundleDir) { + return { scanned: 0, deleted: 0 }; + } + + const resultBundleDir = options.resultBundleDir; + await fs.mkdir(resultBundleDir, { recursive: true, mode: 0o700 }); + const entries = await fs.readdir(resultBundleDir, { withFileTypes: true }); + const candidates = entries + .filter((entry) => entry.isDirectory() && isXcodeBuildMCPManagedResultBundleName(entry.name)) + .map((entry) => ({ name: entry.name, path: path.join(resultBundleDir, entry.name) })); + + const stats = await Promise.all( + candidates.map(async (candidate) => { + try { + const stat = await fs.stat(candidate.path); + return { ...candidate, mtimeMs: stat.mtimeMs } satisfies RetainedLogFile; + } catch { + return null; + } + }), + ); + + const retainedDeletable: RetainedLogFile[] = []; + const expired: RetainedLogFile[] = []; + let scanned = 0; + + for (const bundle of stats) { + if (!bundle) continue; + scanned += 1; + if (await isProtectedResultBundleDirectory(bundle, options)) { + continue; + } + if (options.now - bundle.mtimeMs > options.maxAgeMs) { + expired.push(bundle); + continue; + } + retainedDeletable.push(bundle); + } + + const excessFileCount = retainedDeletable.length - options.maxFiles; + const overflow = + excessFileCount > 0 + ? retainedDeletable + .slice() + .sort((left, right) => left.mtimeMs - right.mtimeMs) + .slice(0, excessFileCount) + : []; + + const deletions = await Promise.all( + [...expired, ...overflow].map(async (bundle) => { + try { + await fs.rm(bundle.path, { recursive: true, force: true }); + await deleteFile(getResultBundleCompletionMarkerPath(bundle.path)); + return true; + } catch { + return false; + } + }), + ); + const deleted = deletions.reduce((count, success) => count + (success ? 1 : 0), 0); + + return { scanned, deleted }; +} + function zeroResult( options: ResolvedWorkspaceFilesystemLifecycleOptions, skippedByCooldown: boolean, @@ -450,15 +558,16 @@ export async function runWorkspaceFilesystemLifecycleSweep( runDaemonCleanup(resolved); const protectedPaths = await collectProtectedLogPaths(resolved); - const { scanned, deleted } = await pruneKnownLogDirectory(resolved, protectedPaths); + const logPrune = await pruneKnownLogDirectory(resolved, protectedPaths); + const resultBundlePrune = await pruneKnownResultBundleDirectory(resolved); await touchCleanupMarker(resolved.markerPath, resolved.now); return { workspaceKey: resolved.workspaceKey, trigger: resolved.trigger, logDir: resolved.logDir, - scanned, - deleted, + scanned: logPrune.scanned + resultBundlePrune.scanned, + deleted: logPrune.deleted + resultBundlePrune.deleted, stopped, skippedByCooldown: false, skippedByLock: false, @@ -520,10 +629,7 @@ export function scheduleWorkspaceFilesystemLifecycleSweep( lastScheduledAtByPreKey.set(preKey, completedAt); } if (result.deleted > 0) { - log( - 'info', - `[FilesystemLifecycle] Deleted ${result.deleted} old log files from ${result.logDir}`, - ); + log('info', `[FilesystemLifecycle] Deleted ${result.deleted} old filesystem artifacts`); } } }) diff --git a/src/utils/xcodebuild-domain-results.ts b/src/utils/xcodebuild-domain-results.ts index 44abdb364..0ece1c045 100644 --- a/src/utils/xcodebuild-domain-results.ts +++ b/src/utils/xcodebuild-domain-results.ts @@ -385,6 +385,7 @@ export function createTestDomainResult(options: { target: BuildTarget; artifacts: TestResultArtifacts; fallbackErrorMessages?: readonly string[]; + includeDetectedXcresult?: boolean; preflight?: TestPreflightResult; request: BuildInvocationRequest; }): TestResultDomainResult { @@ -398,10 +399,12 @@ export function createTestDomainResult(options: { ...(fragment.durationMs !== undefined ? { durationMs: fragment.durationMs } : {}), })); const detectedXcresultPath = - options.target === 'swift-package' ? null : options.started.pipeline.xcresultPath; + options.includeDetectedXcresult === false || options.target === 'swift-package' + ? null + : options.started.pipeline.xcresultPath; const providedXcresultPath = 'xcresultPath' in options.artifacts ? options.artifacts.xcresultPath : undefined; - const xcresultPath = detectedXcresultPath ?? providedXcresultPath; + const xcresultPath = providedXcresultPath ?? detectedXcresultPath; const artifacts: TestResultArtifacts = { ...options.artifacts, ...(xcresultPath ? { xcresultPath } : {}),