From 6debd3d3294bccf620639f22e1d60f450243d7f4 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 22 Aug 2025 21:40:44 +0100 Subject: [PATCH 1/2] feat: add TEST_RUNNER_ environment variable support for test tools Implements explicit testRunnerEnv parameter for all test tools (test_device, test_macos, test_sim) to support passing environment variables to xcodebuild test commands with automatic TEST_RUNNER_ prefix handling. ## Key Features - Added testRunnerEnv parameter to all three test tools - Automatic TEST_RUNNER_ prefix normalization for environment variables - Updated CommandExecutor infrastructure to support execution options - Enhanced executeXcodeBuildCommand to accept environment variables - Updated handleTestLogic for simulator test integration ## Security & Architecture Benefits - Explicit parameter approach prevents environment variable injection attacks - Follows principle of least privilege - only specified variables are passed - Maintains clear separation between MCP server config and tool execution - Provides audit trail of exactly which variables are used per test ## Implementation Details - normalizeTestRunnerEnv() utility automatically adds TEST_RUNNER_ prefix - CommandExecOptions interface supports env and cwd execution options - All tools validate parameters with Zod schemas - Comprehensive test coverage with 26 new tests (18 unit + 8 integration) ## Usage Example ```typescript test_device({ projectPath: "/path/to/project.xcodeproj", scheme: "MyScheme", deviceId: "device-uuid", testRunnerEnv: { "USE_DEV_MODE": "YES" } // Becomes TEST_RUNNER_USE_DEV_MODE }) ``` Addresses GitHub issue #101 with a secure, explicit approach that prevents injection vulnerabilities while providing the requested functionality. Tested via Reloaderoo with real xcodebuild execution validation. --- .../iOS/MCPTest.xcodeproj/project.pbxproj | 128 +++++++++- .../xcshareddata/xcschemes/MCPTest.xcscheme | 13 + .../iOS/MCPTestUITests/MCPTestUITests.swift | 41 +++ src/mcp/tools/device/test_device.ts | 19 +- src/mcp/tools/macos/test_macos.ts | 19 +- src/mcp/tools/simulator/test_sim.ts | 7 + src/utils/CommandExecutor.ts | 7 +- src/utils/__tests__/environment.test.ts | 233 ++++++++++++++++++ .../test-runner-env-integration.test.ts | 167 +++++++++++++ src/utils/build-utils.ts | 5 +- src/utils/command.ts | 14 +- src/utils/environment.ts | 18 ++ src/utils/execution/index.ts | 2 +- src/utils/test-common.ts | 10 +- 14 files changed, 667 insertions(+), 16 deletions(-) create mode 100644 example_projects/iOS/MCPTestUITests/MCPTestUITests.swift create mode 100644 src/utils/__tests__/environment.test.ts create mode 100644 src/utils/__tests__/test-runner-env-integration.test.ts diff --git a/example_projects/iOS/MCPTest.xcodeproj/project.pbxproj b/example_projects/iOS/MCPTest.xcodeproj/project.pbxproj index be115bc3..a2827747 100644 --- a/example_projects/iOS/MCPTest.xcodeproj/project.pbxproj +++ b/example_projects/iOS/MCPTest.xcodeproj/project.pbxproj @@ -6,8 +6,19 @@ objectVersion = 77; objects = { +/* Begin PBXContainerItemProxy section */ + 8BC6F1572E58FBAD008DD7EC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8BA9F7E22D62A14300C22D5D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8BA9F7E92D62A14300C22D5D; + remoteInfo = MCPTest; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 8BA9F7EA2D62A14300C22D5D /* MCPTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MCPTest.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BC6F1512E58FBAD008DD7EC /* MCPTestUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MCPTestUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -16,6 +27,11 @@ path = MCPTest; sourceTree = ""; }; + 8BC6F1522E58FBAD008DD7EC /* MCPTestUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MCPTestUITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +42,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BC6F14E2E58FBAD008DD7EC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,6 +56,7 @@ isa = PBXGroup; children = ( 8BA9F7EC2D62A14300C22D5D /* MCPTest */, + 8BC6F1522E58FBAD008DD7EC /* MCPTestUITests */, 8BA9F7EB2D62A14300C22D5D /* Products */, ); sourceTree = ""; @@ -41,6 +65,7 @@ isa = PBXGroup; children = ( 8BA9F7EA2D62A14300C22D5D /* MCPTest.app */, + 8BC6F1512E58FBAD008DD7EC /* MCPTestUITests.xctest */, ); name = Products; sourceTree = ""; @@ -70,6 +95,29 @@ productReference = 8BA9F7EA2D62A14300C22D5D /* MCPTest.app */; productType = "com.apple.product-type.application"; }; + 8BC6F1502E58FBAD008DD7EC /* MCPTestUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8BC6F15B2E58FBAD008DD7EC /* Build configuration list for PBXNativeTarget "MCPTestUITests" */; + buildPhases = ( + 8BC6F14D2E58FBAD008DD7EC /* Sources */, + 8BC6F14E2E58FBAD008DD7EC /* Frameworks */, + 8BC6F14F2E58FBAD008DD7EC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8BC6F1582E58FBAD008DD7EC /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 8BC6F1522E58FBAD008DD7EC /* MCPTestUITests */, + ); + name = MCPTestUITests; + packageProductDependencies = ( + ); + productName = MCPTestUITests; + productReference = 8BC6F1512E58FBAD008DD7EC /* MCPTestUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -77,12 +125,16 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1620; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1620; TargetAttributes = { 8BA9F7E92D62A14300C22D5D = { CreatedOnToolsVersion = 16.2; }; + 8BC6F1502E58FBAD008DD7EC = { + CreatedOnToolsVersion = 26.0; + TestTargetID = 8BA9F7E92D62A14300C22D5D; + }; }; }; buildConfigurationList = 8BA9F7E52D62A14300C22D5D /* Build configuration list for PBXProject "MCPTest" */; @@ -100,6 +152,7 @@ projectRoot = ""; targets = ( 8BA9F7E92D62A14300C22D5D /* MCPTest */, + 8BC6F1502E58FBAD008DD7EC /* MCPTestUITests */, ); }; /* End PBXProject section */ @@ -112,6 +165,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BC6F14F2E58FBAD008DD7EC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -122,8 +182,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BC6F14D2E58FBAD008DD7EC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 8BC6F1582E58FBAD008DD7EC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8BA9F7E92D62A14300C22D5D /* MCPTest */; + targetProxy = 8BC6F1572E58FBAD008DD7EC /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 8BA9F80C2D62A14500C22D5D /* Debug */ = { isa = XCBuildConfiguration; @@ -302,6 +377,48 @@ }; name = Release; }; + 8BC6F1592E58FBAD008DD7EC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = BR6WD3M6ZD; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.test.MCPTestUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = MCPTest; + }; + name = Debug; + }; + 8BC6F15A2E58FBAD008DD7EC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = BR6WD3M6ZD; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.test.MCPTestUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = MCPTest; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -323,6 +440,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8BC6F15B2E58FBAD008DD7EC /* Build configuration list for PBXNativeTarget "MCPTestUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8BC6F1592E58FBAD008DD7EC /* Debug */, + 8BC6F15A2E58FBAD008DD7EC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 8BA9F7E22D62A14300C22D5D /* Project object */; diff --git a/example_projects/iOS/MCPTest.xcodeproj/xcshareddata/xcschemes/MCPTest.xcscheme b/example_projects/iOS/MCPTest.xcodeproj/xcshareddata/xcschemes/MCPTest.xcscheme index e4ac14fb..6d24981d 100644 --- a/example_projects/iOS/MCPTest.xcodeproj/xcshareddata/xcschemes/MCPTest.xcscheme +++ b/example_projects/iOS/MCPTest.xcodeproj/xcshareddata/xcschemes/MCPTest.xcscheme @@ -29,6 +29,19 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + " + XCTAssertEqual( + value, + "YES", + "Expected USE_DEV_MODE=YES via TEST_RUNNER_USE_DEV_MODE. Actual: \(value)" + ) + } + + /// Example of how a project might use the env var to alter behavior in dev mode. + /// This does not change test runner configuration; it simply demonstrates conditional logic. + func testDevModeBehaviorPlaceholder() throws { + let isDevMode = ProcessInfo.processInfo.environment["USE_DEV_MODE"] == "YES" + if isDevMode { + XCTSkip("Dev mode: skipping heavy or duplicated UI configuration runs") + } + XCTAssertTrue(true) + } +} \ No newline at end of file diff --git a/src/mcp/tools/device/test_device.ts b/src/mcp/tools/device/test_device.ts index 2fee4e6a..b02719aa 100644 --- a/src/mcp/tools/device/test_device.ts +++ b/src/mcp/tools/device/test_device.ts @@ -11,7 +11,12 @@ import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; -import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { normalizeTestRunnerEnv } from '../../../utils/environment.ts'; +import type { + CommandExecutor, + FileSystemExecutor, + CommandExecOptions, +} from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, @@ -33,6 +38,12 @@ const baseSchemaObject = z.object({ .enum(['iOS', 'watchOS', 'tvOS', 'visionOS']) .optional() .describe('Target platform (defaults to iOS)'), + testRunnerEnv: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', + ), }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); @@ -185,6 +196,11 @@ export async function testDeviceLogic( // Add resultBundlePath to extraArgs const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; + // Prepare execution options with TEST_RUNNER_ environment variables + const execOpts: CommandExecOptions | undefined = params.testRunnerEnv + ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } + : undefined; + // Run the test command const testResult = await executeXcodeBuildCommand( { @@ -206,6 +222,7 @@ export async function testDeviceLogic( params.preferXcodebuild, 'test', executor, + execOpts, ); // Parse xcresult bundle if it exists, regardless of whether tests passed or failed diff --git a/src/mcp/tools/macos/test_macos.ts b/src/mcp/tools/macos/test_macos.ts index 40728853..4949a131 100644 --- a/src/mcp/tools/macos/test_macos.ts +++ b/src/mcp/tools/macos/test_macos.ts @@ -11,7 +11,12 @@ import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; -import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { normalizeTestRunnerEnv } from '../../../utils/environment.ts'; +import type { + CommandExecutor, + FileSystemExecutor, + CommandExecOptions, +} from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, @@ -34,6 +39,12 @@ const baseSchemaObject = z.object({ .boolean() .optional() .describe('If true, prefers xcodebuild over the experimental incremental build system'), + testRunnerEnv: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', + ), }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); @@ -229,6 +240,11 @@ export async function testMacosLogic( // Add resultBundlePath to extraArgs const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; + // Prepare execution options with TEST_RUNNER_ environment variables + const execOpts: CommandExecOptions | undefined = params.testRunnerEnv + ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } + : undefined; + // Run the test command const testResult = await executeXcodeBuildCommand( { @@ -246,6 +262,7 @@ export async function testMacosLogic( params.preferXcodebuild ?? false, 'test', executor, + execOpts, ); // Parse xcresult bundle if it exists, regardless of whether tests passed or failed diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index 728aac57..a89fb02c 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -54,6 +54,12 @@ const baseSchemaObject = z.object({ .describe( 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', ), + testRunnerEnv: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', + ), }); // Apply preprocessor to handle empty strings @@ -96,6 +102,7 @@ export async function test_simLogic( useLatestOS: params.simulatorId ? false : (params.useLatestOS ?? false), preferXcodebuild: params.preferXcodebuild ?? false, platform: XcodePlatform.iOSSimulator, + testRunnerEnv: params.testRunnerEnv, }, executor, ); diff --git a/src/utils/CommandExecutor.ts b/src/utils/CommandExecutor.ts index f06ac19f..177c5cad 100644 --- a/src/utils/CommandExecutor.ts +++ b/src/utils/CommandExecutor.ts @@ -1,5 +1,10 @@ import { ChildProcess } from 'child_process'; +export interface CommandExecOptions { + env?: Record; + cwd?: string; +} + /** * Command executor function type for dependency injection */ @@ -7,7 +12,7 @@ export type CommandExecutor = ( command: string[], logPrefix?: string, useShell?: boolean, - env?: Record, + opts?: CommandExecOptions, detached?: boolean, ) => Promise; /** diff --git a/src/utils/__tests__/environment.test.ts b/src/utils/__tests__/environment.test.ts new file mode 100644 index 00000000..deaf3b21 --- /dev/null +++ b/src/utils/__tests__/environment.test.ts @@ -0,0 +1,233 @@ +/** + * Unit tests for environment utilities + */ + +import { describe, it, expect } from 'vitest'; +import { normalizeTestRunnerEnv } from '../environment.ts'; + +describe('normalizeTestRunnerEnv', () => { + describe('Basic Functionality', () => { + it('should add TEST_RUNNER_ prefix to unprefixed keys', () => { + const input = { FOO: 'value1', BAR: 'value2' }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value1', + TEST_RUNNER_BAR: 'value2', + }); + }); + + it('should preserve keys already prefixed with TEST_RUNNER_', () => { + const input = { TEST_RUNNER_FOO: 'value1', TEST_RUNNER_BAR: 'value2' }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value1', + TEST_RUNNER_BAR: 'value2', + }); + }); + + it('should handle mixed prefixed and unprefixed keys', () => { + const input = { + FOO: 'value1', + TEST_RUNNER_BAR: 'value2', + BAZ: 'value3', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value1', + TEST_RUNNER_BAR: 'value2', + TEST_RUNNER_BAZ: 'value3', + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty object', () => { + const result = normalizeTestRunnerEnv({}); + expect(result).toEqual({}); + }); + + it('should handle null/undefined values', () => { + const input = { + FOO: 'value1', + BAR: null as any, + BAZ: undefined as any, + QUX: 'value4', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value1', + TEST_RUNNER_QUX: 'value4', + }); + }); + + it('should handle empty string values', () => { + const input = { FOO: '', BAR: 'value2' }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: '', + TEST_RUNNER_BAR: 'value2', + }); + }); + + it('should handle special characters in keys', () => { + const input = { + FOO_BAR: 'value1', + 'FOO-BAR': 'value2', + 'FOO.BAR': 'value3', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO_BAR: 'value1', + 'TEST_RUNNER_FOO-BAR': 'value2', + 'TEST_RUNNER_FOO.BAR': 'value3', + }); + }); + + it('should handle special characters in values', () => { + const input = { + FOO: 'value with spaces', + BAR: 'value/with/slashes', + BAZ: 'value=with=equals', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value with spaces', + TEST_RUNNER_BAR: 'value/with/slashes', + TEST_RUNNER_BAZ: 'value=with=equals', + }); + }); + }); + + describe('Real-world Usage Scenarios', () => { + it('should handle USE_DEV_MODE scenario from GitHub issue', () => { + const input = { USE_DEV_MODE: 'YES' }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_USE_DEV_MODE: 'YES', + }); + }); + + it('should handle multiple test configuration variables', () => { + const input = { + USE_DEV_MODE: 'YES', + SKIP_ANIMATIONS: '1', + DEBUG_MODE: 'true', + TEST_TIMEOUT: '30', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_USE_DEV_MODE: 'YES', + TEST_RUNNER_SKIP_ANIMATIONS: '1', + TEST_RUNNER_DEBUG_MODE: 'true', + TEST_RUNNER_TEST_TIMEOUT: '30', + }); + }); + + it('should handle user providing pre-prefixed variables', () => { + const input = { + TEST_RUNNER_USE_DEV_MODE: 'YES', + SKIP_ANIMATIONS: '1', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_USE_DEV_MODE: 'YES', + TEST_RUNNER_SKIP_ANIMATIONS: '1', + }); + }); + + it('should handle boolean-like string values', () => { + const input = { + ENABLED: 'true', + DISABLED: 'false', + YES_FLAG: 'YES', + NO_FLAG: 'NO', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_ENABLED: 'true', + TEST_RUNNER_DISABLED: 'false', + TEST_RUNNER_YES_FLAG: 'YES', + TEST_RUNNER_NO_FLAG: 'NO', + }); + }); + }); + + describe('Prefix Handling Edge Cases', () => { + it('should not double-prefix already prefixed keys', () => { + const input = { TEST_RUNNER_FOO: 'value1' }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value1', + }); + + // Ensure no double prefixing occurred + expect(result).not.toHaveProperty('TEST_RUNNER_TEST_RUNNER_FOO'); + }); + + it('should handle partial prefix matches correctly', () => { + const input = { + TEST_RUN: 'value1', // Should get prefixed (not TEST_RUNNER_) + TEST_RUNNER: 'value2', // Should get prefixed (no underscore) + TEST_RUNNER_FOO: 'value3', // Should not get prefixed + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_TEST_RUN: 'value1', + TEST_RUNNER_TEST_RUNNER: 'value2', + TEST_RUNNER_FOO: 'value3', + }); + }); + + it('should handle case-sensitive prefix detection', () => { + const input = { + test_runner_foo: 'value1', // lowercase - should get prefixed + Test_Runner_Bar: 'value2', // mixed case - should get prefixed + TEST_RUNNER_BAZ: 'value3', // correct case - should not get prefixed + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_test_runner_foo: 'value1', + TEST_RUNNER_Test_Runner_Bar: 'value2', + TEST_RUNNER_BAZ: 'value3', + }); + }); + }); + + describe('Input Validation', () => { + it('should handle undefined input gracefully', () => { + const result = normalizeTestRunnerEnv(undefined as any); + expect(result).toEqual({}); + }); + + it('should handle null input gracefully', () => { + const result = normalizeTestRunnerEnv(null as any); + expect(result).toEqual({}); + }); + + it('should preserve original object (immutability)', () => { + const input = { FOO: 'value1', BAR: 'value2' }; + const originalInput = { ...input }; + const result = normalizeTestRunnerEnv(input); + + // Original input should remain unchanged + expect(input).toEqual(originalInput); + + // Result should be different from input + expect(result).not.toEqual(input); + }); + }); +}); diff --git a/src/utils/__tests__/test-runner-env-integration.test.ts b/src/utils/__tests__/test-runner-env-integration.test.ts new file mode 100644 index 00000000..6b728b2f --- /dev/null +++ b/src/utils/__tests__/test-runner-env-integration.test.ts @@ -0,0 +1,167 @@ +/** + * Integration tests for TEST_RUNNER_ environment variable passing + * + * These tests verify that testRunnerEnv parameters are correctly processed + * and passed through the execution chain. We focus on testing the core + * functionality that matters most: environment variable normalization. + */ + +import { describe, it, expect } from 'vitest'; +import { normalizeTestRunnerEnv } from '../environment.ts'; + +describe('TEST_RUNNER_ Environment Variable Integration', () => { + describe('Core normalization functionality', () => { + it('should normalize environment variables correctly for real scenarios', () => { + // Test the GitHub issue scenario: USE_DEV_MODE -> TEST_RUNNER_USE_DEV_MODE + const gitHubIssueScenario = { USE_DEV_MODE: 'YES' }; + const normalized = normalizeTestRunnerEnv(gitHubIssueScenario); + + expect(normalized).toEqual({ TEST_RUNNER_USE_DEV_MODE: 'YES' }); + }); + + it('should handle mixed prefixed and unprefixed variables', () => { + const mixedVars = { + USE_DEV_MODE: 'YES', // Should be prefixed + TEST_RUNNER_SKIP_ANIMATIONS: '1', // Already prefixed, preserve + DEBUG_MODE: 'true', // Should be prefixed + }; + + const normalized = normalizeTestRunnerEnv(mixedVars); + + expect(normalized).toEqual({ + TEST_RUNNER_USE_DEV_MODE: 'YES', + TEST_RUNNER_SKIP_ANIMATIONS: '1', + TEST_RUNNER_DEBUG_MODE: 'true', + }); + }); + + it('should filter out null and undefined values', () => { + const varsWithNulls = { + VALID_VAR: 'value1', + NULL_VAR: null as any, + UNDEFINED_VAR: undefined as any, + ANOTHER_VALID: 'value2', + }; + + const normalized = normalizeTestRunnerEnv(varsWithNulls); + + expect(normalized).toEqual({ + TEST_RUNNER_VALID_VAR: 'value1', + TEST_RUNNER_ANOTHER_VALID: 'value2', + }); + + // Ensure null/undefined vars are not present + expect(normalized).not.toHaveProperty('TEST_RUNNER_NULL_VAR'); + expect(normalized).not.toHaveProperty('TEST_RUNNER_UNDEFINED_VAR'); + }); + + it('should handle special characters in keys and values', () => { + const specialChars = { + 'VAR_WITH-DASH': 'value-with-dash', + 'VAR.WITH.DOTS': 'value/with/slashes', + VAR_WITH_SPACES: 'value with spaces', + TEST_RUNNER_PRE_EXISTING: 'already=prefixed=value', + }; + + const normalized = normalizeTestRunnerEnv(specialChars); + + expect(normalized).toEqual({ + 'TEST_RUNNER_VAR_WITH-DASH': 'value-with-dash', + 'TEST_RUNNER_VAR.WITH.DOTS': 'value/with/slashes', + TEST_RUNNER_VAR_WITH_SPACES: 'value with spaces', + TEST_RUNNER_PRE_EXISTING: 'already=prefixed=value', + }); + }); + + it('should handle empty values correctly', () => { + const emptyValues = { + EMPTY_STRING: '', + NORMAL_VAR: 'normal_value', + }; + + const normalized = normalizeTestRunnerEnv(emptyValues); + + expect(normalized).toEqual({ + TEST_RUNNER_EMPTY_STRING: '', + TEST_RUNNER_NORMAL_VAR: 'normal_value', + }); + }); + + it('should handle edge case prefix variations', () => { + const prefixEdgeCases = { + TEST_RUN: 'not_quite_prefixed', // Should get prefixed + TEST_RUNNER: 'no_underscore', // Should get prefixed + TEST_RUNNER_CORRECT: 'already_good', // Should stay as-is + test_runner_lowercase: 'lowercase', // Should get prefixed (case sensitive) + }; + + const normalized = normalizeTestRunnerEnv(prefixEdgeCases); + + expect(normalized).toEqual({ + TEST_RUNNER_TEST_RUN: 'not_quite_prefixed', + TEST_RUNNER_TEST_RUNNER: 'no_underscore', + TEST_RUNNER_CORRECT: 'already_good', + TEST_RUNNER_test_runner_lowercase: 'lowercase', + }); + }); + + it('should preserve immutability of input object', () => { + const originalInput = { FOO: 'bar', BAZ: 'qux' }; + const inputCopy = { ...originalInput }; + + const normalized = normalizeTestRunnerEnv(originalInput); + + // Original should be unchanged + expect(originalInput).toEqual(inputCopy); + + // Result should be different + expect(normalized).not.toEqual(originalInput); + expect(normalized).toEqual({ + TEST_RUNNER_FOO: 'bar', + TEST_RUNNER_BAZ: 'qux', + }); + }); + + it('should handle the complete test environment workflow', () => { + // Simulate a comprehensive test environment setup + const fullTestEnv = { + // Core testing flags + USE_DEV_MODE: 'YES', + SKIP_ANIMATIONS: '1', + FAST_MODE: 'true', + + // Already prefixed variables (user might provide these) + TEST_RUNNER_TIMEOUT: '30', + TEST_RUNNER_RETRIES: '3', + + // UI testing specific + UI_TESTING_MODE: 'enabled', + SCREENSHOT_MODE: 'disabled', + + // Performance testing + PERFORMANCE_TESTS: 'false', + MEMORY_TESTING: 'true', + + // Special values + EMPTY_VAR: '', + PATH_VAR: '/usr/local/bin:/usr/bin', + }; + + const normalized = normalizeTestRunnerEnv(fullTestEnv); + + expect(normalized).toEqual({ + TEST_RUNNER_USE_DEV_MODE: 'YES', + TEST_RUNNER_SKIP_ANIMATIONS: '1', + TEST_RUNNER_FAST_MODE: 'true', + TEST_RUNNER_TIMEOUT: '30', + TEST_RUNNER_RETRIES: '3', + TEST_RUNNER_UI_TESTING_MODE: 'enabled', + TEST_RUNNER_SCREENSHOT_MODE: 'disabled', + TEST_RUNNER_PERFORMANCE_TESTS: 'false', + TEST_RUNNER_MEMORY_TESTING: 'true', + TEST_RUNNER_EMPTY_VAR: '', + TEST_RUNNER_PATH_VAR: '/usr/local/bin:/usr/bin', + }); + }); + }); +}); diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index be6aa317..66c769ee 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -19,7 +19,7 @@ import { log } from './logger.ts'; import { XcodePlatform, constructDestinationString } from './xcode.ts'; -import { CommandExecutor } from './command.ts'; +import { CommandExecutor, CommandExecOptions } from './command.ts'; import { ToolResponse, SharedBuildParams, PlatformBuildOptions } from '../types/common.ts'; import { createTextResponse, consolidateContentForClaudeCode } from './validation.ts'; import { @@ -47,6 +47,7 @@ export async function executeXcodeBuildCommand( preferXcodebuild: boolean = false, buildAction: string = 'build', executor: CommandExecutor, + execOpts?: CommandExecOptions, ): Promise { // Collect warnings, errors, and stderr messages from the build output const buildMessages: { type: 'text'; text: string }[] = []; @@ -225,7 +226,7 @@ export async function executeXcodeBuildCommand( } } else { // Use standard xcodebuild - result = await executor(command, platformOptions.logPrefix, true, undefined); + result = await executor(command, platformOptions.logPrefix, true, execOpts); } // Grep warnings and errors from stdout (build output) diff --git a/src/utils/command.ts b/src/utils/command.ts index ce73a33b..27924a20 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -14,10 +14,10 @@ import { existsSync } from 'fs'; import { tmpdir as osTmpdir } from 'os'; import { log } from './logger.ts'; import { FileSystemExecutor } from './FileSystemExecutor.ts'; -import { CommandExecutor, CommandResponse } from './CommandExecutor.ts'; +import { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; // Re-export types for backward compatibility -export { CommandExecutor, CommandResponse } from './CommandExecutor.ts'; +export { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; export { FileSystemExecutor } from './FileSystemExecutor.ts'; /** @@ -26,7 +26,7 @@ export { FileSystemExecutor } from './FileSystemExecutor.ts'; * @param command An array of command and arguments * @param logPrefix Prefix for logging * @param useShell Whether to use shell execution (true) or direct execution (false) - * @param env Additional environment variables + * @param opts Optional execution options (env: environment variables to merge with process.env, cwd: working directory) * @param detached Whether to spawn process without waiting for completion (for streaming/background processes) * @returns Promise resolving to command response with the process */ @@ -34,7 +34,7 @@ async function defaultExecutor( command: string[], logPrefix?: string, useShell: boolean = true, - env?: Record, + opts?: CommandExecOptions, detached: boolean = false, ): Promise { // Properly escape arguments for shell @@ -66,12 +66,10 @@ async function defaultExecutor( const spawnOpts: Parameters[2] = { stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr + env: { ...process.env, ...(opts?.env ?? {}) }, + cwd: opts?.cwd, }; - if (env) { - spawnOpts.env = { ...process.env, ...env }; - } - const childProcess = spawn(executable, args, spawnOpts); let stdout = ''; diff --git a/src/utils/environment.ts b/src/utils/environment.ts index c610dd4b..cdfdd7fd 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -66,3 +66,21 @@ export const defaultEnvironmentDetector = new ProductionEnvironmentDetector(); export function getDefaultEnvironmentDetector(): EnvironmentDetector { return defaultEnvironmentDetector; } + +/** + * Normalizes a set of user-provided environment variables by ensuring they are + * prefixed with TEST_RUNNER_. Variables already prefixed are preserved. + * + * Example: + * normalizeTestRunnerEnv({ FOO: '1', TEST_RUNNER_BAR: '2' }) + * => { TEST_RUNNER_FOO: '1', TEST_RUNNER_BAR: '2' } + */ +export function normalizeTestRunnerEnv(vars: Record): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(vars ?? {})) { + if (value == null) continue; + const prefixedKey = key.startsWith('TEST_RUNNER_') ? key : `TEST_RUNNER_${key}`; + normalized[prefixedKey] = value; + } + return normalized; +} diff --git a/src/utils/execution/index.ts b/src/utils/execution/index.ts index 8a8f8c42..efe9ef4f 100644 --- a/src/utils/execution/index.ts +++ b/src/utils/execution/index.ts @@ -5,5 +5,5 @@ export { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../command.ts'; // Types -export type { CommandExecutor, CommandResponse } from '../CommandExecutor.ts'; +export type { CommandExecutor, CommandResponse, CommandExecOptions } from '../CommandExecutor.ts'; export type { FileSystemExecutor } from '../FileSystemExecutor.ts'; diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index 68051635..cc9333e6 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -21,8 +21,9 @@ import { log } from './logger.ts'; import { XcodePlatform } from './xcode.ts'; import { executeXcodeBuildCommand } from './build/index.ts'; import { createTextResponse, consolidateContentForClaudeCode } from './validation.ts'; +import { normalizeTestRunnerEnv } from './environment.ts'; import { ToolResponse } from '../types/common.ts'; -import { CommandExecutor, getDefaultCommandExecutor } from './command.ts'; +import { CommandExecutor, CommandExecOptions, getDefaultCommandExecutor } from './command.ts'; /** * Type definition for test summary structure from xcresulttool @@ -157,6 +158,7 @@ export async function handleTestLogic( extraArgs?: string[]; preferXcodebuild?: boolean; platform: XcodePlatform; + testRunnerEnv?: Record; }, executor?: CommandExecutor, ): Promise { @@ -173,6 +175,11 @@ export async function handleTestLogic( // Add resultBundlePath to extraArgs const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; + // Prepare execution options with TEST_RUNNER_ environment variables + const execOpts: CommandExecOptions | undefined = params.testRunnerEnv + ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } + : undefined; + // Run the test command const testResult = await executeXcodeBuildCommand( { @@ -190,6 +197,7 @@ export async function handleTestLogic( params.preferXcodebuild, 'test', executor ?? getDefaultCommandExecutor(), + execOpts, ); // Parse xcresult bundle if it exists, regardless of whether tests passed or failed From cf7c1c11dc12043c4bb1499a966b1504131247b1 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 22 Aug 2025 21:45:48 +0100 Subject: [PATCH 2/2] docs: add implementation plan and reloaderoo documentation --- docs/RELOADEROO_FOR_XCODEBUILDMCP.md | 309 ++++++++++++++ docs/TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md | 423 ++++++++++++++++++++ 2 files changed, 732 insertions(+) create mode 100644 docs/RELOADEROO_FOR_XCODEBUILDMCP.md create mode 100644 docs/TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md diff --git a/docs/RELOADEROO_FOR_XCODEBUILDMCP.md b/docs/RELOADEROO_FOR_XCODEBUILDMCP.md new file mode 100644 index 00000000..51ace00e --- /dev/null +++ b/docs/RELOADEROO_FOR_XCODEBUILDMCP.md @@ -0,0 +1,309 @@ +# Reloaderoo Usage Guide for XcodeBuildMCP + +This guide explains how to use Reloaderoo for interacting with XcodeBuildMCP as a CLI to save context window space. + +You can use this guide to prompt your agent, but providing the entire document will give you no actual benefits. You will end up using more context than just using MCP server directly. So it's recommended that you curate this document by removing the example commands that you don't need and just keeping the ones that are right for your project. You'll then want to keep this file within your project workspace and then include it in the context window when you need to interact your agent to use XcodeBuildMCP tools. + +> [!IMPORTANT] +> Please remove this introduction before you prompt your agent with this file or any derrived version of it. + +## Installation + +Reloaderoo is available via npm and can be used with npx for universal compatibility. + +```bash +# Use npx to run reloaderoo +npx reloaderoo@latest --help +``` + +**Example Tool Calls:** + +### Dynamic Tool Discovery + +- **`discover_tools`**: Analyzes a task description to enable relevant tools. + ```bash + npx reloaderoo@latest inspect call-tool discover_tools --params '{"task_description": "I want to build and run my iOS app on a simulator."}' -- node build/index.js + ``` + +### iOS Device Development + +- **`build_device`**: Builds an app for a physical device. + ```bash + npx reloaderoo@latest inspect call-tool build_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` +- **`get_device_app_path`**: Gets the `.app` bundle path for a device build. + ```bash + npx reloaderoo@latest inspect call-tool get_device_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` +- **`install_app_device`**: Installs an app on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool install_app_device --params '{"deviceId": "DEVICE_UDID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js + ``` +- **`launch_app_device`**: Launches an app on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool launch_app_device --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`list_devices`**: Lists connected physical devices. + ```bash + npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js + ``` +- **`stop_app_device`**: Stops an app on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool stop_app_device --params '{"deviceId": "DEVICE_UDID", "processId": 12345}' -- node build/index.js + ``` +- **`test_device`**: Runs tests on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool test_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "deviceId": "DEVICE_UDID"}' -- node build/index.js + ``` + +### iOS Simulator Development + +- **`boot_sim`**: Boots a simulator. + ```bash + npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js + ``` +- **`build_run_sim`**: Builds and runs an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool build_run_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js + ``` +- **`build_sim`**: Builds an app for a simulator. + ```bash + npx reloaderoo@latest inspect call-tool build_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js + ``` +- **`get_sim_app_path`**: Gets the `.app` bundle path for a simulator build. + ```bash + npx reloaderoo@latest inspect call-tool get_sim_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "platform": "iOS Simulator", "simulatorName": "iPhone 16"}' -- node build/index.js + ``` +- **`install_app_sim`**: Installs an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorUuid": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js + ``` +- **`launch_app_logs_sim`**: Launches an app on a simulator with log capture. + ```bash + npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`launch_app_sim`**: Launches an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool launch_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`list_sims`**: Lists available simulators. + ```bash + npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js + ``` +- **`open_sim`**: Opens the Simulator application. + ```bash + npx reloaderoo@latest inspect call-tool open_sim --params '{}' -- node build/index.js + ``` +- **`stop_app_sim`**: Stops an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool stop_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`test_sim`**: Runs tests on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool test_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js + ``` + +### Log Capture & Management + +- **`start_device_log_cap`**: Starts log capture for a physical device. + ```bash + npx reloaderoo@latest inspect call-tool start_device_log_cap --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`start_sim_log_cap`**: Starts log capture for a simulator. + ```bash + npx reloaderoo@latest inspect call-tool start_sim_log_cap --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`stop_device_log_cap`**: Stops log capture for a physical device. + ```bash + npx reloaderoo@latest inspect call-tool stop_device_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js + ``` +- **`stop_sim_log_cap`**: Stops log capture for a simulator. + ```bash + npx reloaderoo@latest inspect call-tool stop_sim_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js + ``` + +### macOS Development + +- **`build_macos`**: Builds a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool build_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` +- **`build_run_macos`**: Builds and runs a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool build_run_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` +- **`get_mac_app_path`**: Gets the `.app` bundle path for a macOS build. + ```bash + npx reloaderoo@latest inspect call-tool get_mac_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` +- **`launch_mac_app`**: Launches a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool launch_mac_app --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js + ``` +- **`stop_mac_app`**: Stops a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool stop_mac_app --params '{"appName": "Calculator"}' -- node build/index.js + ``` +- **`test_macos`**: Runs tests for a macOS project. + ```bash + npx reloaderoo@latest inspect call-tool test_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` + +### Project Discovery + +- **`discover_projs`**: Discovers Xcode projects and workspaces. + ```bash + npx reloaderoo@latest inspect call-tool discover_projs --params '{"workspaceRoot": "/path/to/workspace"}' -- node build/index.js + ``` +- **`get_app_bundle_id`**: Gets an app's bundle identifier. + ```bash + npx reloaderoo@latest inspect call-tool get_app_bundle_id --params '{"appPath": "/path/to/MyApp.app"}' -- node build/index.js + ``` +- **`get_mac_bundle_id`**: Gets a macOS app's bundle identifier. + ```bash + npx reloaderoo@latest inspect call-tool get_mac_bundle_id --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js + ``` +- **`list_schemes`**: Lists schemes in a project or workspace. + ```bash + npx reloaderoo@latest inspect call-tool list_schemes --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js + ``` +- **`show_build_settings`**: Shows build settings for a scheme. + ```bash + npx reloaderoo@latest inspect call-tool show_build_settings --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` + +### Project Scaffolding + +- **`scaffold_ios_project`**: Scaffolds a new iOS project. + ```bash + npx reloaderoo@latest inspect call-tool scaffold_ios_project --params '{"projectName": "MyNewApp", "outputPath": "/path/to/projects"}' -- node build/index.js + ``` +- **`scaffold_macos_project`**: Scaffolds a new macOS project. + ```bash + npx reloaderoo@latest inspect call-tool scaffold_macos_project --params '{"projectName": "MyNewMacApp", "outputPath": "/path/to/projects"}' -- node build/index.js + ``` + +### Project Utilities + +- **`clean`**: Cleans build artifacts. + ```bash + # For a project + npx reloaderoo@latest inspect call-tool clean --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js + # For a workspace + npx reloaderoo@latest inspect call-tool clean --params '{"workspacePath": "/path/to/MyWorkspace.xcworkspace", "scheme": "MyScheme"}' -- node build/index.js + ``` + +### Simulator Management + +- **`reset_sim_location`**: Resets a simulator's location. + ```bash + npx reloaderoo@latest inspect call-tool reset_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js + ``` +- **`set_sim_appearance`**: Sets a simulator's appearance (dark/light mode). + ```bash + npx reloaderoo@latest inspect call-tool set_sim_appearance --params '{"simulatorUuid": "SIMULATOR_UUID", "mode": "dark"}' -- node build/index.js + ``` +- **`set_sim_location`**: Sets a simulator's GPS location. + ```bash + npx reloaderoo@latest inspect call-tool set_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID", "latitude": 37.7749, "longitude": -122.4194}' -- node build/index.js + ``` +- **`sim_statusbar`**: Overrides a simulator's status bar. + ```bash + npx reloaderoo@latest inspect call-tool sim_statusbar --params '{"simulatorUuid": "SIMULATOR_UUID", "dataNetwork": "wifi"}' -- node build/index.js + ``` + +### Swift Package Manager + +- **`swift_package_build`**: Builds a Swift package. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_build --params '{"packagePath": "/path/to/package"}' -- node build/index.js + ``` +- **`swift_package_clean`**: Cleans a Swift package. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_clean --params '{"packagePath": "/path/to/package"}' -- node build/index.js + ``` +- **`swift_package_list`**: Lists running Swift package processes. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_list --params '{}' -- node build/index.js + ``` +- **`swift_package_run`**: Runs a Swift package executable. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_run --params '{"packagePath": "/path/to/package"}' -- node build/index.js + ``` +- **`swift_package_stop`**: Stops a running Swift package process. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_stop --params '{"pid": 12345}' -- node build/index.js + ``` +- **`swift_package_test`**: Tests a Swift package. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_test --params '{"packagePath": "/path/to/package"}' -- node build/index.js + ``` + +### System Doctor + +- **`doctor`**: Runs system diagnostics. + ```bash + npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js + ``` + +### UI Testing & Automation + +- **`button`**: Simulates a hardware button press. + ```bash + npx reloaderoo@latest inspect call-tool button --params '{"simulatorUuid": "SIMULATOR_UUID", "buttonType": "home"}' -- node build/index.js + ``` +- **`describe_ui`**: Gets the UI hierarchy of the current screen. + ```bash + npx reloaderoo@latest inspect call-tool describe_ui --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js + ``` +- **`gesture`**: Performs a pre-defined gesture. + ```bash + npx reloaderoo@latest inspect call-tool gesture --params '{"simulatorUuid": "SIMULATOR_UUID", "preset": "scroll-up"}' -- node build/index.js + ``` +- **`key_press`**: Simulates a key press. + ```bash + npx reloaderoo@latest inspect call-tool key_press --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCode": 40}' -- node build/index.js + ``` +- **`key_sequence`**: Simulates a sequence of key presses. + ```bash + npx reloaderoo@latest inspect call-tool key_sequence --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCodes": [40, 42, 44]}' -- node build/index.js + ``` +- **`long_press`**: Performs a long press at coordinates. + ```bash + npx reloaderoo@latest inspect call-tool long_press --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "duration": 1500}' -- node build/index.js + ``` +- **`screenshot`**: Takes a screenshot. + ```bash + npx reloaderoo@latest inspect call-tool screenshot --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js + ``` +- **`swipe`**: Performs a swipe gesture. + ```bash + npx reloaderoo@latest inspect call-tool swipe --params '{"simulatorUuid": "SIMULATOR_UUID", "x1": 100, "y1": 200, "x2": 100, "y2": 400}' -- node build/index.js + ``` +- **`tap`**: Performs a tap at coordinates. + ```bash + npx reloaderoo@latest inspect call-tool tap --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200}' -- node build/index.js + ``` +- **`touch`**: Simulates a touch down or up event. + ```bash + npx reloaderoo@latest inspect call-tool touch --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "down": true}' -- node build/index.js + ``` +- **`type_text`**: Types text into the focused element. + ```bash + npx reloaderoo@latest inspect call-tool type_text --params '{"simulatorUuid": "SIMULATOR_UUID", "text": "Hello, World!"}' -- node build/index.js + ``` + +### Resources + +- **Read devices resource**: + ```bash + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js + ``` +- **Read simulators resource**: + ```bash + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -- node build/index.js + ``` +- **Read doctor resource**: + ```bash + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://doctor" -- node build/index.js + ``` diff --git a/docs/TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md b/docs/TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..e294f9e8 --- /dev/null +++ b/docs/TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md @@ -0,0 +1,423 @@ +# TEST_RUNNER_ Environment Variables Implementation Plan + +## Problem Statement + +**GitHub Issue**: [#101 - Support TEST_RUNNER_ prefixed env vars](https://github.com/cameroncooke/XcodeBuildMCP/issues/101) + +**Core Need**: Enable conditional test behavior by passing TEST_RUNNER_ prefixed environment variables from MCP client configurations to xcodebuild test processes. This addresses the specific use case of disabling `runsForEachTargetApplicationUIConfiguration` for faster development testing. + +## Background Context + +### xcodebuild Environment Variable Support + +From the xcodebuild man page: +``` +TEST_RUNNER_ Set an environment variable whose name is prefixed + with TEST_RUNNER_ to have that variable passed, with + its prefix stripped, to all test runner processes + launched during a test action. For example, + TEST_RUNNER_Foo=Bar xcodebuild test ... sets the + environment variable Foo=Bar in the test runner's + environment. +``` + +### User Requirements + +Users want to configure their MCP server with TEST_RUNNER_ prefixed environment variables: + +```json +{ + "mcpServers": { + "XcodeBuildMCP": { + "type": "stdio", + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest"], + "env": { + "TEST_RUNNER_USE_DEV_MODE": "YES" + } + } + } +} +``` + +And have tests that can conditionally execute based on these variables: + +```swift +func testFoo() throws { + let useDevMode = ProcessInfo.processInfo.environment["USE_DEV_MODE"] == "YES" + guard useDevMode else { + XCTFail("Test requires USE_DEV_MODE to be true") + return + } + // Test logic here... +} +``` + +## Current Architecture Analysis + +### XcodeBuildMCP Execution Flow +1. All Xcode commands flow through `executeXcodeBuildCommand()` function +2. Generic `CommandExecutor` interface handles all command execution +3. Test tools exist for device/simulator/macOS platforms +4. Zod schemas provide parameter validation and type safety + +### Key Files in Current Architecture +- `src/utils/CommandExecutor.ts` - Command execution interface +- `src/utils/build-utils.ts` - Contains `executeXcodeBuildCommand` +- `src/mcp/tools/device/test_device.ts` - Device testing tool +- `src/mcp/tools/simulator/test_sim.ts` - Simulator testing tool +- `src/mcp/tools/macos/test_macos.ts` - macOS testing tool +- `src/utils/test/index.ts` - Shared test logic for simulator + +## Solution Analysis + +### Design Options Considered + +1. **Automatic Detection** (❌ Rejected) + - Scan `process.env` for TEST_RUNNER_ variables and always pass them + - **Issue**: Security risk of environment variable leakage + - **Issue**: Unpredictable behavior based on server environment + +2. **Explicit Parameter** (✅ Chosen) + - Add `testRunnerEnv` parameter to test tools + - Users explicitly specify which variables to pass + - **Benefits**: Secure, predictable, well-validated + +3. **Hybrid Approach** (🤔 Future Enhancement) + - Both automatic + explicit with explicit overriding + - **Issue**: Adds complexity, deferred for future consideration + +### Expert Analysis Summary + +**RepoPrompt Analysis**: Comprehensive architectural plan emphasizing security, type safety, and integration with existing patterns. + +**Gemini Analysis**: Confirmed explicit approach as optimal, highlighting: +- Security benefits of explicit allow-list approach +- Architectural soundness of extending CommandExecutor +- Recommendation for automatic prefix handling for better UX + +## Recommended Solution: Explicit Parameter with Automatic Prefix Handling + +### Key Design Decisions + +1. **Security-First**: Only explicitly provided variables are passed (no automatic process.env scanning) +2. **User Experience**: Automatic prefix handling - users provide unprefixed keys +3. **Architecture**: Extend execution layer generically for future extensibility +4. **Validation**: Zod schema enforcement with proper type safety + +### User Experience Design + +**Input** (what users specify): +```json +{ + "testRunnerEnv": { + "USE_DEV_MODE": "YES", + "runsForEachTargetApplicationUIConfiguration": "NO" + } +} +``` + +**Output** (what gets passed to xcodebuild): +```bash +TEST_RUNNER_USE_DEV_MODE=YES \ +TEST_RUNNER_runsForEachTargetApplicationUIConfiguration=NO \ +xcodebuild test ... +``` + +## Implementation Plan + +### Phase 0: Test-Driven Development Setup + +**Objective**: Create reproduction test to validate issue and later prove fix works + +#### Tasks: +- [ ] Create test in `example_projects/iOS/MCPTest` that checks for environment variable +- [ ] Run current test tools to demonstrate limitation (test should fail) +- [ ] Document baseline behavior + +**Test Code Example**: +```swift +func testEnvironmentVariablePassthrough() throws { + let useDevMode = ProcessInfo.processInfo.environment["USE_DEV_MODE"] == "YES" + guard useDevMode else { + XCTFail("Test requires USE_DEV_MODE=YES via TEST_RUNNER_USE_DEV_MODE") + return + } + XCTAssertTrue(true, "Environment variable successfully passed through") +} +``` + +### Phase 1: Core Infrastructure Updates + +**Objective**: Extend CommandExecutor and build utilities to support environment variables + +#### 1.1 Update CommandExecutor Interface + +**File**: `src/utils/CommandExecutor.ts` + +**Changes**: +- Add `CommandExecOptions` type for execution options +- Update `CommandExecutor` type signature to accept optional execution options + +```typescript +export type CommandExecOptions = { + cwd?: string; + env?: Record; +}; + +export type CommandExecutor = ( + args: string[], + description?: string, + quiet?: boolean, + opts?: CommandExecOptions +) => Promise; +``` + +#### 1.2 Update Execution Facade + +**File**: `src/utils/execution/index.ts` + +**Changes**: +- Re-export `CommandExecOptions` type + +```typescript +export type { CommandExecutor, CommandResponse, CommandExecOptions } from '../CommandExecutor.js'; +``` + +#### 1.3 Update Default Command Executor + +**File**: `src/utils/command.ts` + +**Changes**: +- Modify `getDefaultCommandExecutor` to merge `opts.env` with `process.env` when spawning + +```typescript +// In the returned function: +const env = { ...process.env, ...(opts?.env ?? {}) }; +// Pass env and opts?.cwd to spawn/exec call +``` + +#### 1.4 Create Environment Variable Utility + +**File**: `src/utils/environment.ts` + +**Changes**: +- Add `normalizeTestRunnerEnv` function + +```typescript +export function normalizeTestRunnerEnv( + userVars?: Record +): Record { + const result: Record = {}; + if (userVars) { + for (const [key, value] of Object.entries(userVars)) { + if (value !== undefined) { + result[`TEST_RUNNER_${key}`] = value; + } + } + } + return result; +} +``` + +#### 1.5 Update executeXcodeBuildCommand + +**File**: `src/utils/build-utils.ts` + +**Changes**: +- Add optional `execOpts?: CommandExecOptions` parameter (6th parameter) +- Pass execution options through to `CommandExecutor` calls + +```typescript +export async function executeXcodeBuildCommand( + build: { /* existing fields */ }, + runtime: { /* existing fields */ }, + preferXcodebuild = false, + action: 'build' | 'test' | 'archive' | 'analyze' | string, + executor: CommandExecutor = getDefaultCommandExecutor(), + execOpts?: CommandExecOptions, // NEW +): Promise +``` + +### Phase 2: Test Tool Integration + +**Objective**: Add `testRunnerEnv` parameter to all test tools and wire through execution + +#### 2.1 Update Device Test Tool + +**File**: `src/mcp/tools/device/test_device.ts` + +**Changes**: +- Add `testRunnerEnv` to Zod schema with validation +- Import and use `normalizeTestRunnerEnv` +- Pass execution options to `executeXcodeBuildCommand` + +**Schema Addition**: +```typescript +testRunnerEnv: z + .record(z.string(), z.string().optional()) + .optional() + .describe('Test runner environment variables (TEST_RUNNER_ prefix added automatically)') +``` + +**Usage**: +```typescript +const execEnv = normalizeTestRunnerEnv(params.testRunnerEnv); +const testResult = await executeXcodeBuildCommand( + { /* build params */ }, + { /* runtime params */ }, + params.preferXcodebuild ?? false, + 'test', + executor, + { env: execEnv } // NEW +); +``` + +#### 2.2 Update macOS Test Tool + +**File**: `src/mcp/tools/macos/test_macos.ts` + +**Changes**: Same pattern as device test tool +- Schema addition for `testRunnerEnv` +- Import `normalizeTestRunnerEnv` +- Pass execution options to `executeXcodeBuildCommand` + +#### 2.3 Update Simulator Test Tool and Logic + +**File**: `src/mcp/tools/simulator/test_sim.ts` + +**Changes**: +- Add `testRunnerEnv` to schema +- Pass through to `handleTestLogic` + +**File**: `src/utils/test/index.ts` + +**Changes**: +- Update `handleTestLogic` signature to accept `testRunnerEnv?: Record` +- Import and use `normalizeTestRunnerEnv` +- Pass execution options to `executeXcodeBuildCommand` + +### Phase 3: Testing and Validation + +**Objective**: Comprehensive testing coverage for new functionality + +#### 3.1 Unit Tests + +**File**: `src/utils/__tests__/environment.test.ts` + +**Tests**: +- Test `normalizeTestRunnerEnv` with various inputs +- Verify prefix addition +- Verify undefined filtering +- Verify empty input handling + +#### 3.2 Integration Tests + +**Files**: Update existing test files for test tools + +**Tests**: +- Verify `testRunnerEnv` parameter is properly validated +- Verify environment variables are passed through `CommandExecutor` +- Mock executor to verify correct env object construction + +#### 3.3 Tool Export Validation + +**Files**: Test files in each tool directory + +**Tests**: +- Verify schema exports include new `testRunnerEnv` field +- Verify parameter typing is correct + +### Phase 4: End-to-End Validation + +**Objective**: Prove the fix works with real xcodebuild scenarios + +#### 4.1 Reproduction Test Validation + +**Tasks**: +- Run reproduction test from Phase 0 with new `testRunnerEnv` parameter +- Verify test passes (proving env var was successfully passed) +- Document the before/after behavior + +#### 4.2 Real-World Scenario Testing + +**Tasks**: +- Test with actual iOS project using `runsForEachTargetApplicationUIConfiguration` +- Verify performance difference when variable is set +- Test with multiple environment variables +- Test edge cases (empty values, special characters) + +## Security Considerations + +### Security Benefits +- **No Environment Leakage**: Only explicit user-provided variables are passed +- **Command Injection Prevention**: Environment variables passed as separate object, not interpolated into command string +- **Input Validation**: Zod schemas prevent malformed inputs +- **Prefix Enforcement**: Only TEST_RUNNER_ prefixed variables can be set + +### Security Best Practices +- Never log environment variable values (keys only for debugging) +- Filter out undefined values to prevent accidental exposure +- Validate all user inputs through Zod schemas +- Document supported TEST_RUNNER_ variables from Apple's documentation + +## Architectural Benefits + +### Clean Integration +- Extends existing `CommandExecutor` pattern generically +- Maintains backward compatibility (all existing calls remain valid) +- Follows established Zod validation patterns +- Consistent API across all test tools + +### Future Extensibility +- `CommandExecOptions` can support additional execution options (timeout, cwd, etc.) +- Pattern can be extended to other tools that need environment variables +- Generic approach allows for non-TEST_RUNNER_ use cases in the future + +## File Modification Summary + +### New Files +- `src/utils/__tests__/environment.test.ts` - Unit tests for environment utilities + +### Modified Files +- `src/utils/CommandExecutor.ts` - Add execution options types +- `src/utils/execution/index.ts` - Re-export new types +- `src/utils/command.ts` - Update default executor to handle env +- `src/utils/environment.ts` - Add `normalizeTestRunnerEnv` utility +- `src/utils/build-utils.ts` - Update `executeXcodeBuildCommand` signature +- `src/mcp/tools/device/test_device.ts` - Add schema and integration +- `src/mcp/tools/macos/test_macos.ts` - Add schema and integration +- `src/mcp/tools/simulator/test_sim.ts` - Add schema and pass-through +- `src/utils/test/index.ts` - Update `handleTestLogic` for simulator path +- Test files for each modified tool - Add validation tests + +## Success Criteria + +1. **Functionality**: Users can pass `testRunnerEnv` parameter to test tools and have variables appear in test runner environment +2. **Security**: No unintended environment variable leakage from server process +3. **Usability**: Users specify unprefixed variable names for better UX +4. **Compatibility**: All existing test tool calls continue to work unchanged +5. **Validation**: Comprehensive test coverage proves the feature works end-to-end + +## Future Enhancements (Out of Scope) + +1. **Configuration Profiles**: Allow users to define common TEST_RUNNER_ variable sets in config files +2. **Variable Discovery**: Help users discover available TEST_RUNNER_ variables +3. **Build Tool Support**: Extend to build tools if Apple adds similar BUILD_RUNNER_ support +4. **Performance Monitoring**: Track impact of environment variable passing on build times + +## Implementation Timeline + +- **Phase 0**: 1-2 hours (reproduction test setup) +- **Phase 1**: 4-6 hours (infrastructure changes) +- **Phase 2**: 3-4 hours (tool integration) +- **Phase 3**: 4-5 hours (testing) +- **Phase 4**: 2-3 hours (validation) + +**Total Estimated Time**: 14-20 hours + +## Conclusion + +This implementation plan provides a secure, user-friendly, and architecturally sound solution for TEST_RUNNER_ environment variable support. The explicit parameter approach with automatic prefix handling balances security concerns with user experience, while the test-driven development approach ensures we can prove the solution works as intended. + +The plan leverages XcodeBuildMCP's existing patterns and provides a foundation for future environment variable needs across the tool ecosystem. \ No newline at end of file