diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 94d7473ed..7310b5c42 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -274,6 +274,9 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { udid: options.udid, serial: options.serial, iosSimulatorDeviceSet: options.iosSimulatorDeviceSet, + iosXctestrunFile: options.iosXctestrunFile, + iosXctestDerivedDataPath: options.iosXctestDerivedDataPath, + iosXctestEnvDir: options.iosXctestEnvDir, androidDeviceAllowlist: options.androidDeviceAllowlist, surface: options.surface, activity: options.activity, diff --git a/src/client-types.ts b/src/client-types.ts index f7f22e589..3ec23320d 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -74,6 +74,9 @@ export type AgentDeviceClientConfig = { runtime?: SessionRuntimeHints; cwd?: string; debug?: boolean; + iosXctestrunFile?: string; + iosXctestDerivedDataPath?: string; + iosXctestEnvDir?: string; }; export type AgentDeviceRequestOverrides = Pick< @@ -93,6 +96,9 @@ export type AgentDeviceRequestOverrides = Pick< | 'leaseBackend' | 'cwd' | 'debug' + | 'iosXctestrunFile' + | 'iosXctestDerivedDataPath' + | 'iosXctestEnvDir' >; export type AgentDeviceIdentifiers = { diff --git a/src/commands/cli-grammar/common.ts b/src/commands/cli-grammar/common.ts index 46dadb315..844dba1bd 100644 --- a/src/commands/cli-grammar/common.ts +++ b/src/commands/cli-grammar/common.ts @@ -53,6 +53,9 @@ export function commonInputFromFlags(flags: CliFlags): Record { udid: flags.udid, serial: flags.serial, iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + iosXctestrunFile: flags.iosXctestrunFile, + iosXctestDerivedDataPath: flags.iosXctestDerivedDataPath, + iosXctestEnvDir: flags.iosXctestEnvDir, androidDeviceAllowlist: flags.androidDeviceAllowlist, }); } diff --git a/src/commands/command-input.ts b/src/commands/command-input.ts index b345734a2..f9dbc3041 100644 --- a/src/commands/command-input.ts +++ b/src/commands/command-input.ts @@ -24,6 +24,9 @@ export type CommonCommandInput = Pick< udid?: string; serial?: string; iosSimulatorDeviceSet?: string; + iosXctestrunFile?: string; + iosXctestDerivedDataPath?: string; + iosXctestEnvDir?: string; androidDeviceAllowlist?: string; }; @@ -283,6 +286,9 @@ export function readCommonInput( udid: optionalString(record, 'udid'), serial: optionalString(record, 'serial'), iosSimulatorDeviceSet: optionalString(record, 'iosSimulatorDeviceSet'), + iosXctestrunFile: optionalString(record, 'iosXctestrunFile'), + iosXctestDerivedDataPath: optionalString(record, 'iosXctestDerivedDataPath'), + iosXctestEnvDir: optionalString(record, 'iosXctestEnvDir'), androidDeviceAllowlist: optionalString(record, 'androidDeviceAllowlist'), daemonBaseUrl: optionalString(record, 'daemonBaseUrl'), daemonAuthToken: optionalString(record, 'daemonAuthToken'), @@ -448,6 +454,9 @@ export function commonToClientOptions( udid: input.udid, serial: input.serial, iosSimulatorDeviceSet: input.iosSimulatorDeviceSet, + iosXctestrunFile: input.iosXctestrunFile, + iosXctestDerivedDataPath: input.iosXctestDerivedDataPath, + iosXctestEnvDir: input.iosXctestEnvDir, androidDeviceAllowlist: input.androidDeviceAllowlist, daemonBaseUrl: input.daemonBaseUrl, daemonAuthToken: input.daemonAuthToken, @@ -583,6 +592,18 @@ function commonProperties(): Record { type: 'string', description: 'iOS simulator device-set path used for device resolution.', }, + iosXctestrunFile: { + type: 'string', + description: 'Externally built iOS XCTest runner .xctestrun artifact path.', + }, + iosXctestDerivedDataPath: { + type: 'string', + description: 'Derived data path for external iOS XCTest runner execution.', + }, + iosXctestEnvDir: { + type: 'string', + description: 'Writable directory for iOS XCTest runner env overlays.', + }, androidDeviceAllowlist: { type: 'string', description: 'Android serial allowlist used for device resolution.', diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 3e52812fe..03021e204 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -40,6 +40,9 @@ export type DispatchContext = ScreenshotDispatchFlags & { verbose?: boolean; logPath?: string; traceLogPath?: string; + iosXctestrunFile?: string; + iosXctestDerivedDataPath?: string; + iosXctestEnvDir?: string; snapshotInteractiveOnly?: boolean; snapshotDepth?: number; snapshotScope?: string; diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index cd2b9a2df..468a513e6 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -528,6 +528,9 @@ function runnerOptionsFromContext(context: DispatchContext | undefined): RunnerC logPath: context?.logPath, traceLogPath: context?.traceLogPath, requestId: context?.requestId, + iosXctestrunFile: context?.iosXctestrunFile, + iosXctestDerivedDataPath: context?.iosXctestDerivedDataPath, + iosXctestEnvDir: context?.iosXctestEnvDir, }; } @@ -1024,6 +1027,9 @@ export async function handleReadCommand( logPath: context?.logPath, traceLogPath: context?.traceLogPath, requestId: context?.requestId, + iosXctestrunFile: context?.iosXctestrunFile, + iosXctestDerivedDataPath: context?.iosXctestDerivedDataPath, + iosXctestEnvDir: context?.iosXctestEnvDir, }, ); const text = diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 79112a6d2..a04e928fa 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -51,6 +51,9 @@ export async function dispatchCommand( verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath, + iosXctestrunFile: context?.iosXctestrunFile, + iosXctestDerivedDataPath: context?.iosXctestDerivedDataPath, + iosXctestEnvDir: context?.iosXctestEnvDir, }; const interactor = await getInteractor(device, runnerCtx); emitDiagnostic({ diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index 9966d2cca..2cb313d36 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -16,12 +16,21 @@ export type RunnerContext = { verbose?: boolean; logPath?: string; traceLogPath?: string; + iosXctestrunFile?: string; + iosXctestDerivedDataPath?: string; + iosXctestEnvDir?: string; }; /** Subset of {@link RunnerContext} forwarded to runner command invocations. */ export type RunnerCallOptions = Pick< RunnerContext, - 'verbose' | 'logPath' | 'traceLogPath' | 'requestId' + | 'verbose' + | 'logPath' + | 'traceLogPath' + | 'requestId' + | 'iosXctestrunFile' + | 'iosXctestDerivedDataPath' + | 'iosXctestEnvDir' >; export type { BackMode }; diff --git a/src/daemon/context.ts b/src/daemon/context.ts index ab351a724..a4c577106 100644 --- a/src/daemon/context.ts +++ b/src/daemon/context.ts @@ -29,6 +29,9 @@ export function contextFromFlags( verbose: flags?.verbose, logPath, traceLogPath, + iosXctestrunFile: flags?.iosXctestrunFile, + iosXctestDerivedDataPath: flags?.iosXctestDerivedDataPath, + iosXctestEnvDir: flags?.iosXctestEnvDir, snapshotInteractiveOnly: flags?.snapshotInteractiveOnly, snapshotDepth: flags?.snapshotDepth, snapshotScope: flags?.snapshotScope, diff --git a/src/daemon/handlers/record-trace-ios.ts b/src/daemon/handlers/record-trace-ios.ts index 35cc70b96..37a9619ee 100644 --- a/src/daemon/handlers/record-trace-ios.ts +++ b/src/daemon/handlers/record-trace-ios.ts @@ -43,6 +43,9 @@ export function getIosRunnerOptions( logPath, traceLogPath: session.trace?.outPath, requestId: req.meta?.requestId, + iosXctestrunFile: req.flags?.iosXctestrunFile, + iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, + iosXctestEnvDir: req.flags?.iosXctestEnvDir, }; } diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 22d298e28..3566fe042 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -188,6 +188,9 @@ async function completeOpenCommand(params: { logPath, traceLogPath, requestId: req.meta?.requestId, + iosXctestrunFile: req.flags?.iosXctestrunFile, + iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, + iosXctestEnvDir: req.flags?.iosXctestEnvDir, }; const shouldPrewarmRunnerBeforeOpen = req.flags?.maestro?.prewarmRunnerBeforeOpen === true; let runnerPrewarm: Promise | undefined; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 1bc027171..9d30f903e 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -97,6 +97,9 @@ function buildPrepareIosRunnerOptions( requestId: req.meta?.requestId, buildTimeoutMs, healthTimeoutMs: Math.min(buildTimeoutMs, PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS), + iosXctestrunFile: req.flags?.iosXctestrunFile, + iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, + iosXctestEnvDir: req.flags?.iosXctestEnvDir, }; } diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index e12cb1fb9..4b2b6f54f 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -68,6 +68,9 @@ export async function handleAlertCommand( logPath, traceLogPath: session?.trace?.outPath, requestId: req.meta?.requestId, + iosXctestrunFile: req.flags?.iosXctestrunFile, + iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, + iosXctestEnvDir: req.flags?.iosXctestEnvDir, }; const runAlert: NativeAlertRunner = async (alertAction) => await runIosRunnerCommand( diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 24800a1db..ef172862e 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -392,6 +392,9 @@ async function queryDirectIosSelector( logPath: params.logPath, traceLogPath: session.trace?.outPath, requestId: params.req.meta?.requestId, + iosXctestrunFile: params.req.flags?.iosXctestrunFile, + iosXctestDerivedDataPath: params.req.flags?.iosXctestDerivedDataPath, + iosXctestEnvDir: params.req.flags?.iosXctestEnvDir, }, ); const found = data.found === true; @@ -648,6 +651,9 @@ async function findText( logPath, traceLogPath: session?.trace?.outPath, requestId: req.meta?.requestId, + iosXctestrunFile: req.flags?.iosXctestrunFile, + iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, + iosXctestEnvDir: req.flags?.iosXctestEnvDir, }, )) as { found?: boolean }; return result?.found === true; diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index b7987ac38..6ea6e7a98 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -585,6 +585,9 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv assert.equal(session.xctestrunPath, '/tmp/session-runner.xctestrun'); assert.equal(mockRunCmdBackground.mock.calls.length, 1); assert.equal(mockRunCmdBackground.mock.calls[0]?.[0], 'xcodebuild'); + const xcodebuildArgs = mockRunCmdBackground.mock.calls[0]?.[1]; + assert.ok(Array.isArray(xcodebuildArgs)); + assert.equal(xcodebuildArgs[xcodebuildArgs.indexOf('-derivedDataPath') + 1], '/tmp/derived'); assert.deepEqual(mockPrepareXctestrunWithEnv.mock.calls[0]?.[1], { AGENT_DEVICE_RUNNER_PORT: '8123', }); @@ -734,6 +737,26 @@ test('runner session restarts alive runner when expected xctestrun artifact chan assert.equal(mockRunCmdBackground.mock.calls.length, 2); }); +test('runner session reuses external xctestrun artifact without cache-derived comparison', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-external-artifact-sim' }; + mockEnsureXctestrunArtifact.mockResolvedValueOnce({ + xctestrunPath: '/tmp/aws/AgentDeviceRunner.xctestrun', + derived: '/tmp/aws-derived', + cache: 'external', + artifact: 'valid', + buildMs: 0, + xctestrunPathSource: 'external', + }); + + const session = await ensureRunnerSession(device, {}); + mockResolveRunnerDerivedPath.mockReturnValue('/tmp/internal-cache-derived'); + const reused = await ensureRunnerSession(device, {}); + + assert.equal(reused, session); + assert.equal(mockRunCmdBackground.mock.calls.length, 1); + assert.equal(mockEnsureXctestrunArtifact.mock.calls.length, 1); +}); + test('runner session restarts dead runner without graceful shutdown', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-dead-sim' }; diff --git a/src/platforms/ios/__tests__/runner-xctestrun.test.ts b/src/platforms/ios/__tests__/runner-xctestrun.test.ts index f0a903027..c9e78a6ee 100644 --- a/src/platforms/ios/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/ios/__tests__/runner-xctestrun.test.ts @@ -10,7 +10,9 @@ import { withCommandExecutorOverride } from '../../../utils/exec.ts'; import { __resetRunnerToolchainFingerprintCacheForTests, acquireXcodebuildSimulatorSetRedirect, + ensureXctestrunArtifact, findXctestrun, + markRunnerXctestrunArtifactBadForRun, prepareXctestrunWithEnv, resolveExpectedRunnerCacheMetadata, resolveXcodebuildSimulatorDeviceSetPath, @@ -66,6 +68,14 @@ function makeScopedSimulator(paths: RedirectPaths): DeviceInfo { return { ...iosSimulator, simulatorSetPath: paths.requestedSetPath }; } +function restoreEnvVar(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + async function acquireRedirect( paths: RedirectPaths, options: Partial[1]> = {}, @@ -277,14 +287,6 @@ function writeExecutable(filePath: string, contents: string): void { fs.writeFileSync(filePath, `${contents}\n`, { mode: 0o755 }); } -function restoreEnvVar(name: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[name]; - return; - } - process.env[name] = value; -} - test('prepareXctestrunWithEnv avoids XCTest screen recordings for nested and legacy targets', async () => { await withTempDir('runner-xctestrun-policy-', async (root) => { const xctestrunPath = path.join(root, 'AgentDeviceRunner.xctestrun'); @@ -319,6 +321,55 @@ test('prepareXctestrunWithEnv avoids XCTest screen recordings for nested and leg }); }); +test('prepareXctestrunWithEnv writes env overlays into configured env dir', async () => { + await withTempDir('runner-xctestrun-env-dir-', async (root) => { + const xctestrunPath = path.join(root, 'readonly-artifacts', 'AgentDeviceRunner.xctestrun'); + const envDir = path.join(root, 'writable-env'); + fs.mkdirSync(path.dirname(xctestrunPath), { recursive: true }); + fs.writeFileSync( + xctestrunPath, + JSON.stringify({ + TestConfigurations: [{ TestTargets: [{ TestBundlePath: 'AgentDeviceRunnerUITests' }] }], + }), + ); + + const prepared = await withCommandExecutorOverride( + (cmd, args) => { + if (cmd !== 'plutil') return undefined; + if (args[0] === '-convert' && args[1] === 'json' && args[2] === '-o' && args[3] === '-') { + return Promise.resolve({ + stdout: fs.readFileSync(String(args[4]), 'utf8'), + stderr: '', + exitCode: 0, + }); + } + if (args[0] === '-convert' && args[1] === 'xml1' && args[2] === '-o') { + fs.copyFileSync(String(args[4]), String(args[3])); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ + stdout: '', + stderr: `unexpected plutil args: ${args.join(' ')}`, + exitCode: 1, + }); + }, + () => + prepareXctestrunWithEnv(xctestrunPath, runnerPortEnv, 'aws session', { + iosXctestEnvDir: envDir, + }), + ); + + assert.equal(path.dirname(prepared.xctestrunPath), envDir); + assert.equal(path.dirname(prepared.jsonPath), envDir); + assert.equal( + path.basename(prepared.xctestrunPath), + 'AgentDeviceRunner.env.aws_session.xctestrun', + ); + assert.equal(fs.existsSync(prepared.xctestrunPath), true); + assert.equal(fs.existsSync(prepared.jsonPath), true); + }); +}); + test('prepareXctestrunWithEnv leaves unrelated targets without capture policy', async () => { await withTempDir('runner-xctestrun-policy-', async (root) => { const xctestrunPath = path.join(root, 'AgentDeviceRunner.xctestrun'); @@ -337,6 +388,67 @@ test('prepareXctestrunWithEnv leaves unrelated targets without capture policy', }); }); +test('ensureXctestrunArtifact uses configured external xctestrun artifact', async () => { + await withTempDir('runner-xctestrun-external-', async (root) => { + const xctestrunPath = path.join(root, 'aws', 'AgentDeviceRunner.xctestrun'); + const derivedPath = path.join(root, 'derived'); + fs.mkdirSync(path.dirname(xctestrunPath), { recursive: true }); + fs.writeFileSync(xctestrunPath, '{}'); + + const artifact = await ensureXctestrunArtifact(iosDevice, { + forceRunnerXctestrunRebuild: true, + iosXctestrunFile: xctestrunPath, + iosXctestDerivedDataPath: derivedPath, + }); + + assert.equal(artifact.xctestrunPath, xctestrunPath); + assert.equal(artifact.derived, derivedPath); + assert.equal(artifact.cache, 'external'); + assert.equal(artifact.artifact, 'valid'); + assert.equal(artifact.buildMs, 0); + assert.equal(artifact.xctestrunPathSource, 'external'); + }); +}); + +test('ensureXctestrunArtifact defaults external derived data to writable temp path', async () => { + await withTempDir('runner-xctestrun-external-temp-', async (root) => { + const xctestrunPath = path.join(root, 'aws', 'AgentDeviceRunner.xctestrun'); + fs.mkdirSync(path.dirname(xctestrunPath), { recursive: true }); + fs.writeFileSync(xctestrunPath, '{}'); + + const artifact = await ensureXctestrunArtifact(iosDevice, { + iosXctestrunFile: xctestrunPath, + }); + + const expectedRoot = path.join(os.tmpdir(), 'agent-device-ios-xctest-derived'); + assert.equal(artifact.derived.startsWith(expectedRoot), true); + assert.notEqual(artifact.derived, path.dirname(xctestrunPath)); + }); +}); + +test('markRunnerXctestrunArtifactBadForRun preserves configured external artifacts', async () => { + await withTempDir('runner-xctestrun-external-bad-', async (root) => { + const derivedPath = path.join(root, 'derived'); + const xctestrunPath = path.join(root, 'aws', 'AgentDeviceRunner.xctestrun'); + fs.mkdirSync(derivedPath, { recursive: true }); + fs.mkdirSync(path.dirname(xctestrunPath), { recursive: true }); + fs.writeFileSync(path.join(derivedPath, 'keep.txt'), 'derived'); + fs.writeFileSync(xctestrunPath, 'xctestrun'); + + await markRunnerXctestrunArtifactBadForRun( + { + xctestrunPath, + derived: derivedPath, + cache: 'external', + }, + 'runner health failed', + ); + + assert.equal(fs.existsSync(path.join(derivedPath, 'keep.txt')), true); + assert.equal(fs.existsSync(xctestrunPath), true); + }); +}); + test('resolveXcodebuildSimulatorDeviceSetPath uses XCTestDevices under the user home', () => { assert.equal( resolveXcodebuildSimulatorDeviceSetPath('/tmp/agent-device-home'), diff --git a/src/platforms/ios/interactions.ts b/src/platforms/ios/interactions.ts index 3565da6a8..4939ee45e 100644 --- a/src/platforms/ios/interactions.ts +++ b/src/platforms/ios/interactions.ts @@ -71,6 +71,9 @@ export function iosRunnerOverrides( logPath: ctx.logPath, traceLogPath: ctx.traceLogPath, requestId: ctx.requestId, + iosXctestrunFile: ctx.iosXctestrunFile, + iosXctestDerivedDataPath: ctx.iosXctestDerivedDataPath, + iosXctestEnvDir: ctx.iosXctestEnvDir, }; return { runnerOpts, diff --git a/src/platforms/ios/runner-provider.ts b/src/platforms/ios/runner-provider.ts index d8a7b42c4..277379a68 100644 --- a/src/platforms/ios/runner-provider.ts +++ b/src/platforms/ios/runner-provider.ts @@ -1,9 +1,13 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { DeviceInfo } from '../../utils/device.ts'; import type { RunnerCommand } from './runner-contract.ts'; -import type { RunnerXctestrunArtifactState, RunnerXctestrunCacheKind } from './runner-xctestrun.ts'; +import type { + RunnerXctestrunArtifactState, + RunnerXctestrunCacheKind, + ExternalXctestRunnerOptions, +} from './runner-xctestrun.ts'; -export type AppleRunnerCommandOptions = { +export type AppleRunnerCommandOptions = ExternalXctestRunnerOptions & { verbose?: boolean; logPath?: string; traceLogPath?: string; diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 6f6b2ec29..46a7b51ce 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -157,6 +157,7 @@ async function startRunnerSessionWithLease( xctestrunArtifact.xctestrunPath, { AGENT_DEVICE_RUNNER_PORT: String(port) }, `session-${device.id}-${RUNNER_OWNER_TOKEN}-${port}`, + { iosXctestEnvDir: options.iosXctestEnvDir }, ), ); const simulatorSetRedirect = await measureRunnerStartupStep( @@ -166,38 +167,37 @@ async function startRunnerSessionWithLease( ); let child: ExecBackgroundResult['child'] | undefined; let testPromise: Promise; + const xcodebuildArgs = [ + 'test-without-building', + '-only-testing', + 'AgentDeviceRunnerUITests/RunnerTests/testCommand', + '-parallel-testing-enabled', + 'NO', + '-test-timeouts-enabled', + 'NO', + '-collect-test-diagnostics', + 'never', + resolveRunnerMaxConcurrentDestinationsFlag(device), + '1', + '-destination-timeout', + String(RUNNER_DESTINATION_TIMEOUT_SECONDS), + '-xctestrun', + xctestrunPath, + '-derivedDataPath', + xctestrunArtifact.derived, + '-destination', + resolveRunnerDestination(device), + ]; try { ({ child, wait: testPromise } = await measureRunnerStartupStep( startupTimings, 'launch_xcodebuild', () => - runCmdBackground( - 'xcodebuild', - [ - 'test-without-building', - '-only-testing', - 'AgentDeviceRunnerUITests/RunnerTests/testCommand', - '-parallel-testing-enabled', - 'NO', - '-test-timeouts-enabled', - 'NO', - '-collect-test-diagnostics', - 'never', - resolveRunnerMaxConcurrentDestinationsFlag(device), - '1', - '-destination-timeout', - String(RUNNER_DESTINATION_TIMEOUT_SECONDS), - '-xctestrun', - xctestrunPath, - '-destination', - resolveRunnerDestination(device), - ], - { - allowFailure: true, - env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) }, - detached: true, - }, - ), + runCmdBackground('xcodebuild', xcodebuildArgs, { + allowFailure: true, + env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) }, + detached: true, + }), )); } catch (error) { await simulatorSetRedirect?.release(); @@ -262,18 +262,33 @@ async function resolveReusableRunnerSession( return null; } + const existingArtifact = existing.xctestrunArtifact; + if (existingArtifact?.cache === 'external') { + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_session_reuse', + data: { + deviceId: device.id, + sessionId: existing.sessionId, + ready: existing.ready, + cache: existingArtifact.cache, + }, + }); + return existing; + } + const expectedDerived = resolveRunnerDerivedPath( device, resolveExpectedRunnerCacheMetadata(device), ); - if (existing.xctestrunArtifact?.derived !== expectedDerived) { + if (existingArtifact?.derived !== expectedDerived) { emitDiagnostic({ level: 'debug', phase: 'ios_runner_session_artifact_stale', data: { deviceId: device.id, sessionId: existing.sessionId, - currentDerived: existing.xctestrunArtifact?.derived, + currentDerived: existingArtifact?.derived, expectedDerived, }, }); diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index 7a5c31366..d8ba13810 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -110,7 +110,7 @@ export type RunnerXctestrunCacheMetadata = { artifacts?: RunnerXctestrunCacheArtifacts; }; -export type RunnerXctestrunCacheKind = 'exact' | 'restore-key' | 'miss'; +export type RunnerXctestrunCacheKind = 'exact' | 'restore-key' | 'miss' | 'external'; export type RunnerXctestrunArtifactState = 'valid' | 'rebuilt'; export type RunnerXctestrunArtifact = { @@ -119,10 +119,16 @@ export type RunnerXctestrunArtifact = { cache: RunnerXctestrunCacheKind; artifact: RunnerXctestrunArtifactState; buildMs: number; - xctestrunPathSource: 'manifest' | 'scan' | 'build'; + xctestrunPathSource: 'manifest' | 'scan' | 'build' | 'external'; reason?: string; }; +export type ExternalXctestRunnerOptions = { + iosXctestrunFile?: string; + iosXctestDerivedDataPath?: string; + iosXctestEnvDir?: string; +}; + type RunnerXctestrunCacheArtifacts = { xctestrunPath: string; xctestrunMtimeMs: number; @@ -421,7 +427,12 @@ function resolveRunnerXctestrunCacheLockPath(derived: string): string { export async function ensureXctestrun( device: DeviceInfo, - options: { verbose?: boolean; logPath?: string; traceLogPath?: string; buildTimeoutMs?: number }, + options: { + verbose?: boolean; + logPath?: string; + traceLogPath?: string; + buildTimeoutMs?: number; + } & ExternalXctestRunnerOptions, ): Promise { return (await ensureXctestrunArtifact(device, options)).xctestrunPath; } @@ -434,8 +445,11 @@ export async function ensureXctestrunArtifact( traceLogPath?: string; buildTimeoutMs?: number; forceRunnerXctestrunRebuild?: boolean; - }, + } & ExternalXctestRunnerOptions, ): Promise { + const external = resolveExternalXctestrunArtifact(options); + if (external) return external; + const projectRoot = findProjectRoot(); const expectedCacheMetadata = resolveExpectedRunnerCacheMetadata(device, projectRoot); const derived = resolveRunnerDerivedPath(device, expectedCacheMetadata); @@ -456,6 +470,49 @@ export async function ensureXctestrunArtifact( }); } +function resolveExternalXctestrunArtifact( + options: ExternalXctestRunnerOptions, +): RunnerXctestrunArtifact | null { + const configuredXctestrunPath = options.iosXctestrunFile?.trim(); + if (!configuredXctestrunPath) { + return null; + } + + const xctestrunPath = path.resolve(configuredXctestrunPath); + if (!fs.existsSync(xctestrunPath)) { + throw new AppError('COMMAND_FAILED', 'Configured iOS XCTest runner .xctestrun file not found', { + configKey: 'iosXctestrunFile', + xctestrunPath, + }); + } + + const configuredDerivedPath = options.iosXctestDerivedDataPath?.trim(); + const derived = configuredDerivedPath + ? path.resolve(configuredDerivedPath) + : resolveExternalXctestDerivedDataPath(xctestrunPath); + + emitRunnerXctestrunDecision('reuse', 'external_xctestrun', { + derived, + xctestrunPath, + }); + + return { + xctestrunPath, + derived, + cache: 'external', + artifact: 'valid', + buildMs: 0, + xctestrunPathSource: 'external', + }; +} + +function resolveExternalXctestDerivedDataPath(xctestrunPath: string): string { + const hash = crypto.createHash('sha1'); + hash.update(xctestrunPath); + const suffix = hash.digest('hex').slice(0, 12); + return path.join(os.tmpdir(), 'agent-device-ios-xctest-derived', suffix); +} + async function ensureXctestrunUnderCacheLock(params: { device: DeviceInfo; options: { verbose?: boolean; logPath?: string; traceLogPath?: string; buildTimeoutMs?: number }; @@ -766,9 +823,18 @@ export function writeRunnerCacheMetadata( } export async function markRunnerXctestrunArtifactBadForRun( - artifact: Pick, + artifact: Pick, reason: string, ): Promise { + if (artifact.cache === 'external') { + emitRunnerXctestrunDecision('preserve', 'external_bad_artifact', { + derived: artifact.derived, + xctestrunPath: artifact.xctestrunPath, + reason, + }); + return; + } + badRunnerArtifactsForRun.add(artifact.derived); const releaseCacheLock = await acquireRunnerXctestrunCacheLock(artifact.derived); try { @@ -1149,8 +1215,11 @@ export async function prepareXctestrunWithEnv( xctestrunPath: string, envVars: Record, suffix: string, + options: Pick = {}, ): Promise<{ xctestrunPath: string; jsonPath: string }> { - const dir = path.dirname(xctestrunPath); + const configuredEnvDir = options.iosXctestEnvDir?.trim(); + const dir = configuredEnvDir ? path.resolve(configuredEnvDir) : path.dirname(xctestrunPath); + fs.mkdirSync(dir, { recursive: true }); const safeSuffix = suffix.replace(/[^a-zA-Z0-9._-]/g, '_'); const tmpJsonPath = path.join(dir, `AgentDeviceRunner.env.${safeSuffix}.json`); const tmpXctestrunPath = path.join(dir, `AgentDeviceRunner.env.${safeSuffix}.xctestrun`); @@ -1508,7 +1577,7 @@ async function evaluateExistingXctestrun(options: { } function emitRunnerXctestrunDecision( - action: 'clean' | 'reuse' | 'rebuild' | 'build', + action: 'clean' | 'reuse' | 'rebuild' | 'build' | 'preserve', reason: | 'forced_clean' | 'missing_xctestrun' @@ -1520,7 +1589,9 @@ function emitRunnerXctestrunDecision( | 'reuse_ready' | 'forced_rebuild' | 'bad_artifact' - | 'built_new', + | 'built_new' + | 'external_xctestrun' + | 'external_bad_artifact', data: Record, ): void { emitDiagnostic({ diff --git a/src/utils/__tests__/cli-option-schema.test.ts b/src/utils/__tests__/cli-option-schema.test.ts index 2ac139daa..f784ade5e 100644 --- a/src/utils/__tests__/cli-option-schema.test.ts +++ b/src/utils/__tests__/cli-option-schema.test.ts @@ -32,6 +32,21 @@ test('option schema exposes env defaults and command scoping', () => { assert.ok(androidDeviceAllowlist); assert.deepEqual(androidDeviceAllowlist.env.names, ['AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST']); + const iosXctestrunFile = getOptionSpec('iosXctestrunFile'); + assert.ok(iosXctestrunFile); + assert.deepEqual(iosXctestrunFile.env.names, ['AGENT_DEVICE_IOS_XCTESTRUN_FILE']); + assert.equal(iosXctestrunFile.supportsCommand('open'), true); + + const iosXctestDerivedDataPath = getOptionSpec('iosXctestDerivedDataPath'); + assert.ok(iosXctestDerivedDataPath); + assert.deepEqual(iosXctestDerivedDataPath.env.names, [ + 'AGENT_DEVICE_IOS_XCTEST_DERIVED_DATA_PATH', + ]); + + const iosXctestEnvDir = getOptionSpec('iosXctestEnvDir'); + assert.ok(iosXctestEnvDir); + assert.deepEqual(iosXctestEnvDir.env.names, ['AGENT_DEVICE_IOS_XCTEST_ENV_DIR']); + const snapshotDepth = getOptionSpec('snapshotDepth'); assert.ok(snapshotDepth); assert.equal(snapshotDepth.supportsCommand('snapshot'), true); diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 0052232fa..8050f43ee 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -50,6 +50,9 @@ export type CliFlags = RemoteConfigMetroOptions & udid?: string; serial?: string; iosSimulatorDeviceSet?: string; + iosXctestrunFile?: string; + iosXctestDerivedDataPath?: string; + iosXctestEnvDir?: string; deviceHub?: boolean; androidDeviceAllowlist?: string; session?: string; @@ -517,6 +520,27 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--ios-simulator-device-set ', usageDescription: 'Scope iOS simulator discovery/commands to this simulator device set', }, + { + key: 'iosXctestrunFile', + names: ['--ios-xctestrun-file'], + type: 'string', + usageLabel: '--ios-xctestrun-file ', + usageDescription: 'Use an externally built iOS XCTest runner .xctestrun artifact', + }, + { + key: 'iosXctestDerivedDataPath', + names: ['--ios-xctest-derived-data-path'], + type: 'string', + usageLabel: '--ios-xctest-derived-data-path ', + usageDescription: 'Derived data path for external iOS XCTest runner execution', + }, + { + key: 'iosXctestEnvDir', + names: ['--ios-xctest-env-dir'], + type: 'string', + usageLabel: '--ios-xctest-env-dir ', + usageDescription: 'Writable directory for per-session iOS XCTest runner env overlays', + }, { key: 'deviceHub', names: ['--device-hub'], @@ -1074,6 +1098,9 @@ export const GLOBAL_FLAG_KEYS = new Set([ 'udid', 'serial', 'iosSimulatorDeviceSet', + 'iosXctestrunFile', + 'iosXctestDerivedDataPath', + 'iosXctestEnvDir', 'androidDeviceAllowlist', 'session', 'noRecord',