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 52cb0ab7..6aafbdcf 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 82bc702e..c41a0126 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 970c7994..22431486 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 1d9ffb45..25f19932 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 982b0cfa..20d8372c 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 5c83e7d6..7821e137 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 103e729b..bf22f9ec 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 28ba26e3..301f586d 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 b30dae77..0b1f6ded 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 7a0afe07..961239d2 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 1538ea01..0e0a625b 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 ef7acec1..5cefd181 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 2f9766e9..be0cc4c0 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 6a549c92..d694e3fb 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 e98ad4f7..5dc89481 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 4f5d345f..a6521794 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 97bc2596..494cc28b 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 03e9540e..ffdbc19e 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 470a91df..01f82f0f 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 c088f083..3eb1aa90 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 08db9978..ada1fae8 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 cd0a8c7c..d00ef768 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 983acb4c..41805834 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 eefb3f2d..0165555f 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 35e6c263..8a26848e 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 103e729b..bf22f9ec 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 b3b83e14..8222e170 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 e519c8a1..a17c9d71 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 38bd5688..f74680d2 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 a1ca41f9..8db8f9c8 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 0ad98e1a..8db02e9b 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 aae60579..9ef1894a 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 6b1288c8..d437d3d1 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 f3170a49..aeb35190 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 33b1acb3..fe1c66c2 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 9006bca1..99d99e44 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 3d0d96ef..43da41fa 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 2e0c3273..234042b0 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 00000000..e0ccb22e --- /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 af7d8876..4fbbfcb9 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 50a1a2cc..689cc031 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 13521197..5489c220 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 44abdb36..0ece1c04 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 } : {}),