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 } : {}),