diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a63daf5f..3ae17fd70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,25 @@ jobs: exit 1 fi + swift-runner-unit-compile: + name: Swift Runner Unit Compile + runs-on: macos-26 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Compile Swift runner unit-test surface + uses: ./.github/actions/setup-apple-replay + with: + derived-path: ${{ github.workspace }}/.tmp/swift-runner-unit-derived + cache-key-prefix: swift-runner-unit + build-command: AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 pnpm build:xcuitest:macos + xcuitest-platform: macos + no-test-di-seams: name: No test-only DI seams runs-on: ubuntu-latest diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj b/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj index 5baf6047f..1ba70d1a1 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj @@ -206,7 +206,6 @@ ALWAYS_SEARCH_USER_PATHS = NO; AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID = com.callstack.agentdevice.runner; AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID = "$(AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID).uitests"; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -273,7 +272,6 @@ ALWAYS_SEARCH_USER_PATHS = NO; AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID = com.callstack.agentdevice.runner; AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID = "$(AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID).uitests"; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -330,8 +328,6 @@ 20EA2EE82F2CFC7C001CF0EF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 2S799L9W4M; @@ -366,8 +362,6 @@ 20EA2EE92F2CFC7C001CF0EF /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 2S799L9W4M; diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AccentColor.colorset/Contents.json b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb8789700..000000000 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index c027ad009..000000000 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "images" : [ - { - "filename" : "logo.jpg", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/logo.jpg b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/logo.jpg deleted file mode 100644 index 1c2317edc..000000000 Binary files a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/logo.jpg and /dev/null differ diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Contents.json b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a..000000000 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/Contents.json b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/Contents.json deleted file mode 100644 index ccc586918..000000000 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "logo.jpg", - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/logo.jpg b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/logo.jpg deleted file mode 100644 index 0507c5e3b..000000000 Binary files a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Logo.imageset/logo.jpg and /dev/null differ diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/Contents.json b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/Contents.json deleted file mode 100644 index 35b43e987..000000000 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "powered-by.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/powered-by.png b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/powered-by.png deleted file mode 100644 index 5377d689d..000000000 Binary files a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/PoweredBy.imageset/powered-by.png and /dev/null differ diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift index bf2c354de..cf7828dd8 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift @@ -208,6 +208,7 @@ extension RunnerTests { } } +#if AGENT_DEVICE_RUNNER_UNIT_TESTS // MARK: - In-bundle unit tests extension RunnerTests { @@ -325,3 +326,4 @@ extension RunnerTests { XCTAssertFalse(labels.contains("Admin settings")) } } +#endif diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 620dca958..075969207 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -130,6 +130,7 @@ extension RunnerTests { return Response(ok: true, data: data) } +#if AGENT_DEVICE_RUNNER_UNIT_TESTS func testGestureResponseIncludesSynthesizedTapFallbackDiagnostics() { let response = gestureResponse( message: "tapped", @@ -180,6 +181,7 @@ extension RunnerTests { ) XCTAssertNil(xctestRecordedFailureResponse(command: tapCommand, response: runnerFatalResponse)) } +#endif func execute(command: Command) throws -> Response { if command.command == .status { diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift index 2c28dd10e..1132e0254 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift @@ -153,6 +153,7 @@ final class RunnerCommandJournal { } } +#if AGENT_DEVICE_RUNNER_UNIT_TESTS extension RunnerTests { func testUptimeBypassesCommandJournal() throws { let command = runnerJournalCommand("uptime", id: "uptime-probe") @@ -439,3 +440,4 @@ extension RunnerTests { return try JSONDecoder().decode(Response.self, from: Data(responseJson.utf8)) } } +#endif diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift index 011c768b6..9ce3b549c 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift @@ -7,10 +7,6 @@ struct FlatSnapshotFilterNode { let valueText: String? let visible: Bool - var hasContent: Bool { - return !label.isEmpty || !identifier.isEmpty || valueText != nil - } - func matchesScope(_ scope: String) -> Bool { let haystack = [label, identifier, valueText ?? ""].joined(separator: "\n") return haystack.localizedCaseInsensitiveContains(scope) @@ -68,6 +64,7 @@ extension RunnerTests { return type } +#if AGENT_DEVICE_RUNNER_UNIT_TESTS func testFlatSnapshotFilterDecisionMatrixCoversOptions() { let visibleContent = FlatSnapshotFilterNode( isRoot: false, @@ -168,4 +165,5 @@ extension RunnerTests { "private AX marks scroll containers as interactive candidates" ) } +#endif } diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift index c44f41ed3..38460b54b 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift @@ -1265,6 +1265,7 @@ extension RunnerTests { return element.exists ? element : nil } +#if AGENT_DEVICE_RUNNER_UNIT_TESTS // Identity in portrait/unknown, 90° per landscape, 180° upside-down. func testNativeSynthesizedPointRotatesByInterfaceOrientation() { let portrait = CGRect(x: 0, y: 0, width: 834, height: 1210) @@ -1333,4 +1334,5 @@ extension RunnerTests { XCTAssertEqual(events.count, 1) XCTAssertEqual(events.first?.vertical, -200) } +#endif } diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift index 085a859d8..8b5ac4df3 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift @@ -62,6 +62,7 @@ func runnerScrollGesturePlan( } } +#if AGENT_DEVICE_RUNNER_UNIT_TESTS extension RunnerTests { // Cross-language parity vectors mirroring src/core/__tests__/scroll-gesture.test.ts. Keep these // in sync with the vitest vectors so the two buildScrollGesturePlan implementations cannot drift. @@ -216,3 +217,4 @@ extension RunnerTests { ) } } +#endif diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift index 9796e2e87..12427228b 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift @@ -273,6 +273,7 @@ extension RunnerTests { } } +#if AGENT_DEVICE_RUNNER_UNIT_TESTS // MARK: - In-bundle unit tests (device-free) extension RunnerTests { @@ -448,3 +449,4 @@ extension RunnerTests { return try! JSONDecoder().decode(Command.self, from: data) } } +#endif diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 13e305ea9..45be79886 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -426,6 +426,7 @@ extension RunnerTests { ) } +#if AGENT_DEVICE_RUNNER_UNIT_TESTS func testSnapshotAccessibilityUnavailableMarksSparseSnapshotRunnerFatal() { currentApp = app currentBundleId = "com.example.app" @@ -468,6 +469,7 @@ extension RunnerTests { XCTAssertTrue(failure.message.contains("\(Self.rawSnapshotMaxNodes) nodes")) XCTAssertEqual(failure.hint, Self.rawSnapshotTooLargeHint) } +#endif private func interactiveRootNode(rect: CGRect) -> SnapshotNode { SnapshotNode( diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 8d9f4f0b7..3cb85eeae 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -304,6 +304,7 @@ extension RunnerTests { } } +#if AGENT_DEVICE_RUNNER_UNIT_TESTS // MARK: - In-bundle unit tests extension RunnerTests { @@ -423,3 +424,4 @@ extension RunnerTests { XCTAssertEqual(payload.snapshotQuality?.reasonCode, "ax-rejected") } } +#endif diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift index b06f5bda6..69d979af8 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift @@ -12,7 +12,6 @@ extension RunnerTests { enum TextEntryTiming { static let focusTimeout: TimeInterval = 0.4 - static let repairReadinessTimeout: TimeInterval = 1.0 static let readinessTimeout: TimeInterval = 2.0 static let hardwareKeyboardFallbackTimeout: TimeInterval = 0.35 static let pollInterval: TimeInterval = 0.02 diff --git a/scripts/build-xcuitest-apple.sh b/scripts/build-xcuitest-apple.sh index c0f2f3233..0da8613a3 100644 --- a/scripts/build-xcuitest-apple.sh +++ b/scripts/build-xcuitest-apple.sh @@ -25,13 +25,13 @@ is_truthy() { resolve_default_destination() { case "$PLATFORM" in ios) - printf '%s\n' 'generic/platform=iOS Simulator' + resolve_simulator_destination 'iOS' 'iPhone' || printf '%s\n' 'generic/platform=iOS Simulator' ;; macos) printf 'platform=macOS,arch=%s\n' "$(uname -m)" ;; tvos) - printf '%s\n' 'generic/platform=tvOS Simulator' + resolve_simulator_destination 'tvOS' 'Apple TV' || printf '%s\n' 'generic/platform=tvOS Simulator' ;; *) echo "Unsupported AGENT_DEVICE_XCUITEST_PLATFORM: $PLATFORM" >&2 @@ -40,6 +40,40 @@ resolve_default_destination() { esac } +resolve_simulator_destination() { + command -v node >/dev/null 2>&1 || return 1 + node -e ' +const { execFileSync } = require("node:child_process"); +const platformName = process.argv[1]; +const deviceNamePattern = new RegExp(process.argv[2]); +const platformNameLower = platformName.toLowerCase(); +try { + const output = execFileSync("xcrun", ["simctl", "list", "devices", "available", "-j"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000, + }); + const parsed = JSON.parse(output); + const devices = Object.entries(parsed.devices ?? {}) + .filter(([runtime]) => runtime.toLowerCase().includes(platformNameLower)) + .flatMap(([, runtimeDevices]) => Array.isArray(runtimeDevices) ? runtimeDevices : []) + .filter( + (device) => + device && + device.isAvailable !== false && + typeof device.udid === "string" && + typeof device.name === "string" && + deviceNamePattern.test(device.name), + ); + const selected = devices.find((device) => device.state === "Booted") ?? devices[0]; + if (!selected) process.exit(1); + console.log(`platform=${platformName} Simulator,id=${selected.udid}`); +} catch { + process.exit(1); +} +' "$1" "$2" +} + resolve_default_derived_path() { case "$PLATFORM" in ios) @@ -93,6 +127,11 @@ if is_truthy "${AGENT_DEVICE_IOS_CLEAN_DERIVED:-}"; then rm -rf "$CLEAN_PATH" fi +SWIFT_FLAGS='$(inherited) -disable-sandbox' +if is_truthy "${AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS:-}"; then + SWIFT_FLAGS="$SWIFT_FLAGS -D AGENT_DEVICE_RUNNER_UNIT_TESTS" +fi + xcodebuild build-for-testing \ -project "$PROJECT_PATH" \ -scheme "$SCHEME" \ @@ -108,7 +147,7 @@ xcodebuild build-for-testing \ -IDEPackageSupportDisableManifestSandbox=1 \ -IDEPackageSupportDisablePluginExecutionSandbox=1 \ ENABLE_USER_SCRIPT_SANDBOXING=NO \ - OTHER_SWIFT_FLAGS='$(inherited) -disable-sandbox' \ + OTHER_SWIFT_FLAGS="$SWIFT_FLAGS" \ $SIGNING_BUILD_SETTINGS node --experimental-strip-types scripts/patch-xcuitest-runner-icon.ts "$DERIVED_PATH" diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs index 13b1eb329..4c1d0d089 100644 --- a/scripts/write-xcuitest-cache-metadata.mjs +++ b/scripts/write-xcuitest-cache-metadata.mjs @@ -19,6 +19,10 @@ const metadataPath = path.join(derivedPath, '.agent-device-runner-cache.json'); const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner'; +function isTruthy(value) { + return ['1', 'true', 'TRUE', 'yes', 'YES', 'on', 'ON'].includes(String(value ?? '')); +} + function readPackageVersion() { try { const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); @@ -196,11 +200,14 @@ function resolveSigningBuildSettings() { } function resolveSandboxBuildArgs() { + const swiftFlags = isTruthy(process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS) + ? '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS' + : '$(inherited) -disable-sandbox'; return [ '-IDEPackageSupportDisableManifestSandbox=1', '-IDEPackageSupportDisablePluginExecutionSandbox=1', 'ENABLE_USER_SCRIPT_SANDBOXING=NO', - 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox', + `OTHER_SWIFT_FLAGS=${swiftFlags}`, ]; } diff --git a/src/__tests__/cli-network.test.ts b/src/__tests__/cli-network.test.ts index bdbef7d7f..76b66f654 100644 --- a/src/__tests__/cli-network.test.ts +++ b/src/__tests__/cli-network.test.ts @@ -109,6 +109,30 @@ test('network dump prints parsed entries and metadata', async () => { assert.match(result.stderr, /best-effort parser/); }); +test('non-json commands opt into generic progress streaming', async () => { + const result = await runCliCapture(['snapshot'], async () => ({ + ok: true, + data: { nodes: [], truncated: false }, + })); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'snapshot'); + assert.equal(result.calls[0]?.meta?.requestProgress, 'command'); +}); + +test('json commands do not opt into progress streaming', async () => { + const result = await runCliCapture(['snapshot', '--json'], async () => ({ + ok: true, + data: { nodes: [], truncated: false }, + })); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'snapshot'); + assert.equal(result.calls[0]?.meta?.requestProgress, undefined); +}); + test('test command prints suite summary and exits non-zero on failures', async () => { const result = await runCliCapture(['test', './suite'], async () => makeReplaySuiteResponse()); diff --git a/src/__tests__/daemon-client-progress.test.ts b/src/__tests__/daemon-client-progress.test.ts index 0a552409d..bc5254570 100644 --- a/src/__tests__/daemon-client-progress.test.ts +++ b/src/__tests__/daemon-client-progress.test.ts @@ -129,6 +129,52 @@ test('readDaemonSocketProgressResponse parses split progress lines before respon } }); +test('readDaemonSocketProgressResponse renders generic command progress', async () => { + const socket = createMockSocket(); + const req: DaemonRequest = { + session: 'default', + command: 'snapshot', + positionals: [], + flags: {}, + token: 'secret', + meta: { requestId: 'req-command-progress', requestProgress: 'command' }, + }; + let stderr = ''; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + try { + (process.stderr as any).write = ((chunk: unknown) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + + const responsePromise = readSocketProgressResponse(socket, req); + socket.emit( + 'data', + `${JSON.stringify({ + type: 'progress', + event: { + type: 'command', + status: 'progress', + message: 'Building Apple runner...', + }, + })}\n`, + ); + socket.emit( + 'data', + `${JSON.stringify({ + type: 'response', + response: { ok: true, data: { via: 'command-progress' } }, + })}\n`, + ); + + assert.deepEqual(await responsePromise, { ok: true, data: { via: 'command-progress' } }); + assert.equal(stderr, 'Building Apple runner...\n'); + } finally { + process.stderr.write = originalStderrWrite; + } +}); + test('readDaemonSocketProgressResponse rewrites live progress and clears it for final result', async () => { const socket = createMockSocket(); const req: DaemonRequest = { diff --git a/src/cli.ts b/src/cli.ts index 89505716a..45454f9ea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -558,13 +558,13 @@ function createCliDaemonTransport(options: { transport: AgentDeviceDaemonTransport; }): AgentDeviceDaemonTransport { const { command, flags, transport } = options; - if (command !== 'test' || flags.json) return transport; + if (flags.json) return transport; return async (req) => await transport({ ...req, meta: { ...req.meta, - requestProgress: 'replay-test', + requestProgress: command === 'test' ? 'replay-test' : 'command', }, }); } diff --git a/src/contracts.ts b/src/contracts.ts index 1b5d808e2..d55702b1c 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -80,7 +80,7 @@ export type DaemonRequestMeta = { materializationId?: string; lockPolicy?: DaemonLockPolicy; lockPlatform?: PlatformSelector; - requestProgress?: 'replay-test'; + requestProgress?: 'replay-test' | 'command'; }; export type DaemonRequest = { diff --git a/src/daemon-client-progress.ts b/src/daemon-client-progress.ts index 26a647027..11481730a 100644 --- a/src/daemon-client-progress.ts +++ b/src/daemon-client-progress.ts @@ -6,7 +6,7 @@ import type { RequestProgressEvent } from './daemon/request-progress.ts'; import { consumeTextLines } from './utils/line-stream.ts'; import { createReplayTestProgressRenderer, - type ReplayTestProgressRenderer, + type ReplayTestProgressRender, } from './cli-test-progress.ts'; import { isDaemonProgressEnvelope, @@ -14,17 +14,29 @@ import { shouldStreamRequestProgress, } from './daemon/request-progress-protocol.ts'; -function createRequestProgressRenderer(req: DaemonRequest): ReplayTestProgressRenderer { - return createReplayTestProgressRenderer({ +type RequestProgressRenderer = { + render(event: RequestProgressEvent): ReplayTestProgressRender | undefined; +}; + +function createRequestProgressRenderer(req: DaemonRequest): RequestProgressRenderer { + const replayProgressRenderer = createReplayTestProgressRenderer({ verbose: Boolean(req.flags?.verbose || req.meta?.debug), liveProgress: shouldRenderLiveProgress(), columns: process.stderr.columns, }); + return { + render(event) { + if (event.type === 'command') { + return { text: event.message, newline: true }; + } + return replayProgressRenderer.render(event); + }, + }; } function writeRequestProgressEvent( event: RequestProgressEvent, - renderer: ReplayTestProgressRenderer, + renderer: RequestProgressRenderer, ): void { const output = renderer.render(event); if (!output) return; diff --git a/src/daemon/__tests__/device-ready.test.ts b/src/daemon/__tests__/device-ready.test.ts index bffec16bc..73ec263ea 100644 --- a/src/daemon/__tests__/device-ready.test.ts +++ b/src/daemon/__tests__/device-ready.test.ts @@ -54,10 +54,13 @@ test('ensureDeviceReady caches successful simulator readiness checks', async () await ensureDeviceReady({ ...device }); expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(1); - expect(mockEnsureBootedSimulator).toHaveBeenCalledWith(device, { - deviceHub: undefined, - focusExisting: undefined, - }); + expect(mockEnsureBootedSimulator).toHaveBeenCalledWith( + device, + expect.objectContaining({ + deviceHub: undefined, + focusExisting: undefined, + }), + ); }); test('ensureDeviceReady focuses cached simulator readiness checks when requested', async () => { @@ -69,7 +72,7 @@ test('ensureDeviceReady focuses cached simulator readiness checks when requested expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2); expect(mockEnsureBootedSimulator).toHaveBeenLastCalledWith( { ...device }, - { deviceHub: true, focusExisting: true }, + expect.objectContaining({ deviceHub: true, focusExisting: true }), ); }); @@ -102,6 +105,16 @@ test('ensureDeviceReady includes simulator set path in the cache key', async () expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2); }); +test('ensureDeviceReady forwards iOS simulator cold boot callback', async () => { + const onColdBootStart = vi.fn(); + await ensureDeviceReady(IOS_SIMULATOR, { onIosSimulatorColdBootStart: onColdBootStart }); + + expect(mockEnsureBootedSimulator).toHaveBeenCalledWith( + IOS_SIMULATOR, + expect.objectContaining({ onColdBootStart }), + ); +}); + test('ensureDeviceReady expires cached readiness checks after the ttl', async () => { await ensureDeviceReady(ANDROID_EMULATOR); vi.setSystemTime(new Date(Date.now() + DEVICE_READY_CACHE_TTL_MS - 1)); diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index f34335a97..d8b69086b 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -77,7 +77,10 @@ test('open returns and creates the session state directory', async () => { const response = await handler(openRequest('session-a', { platform: 'ios' }, 'req-open-state')); expect(response.ok).toBe(true); - expect(mockEnsureDeviceReady.mock.calls[0]?.[1]).toEqual({ deviceHub: false }); + expect(mockEnsureDeviceReady.mock.calls[0]?.[1]).toEqual({ + deviceHub: false, + onIosSimulatorColdBootStart: undefined, + }); if (response.ok) { expect(response.data?.session).toBe('session-a'); expect(response.data?.sessionStateDir).toEqual(expect.stringContaining('session-a')); diff --git a/src/daemon/apple-runner-options.ts b/src/daemon/apple-runner-options.ts new file mode 100644 index 000000000..601a73c53 --- /dev/null +++ b/src/daemon/apple-runner-options.ts @@ -0,0 +1,92 @@ +import { isDeepLinkTarget } from '../core/open-target.ts'; +import type { SessionSurface } from '../core/session-surface.ts'; +import type { AppleRunnerLifecycleOptions } from '../platforms/ios/runner-provider.ts'; +import { prewarmIosRunnerCache } from '../platforms/ios/runner-client.ts'; +import type { DeviceInfo } from '../utils/device.ts'; +import { contextFromFlags } from './context.ts'; +import type { DaemonRequest } from './types.ts'; + +export type AppleRunnerRequestOptions = Pick< + AppleRunnerLifecycleOptions, + | 'verbose' + | 'logPath' + | 'traceLogPath' + | 'requestId' + | 'runnerLeaseContext' + | 'iosXctestrunFile' + | 'iosXctestDerivedDataPath' + | 'iosXctestEnvDir' +>; + +export function buildAppleRunnerRequestOptions(params: { + req: Pick; + logPath?: string; + traceLogPath?: string; +}): AppleRunnerRequestOptions { + const { req, logPath, traceLogPath } = params; + return { + verbose: req.flags?.verbose, + logPath, + traceLogPath, + requestId: req.meta?.requestId, + iosXctestrunFile: req.flags?.iosXctestrunFile, + iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, + iosXctestEnvDir: req.flags?.iosXctestEnvDir, + }; +} + +export function buildAppleRunnerSessionOptions(params: { + req: Pick; + logPath: string; + appBundleId?: string; + traceLogPath?: string; +}): AppleRunnerRequestOptions { + const { req, logPath, appBundleId, traceLogPath } = params; + return { + ...buildAppleRunnerRequestOptions({ req, logPath, traceLogPath }), + runnerLeaseContext: contextFromFlags( + logPath, + req.flags, + appBundleId, + traceLogPath, + req.meta?.requestId, + req.meta, + ).runnerLeaseContext, + }; +} + +export function createIosRunnerCachePrewarmOnColdBoot(params: { + req: Pick; + logPath: string; + device: DeviceInfo; + traceLogPath?: string; + enabled: boolean; +}): ((device: DeviceInfo) => void) | undefined { + const { req, logPath, device, traceLogPath, enabled } = params; + if (!enabled || device.platform !== 'ios' || device.kind !== 'simulator') { + return undefined; + } + return (bootingDevice) => + prewarmIosRunnerCache( + bootingDevice, + buildAppleRunnerRequestOptions({ req, logPath, traceLogPath }), + ); +} + +export function createIosRunnerCacheColdBootPrewarmForOpen(params: { + req: Pick; + logPath: string; + device: DeviceInfo; + surface: SessionSurface; + openTarget: string | undefined; + traceLogPath?: string; +}): ((device: DeviceInfo) => void) | undefined { + const { req, logPath, device, surface, openTarget, traceLogPath } = params; + return createIosRunnerCachePrewarmOnColdBoot({ + req, + logPath, + device, + traceLogPath, + enabled: surface === 'app' && Boolean(openTarget) && !isDeepLinkTarget(openTarget ?? ''), + }); +} diff --git a/src/daemon/device-ready.ts b/src/daemon/device-ready.ts index 0f94e018d..03c28316e 100644 --- a/src/daemon/device-ready.ts +++ b/src/daemon/device-ready.ts @@ -17,6 +17,7 @@ const readyCache = new Map(); export type DeviceReadyOptions = { deviceHub?: boolean; focusExisting?: boolean; + onIosSimulatorColdBootStart?: (device: DeviceInfo) => void; }; export async function ensureDeviceReady( @@ -38,6 +39,7 @@ export async function ensureDeviceReady( await ensureBootedSimulator(device, { deviceHub: options.deviceHub, focusExisting: options.focusExisting, + onColdBootStart: options.onIosSimulatorColdBootStart, }); markDeviceReady(cacheKey); return; diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 26dfeb6d0..4e64950c5 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -13,6 +13,7 @@ test('boot rejects --headless outside Android directly', async () => { flags: { platform: 'ios', headless: true }, }, sessionName: 'default', + logPath: '/tmp/daemon.log', sessionStore: makeSessionStore('agent-device-session-state-'), }); @@ -34,6 +35,7 @@ test('appstate returns missing-session error for explicit session flag', async ( flags: { platform: 'ios', session: 'named' }, }, sessionName: 'named', + logPath: '/tmp/daemon.log', sessionStore: makeSessionStore('agent-device-session-state-'), }); @@ -55,6 +57,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () flags: { platform: 'web' }, }, sessionName: 'default', + logPath: '/tmp/daemon.log', sessionStore: makeSessionStore('agent-device-session-state-'), }); diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 11dd2af68..4172668ad 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -22,6 +22,7 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => { connectMs: 3, healthCheckMs: 3, })), + prewarmIosRunnerCache: vi.fn(), prewarmIosRunnerSession: vi.fn(), stopIosRunnerSession: vi.fn(async () => {}), }; @@ -101,6 +102,7 @@ import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts'; import { prepareIosRunner, + prewarmIosRunnerCache, prewarmIosRunnerSession, stopIosRunnerSession, } from '../../../platforms/ios/runner-client.ts'; @@ -125,6 +127,7 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp); const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp); const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); +const mockPrewarmIosRunnerCache = vi.mocked(prewarmIosRunnerCache); const mockPrepareIosRunner = vi.mocked(prepareIosRunner); const mockStopIosRunner = vi.mocked(stopIosRunnerSession); const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); @@ -157,6 +160,7 @@ beforeEach(() => { mockClearRuntimeHints.mockReset(); mockClearRuntimeHints.mockResolvedValue(undefined); mockPrewarmIosRunnerSession.mockReset(); + mockPrewarmIosRunnerCache.mockReset(); mockPrepareIosRunner.mockReset(); mockPrepareIosRunner.mockResolvedValue({ runner: { currentUptimeMs: 42 }, @@ -889,7 +893,19 @@ test('boot prefers explicit device selector over active session device', async ( expect(response).toBeTruthy(); expect(response?.ok).toBe(true); - expect(mockEnsureDeviceReady).toHaveBeenCalledWith(expect.objectContaining({ id: 'sim-2' })); + expect(mockEnsureDeviceReady).toHaveBeenCalledWith( + expect.objectContaining({ id: 'sim-2' }), + expect.any(Object), + ); + const onColdBootStart = mockEnsureDeviceReady.mock.calls[0]?.[1]?.onIosSimulatorColdBootStart; + expect(onColdBootStart).toBeTypeOf('function'); + onColdBootStart?.(selectedDevice); + expect(mockPrewarmIosRunnerCache).toHaveBeenCalledWith( + selectedDevice, + expect.objectContaining({ + logPath: expect.stringMatching(/daemon\.log$/), + }), + ); if (response && response.ok) { expect(response.data?.platform).toBe('ios'); expect(response.data?.id).toBe('sim-2'); @@ -2174,7 +2190,10 @@ test('open custom URL on existing iOS simulator session preserves app bundle id expect(response).toBeTruthy(); expect(response?.ok).toBe(true); - expect(mockEnsureDeviceReady.mock.calls[0]?.[1]).toEqual({ deviceHub: false }); + expect(mockEnsureDeviceReady.mock.calls[0]?.[1]).toEqual({ + deviceHub: false, + onIosSimulatorColdBootStart: undefined, + }); const updated = sessionStore.get(sessionName); expect(updated?.appBundleId).toBe('com.example.app'); expect(updated?.appName).toBe('myapp://item/42'); @@ -2226,6 +2245,49 @@ test('open custom URL on fresh iOS simulator session infers app bundle id from U expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); }); +test('open iOS simulator app prewarms runner cache during cold boot', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-cold-boot-cache-prewarm'; + const device: SessionState['device'] = { + platform: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: false, + }; + mockResolveTargetDevice.mockResolvedValue(device); + mockResolveIosApp.mockResolvedValueOnce('com.example.app'); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['Demo'], + flags: { platform: 'ios', udid: 'sim-1' }, + meta: { requestId: 'open-request' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + const onColdBootStart = mockEnsureDeviceReady.mock.calls[0]?.[1]?.onIosSimulatorColdBootStart; + expect(onColdBootStart).toBeTypeOf('function'); + onColdBootStart?.(device); + expect(mockPrewarmIosRunnerCache).toHaveBeenCalledWith( + device, + expect.objectContaining({ + logPath: expect.stringMatching(/daemon\.log$/), + requestId: 'open-request', + }), + ); + expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); +}); + test('open iOS app session prewarms runner session when app bundle id is known', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; diff --git a/src/daemon/handlers/record-trace-ios.ts b/src/daemon/handlers/record-trace-ios.ts index e37b632d4..af1e3811a 100644 --- a/src/daemon/handlers/record-trace-ios.ts +++ b/src/daemon/handlers/record-trace-ios.ts @@ -3,6 +3,7 @@ import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../../platforms/ios/runner-client.ts'; import { formatRecordTraceError } from '../record-trace-errors.ts'; +import { buildAppleRunnerRequestOptions } from '../apple-runner-options.ts'; import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; import { errorResponse } from './response.ts'; @@ -38,15 +39,11 @@ export function getIosRunnerOptions( logPath: string | undefined, session: SessionState, ) { - return { - verbose: req.flags?.verbose, + return buildAppleRunnerRequestOptions({ + req, logPath, traceLogPath: session.trace?.outPath, - requestId: req.meta?.requestId, - iosXctestrunFile: req.flags?.iosXctestrunFile, - iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, - iosXctestEnvDir: req.flags?.iosXctestEnvDir, - }; + }); } function resolveIosRecordingTrimStartMs( diff --git a/src/daemon/handlers/session-open-prepare.ts b/src/daemon/handlers/session-open-prepare.ts index 52d9f8146..fcb6e828b 100644 --- a/src/daemon/handlers/session-open-prepare.ts +++ b/src/daemon/handlers/session-open-prepare.ts @@ -98,6 +98,8 @@ export function validatePreResolvedOpenRequest(params: { return null; } +export type IosSimulatorColdBootStartHandler = (device: DeviceInfo) => void; + export async function prepareOpenCommandDetails(params: { req: DaemonRequest; sessionName: string; @@ -106,10 +108,21 @@ export async function prepareOpenCommandDetails(params: { surface: SessionSurface; openTarget: string | undefined; existingSession?: SessionState; + onIosSimulatorColdBootStart?: IosSimulatorColdBootStartHandler; }): Promise { - const { req, sessionName, sessionStore, device, surface, openTarget, existingSession } = params; + const { + req, + sessionName, + sessionStore, + device, + surface, + openTarget, + existingSession, + onIosSimulatorColdBootStart, + } = params; await ensureDeviceReady(device, { deviceHub: req.flags?.deviceHub === true, + onIosSimulatorColdBootStart, }); const { appBundleId, appName } = await resolvePreparedOpenIdentity({ device, diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index ed50b116d..276a46f1f 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -7,6 +7,10 @@ import { prewarmIosRunnerSession, stopIosRunnerSession, } from '../../platforms/ios/runner-client.ts'; +import { + buildAppleRunnerSessionOptions, + createIosRunnerCacheColdBootPrewarmForOpen, +} from '../apple-runner-options.ts'; import { applyRuntimeHintsToApp } from '../runtime-hints.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse, SessionRuntimeHints, SessionState } from '../types.ts'; @@ -184,23 +188,12 @@ async function completeOpenCommand(params: { timing.runtimeHintsDurationMs = Math.max(0, Date.now() - runtimeHintsStartedAtMs); const shouldPrewarmIosRunner = device.platform === 'ios' && surface === 'app' && openPositionals.length > 0; - const runnerPrewarmOptions = { - verbose: req.flags?.verbose, + const runnerPrewarmOptions = buildAppleRunnerSessionOptions({ + req, logPath, + appBundleId: sessionAppBundleId, traceLogPath, - requestId: req.meta?.requestId, - runnerLeaseContext: contextFromFlags( - logPath, - req.flags, - sessionAppBundleId, - traceLogPath, - req.meta?.requestId, - req.meta, - ).runnerLeaseContext, - iosXctestrunFile: req.flags?.iosXctestrunFile, - iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, - iosXctestEnvDir: req.flags?.iosXctestEnvDir, - }; + }); const shouldPrewarmRunnerBeforeOpen = req.flags?.maestro?.prewarmRunnerBeforeOpen === true; let runnerPrewarm: Promise | undefined; if (shouldPrewarmIosRunner && sessionAppBundleId) { @@ -372,6 +365,7 @@ async function prepareOpenDispatchSession(params: { return { type: 'session', session: sessionStore.get(sessionName) ?? provisionalSession }; } +// fallow-ignore-next-line complexity export async function handleOpenCommand(params: { req: DaemonRequest; sessionName: string; @@ -419,6 +413,14 @@ export async function handleOpenCommand(params: { surface: surfaceResult, openTarget, existingSession: session, + onIosSimulatorColdBootStart: createIosRunnerCacheColdBootPrewarmForOpen({ + req, + logPath, + device, + surface: surfaceResult, + openTarget, + traceLogPath: session.trace?.outPath, + }), }); if (details.type === 'response') { return details.response; @@ -510,6 +512,13 @@ export async function handleOpenCommand(params: { device, surface: surfaceResult, openTarget, + onIosSimulatorColdBootStart: createIosRunnerCacheColdBootPrewarmForOpen({ + req, + logPath, + device, + surface: surfaceResult, + openTarget, + }), }); if (details.type === 'response') { return details.response; diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index a763ab9c8..8379c1da8 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -4,6 +4,7 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { shutdownDeviceTarget } from '../target-shutdown.ts'; +import { createIosRunnerCachePrewarmOnColdBoot } from '../apple-runner-options.ts'; import { hasExplicitSessionFlag, requireSessionOrExplicitSelector, @@ -136,9 +137,10 @@ async function handleAppStateCommand(params: { export async function handleSessionStateCommands(params: { req: DaemonRequest; sessionName: string; + logPath: string; sessionStore: SessionStore; }): Promise { - const { req, sessionName, sessionStore } = params; + const { req, sessionName, logPath, sessionStore } = params; if (req.command === 'boot') { const session = sessionStore.get(sessionName); @@ -234,7 +236,14 @@ export async function handleSessionStateCommands(params: { } else { const shouldEnsureReady = device.platform !== 'android' || device.booted !== true; if (shouldEnsureReady) { - await ensureDeviceReady(device); + await ensureDeviceReady(device, { + onIosSimulatorColdBootStart: createIosRunnerCachePrewarmOnColdBoot({ + req, + logPath, + device, + enabled: true, + }), + }); } } diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 26000c6a1..a6103ed17 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -11,6 +11,7 @@ import { isApplePlatform } from '../../utils/device.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; +import { buildAppleRunnerRequestOptions } from '../apple-runner-options.ts'; import { handleInstallFromSourceCommand, handleReleaseMaterializedPathsCommand, @@ -90,17 +91,15 @@ function buildPrepareIosRunnerOptions( ): Parameters[1] { const buildTimeoutMs = readPrepareIosRunnerBuildTimeoutMs(req); return { - verbose: req.flags?.verbose, - logPath, - traceLogPath: session?.trace?.outPath, + ...buildAppleRunnerRequestOptions({ + req, + logPath, + traceLogPath: session?.trace?.outPath, + }), cleanStaleBundles: true, startupTimeoutMs: resolvePrepareIosRunnerStartupTimeoutMs(req.flags?.timeoutMs), - 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, }; } @@ -272,6 +271,7 @@ export async function handleSessionCommands(params: { return await handleSessionStateCommands({ req, sessionName, + logPath, sessionStore, }); } diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index c849f06dc..eda00cf24 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -11,6 +11,7 @@ import { handleAndroidAlert } from '../../platforms/android/alert.ts'; import { AppError } from '../../utils/errors.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; +import { buildAppleRunnerRequestOptions } from '../apple-runner-options.ts'; import { recordIfSession } from './snapshot-session.ts'; import { parseTimeout } from '../../utils/parse-timeout.ts'; import { errorResponse, requireCommandSupported } from './response.ts'; @@ -61,15 +62,11 @@ export async function handleAlertCommand( return await handleNativeAlertCommand(params, action, runAlert); } - const runnerOptions = { - verbose: req.flags?.verbose, + const runnerOptions = buildAppleRunnerRequestOptions({ + req, 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( device, diff --git a/src/daemon/request-progress-protocol.ts b/src/daemon/request-progress-protocol.ts index cf580da78..5af4998cd 100644 --- a/src/daemon/request-progress-protocol.ts +++ b/src/daemon/request-progress-protocol.ts @@ -12,7 +12,7 @@ export type DaemonResponseEnvelope = { }; export function shouldStreamRequestProgress(req: Pick): boolean { - return req.meta?.requestProgress === 'replay-test'; + return req.meta?.requestProgress === 'replay-test' || req.meta?.requestProgress === 'command'; } export function isDaemonProgressEnvelope(value: unknown): value is DaemonProgressEnvelope { diff --git a/src/daemon/request-progress.ts b/src/daemon/request-progress.ts index 90c950eec..88aed6d81 100644 --- a/src/daemon/request-progress.ts +++ b/src/daemon/request-progress.ts @@ -32,7 +32,16 @@ export type ReplayTestProgressEvent = { deviceId?: string; }; -export type RequestProgressEvent = ReplayTestSuiteProgressEvent | ReplayTestProgressEvent; +export type CommandProgressEvent = { + type: 'command'; + status: 'progress'; + message: string; +}; + +export type RequestProgressEvent = + | ReplayTestSuiteProgressEvent + | ReplayTestProgressEvent + | CommandProgressEvent; export type RequestProgressSink = (event: RequestProgressEvent) => void; export type ReplayTestActionProgressContext = Omit< ReplayTestProgressEvent, diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 6712fdb1f..592c6b129 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -10,6 +10,7 @@ import { noActiveSessionError, requireCommandSupported } from './handlers/respon import type { SnapshotNode } from '../utils/snapshot.ts'; import { findNodeByLabel } from '../utils/snapshot-processing.ts'; import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; +import { buildAppleRunnerRequestOptions } from './apple-runner-options.ts'; import { createDaemonRuntimePolicy } from './runtime-policy.ts'; import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; import { contextFromFlags } from './context.ts'; @@ -200,17 +201,11 @@ function buildAppleRunnerFindTextOptions( params: SelectorRuntimeDeviceParams, target: AppleRunnerFindTextTarget, ) { - const flags = params.req.flags ?? {}; - const meta = params.req.meta ?? {}; - return { - verbose: flags.verbose, + return buildAppleRunnerRequestOptions({ + req: params.req, logPath: params.logPath, traceLogPath: target.traceLogPath, - requestId: meta.requestId, - iosXctestrunFile: flags.iosXctestrunFile, - iosXctestDerivedDataPath: flags.iosXctestDerivedDataPath, - iosXctestEnvDir: flags.iosXctestEnvDir, - }; + }); } async function findTextInWaitSnapshot( diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index cdbd2fc0d..f04050699 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -3,6 +3,7 @@ import type { WaitParsed } from '../core/wait-positionals.ts'; import { AppError, asAppError, normalizeError } from '../utils/errors.ts'; import type { SnapshotNode } from '../utils/snapshot.ts'; import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; +import { buildAppleRunnerRequestOptions } from './apple-runner-options.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { errorResponse, requireCommandSupported } from './handlers/response.ts'; import { resolveSessionDevice, withSessionlessRunnerCleanup } from './handlers/snapshot-session.ts'; @@ -356,15 +357,11 @@ async function queryDirectIosSelector( selectorValue: selector.value, appBundleId: session.appBundleId, }, - { - verbose: Boolean(params.req.flags?.verbose), + buildAppleRunnerRequestOptions({ + req: params.req, 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; const node = readDirectIosSelectorNode(data); diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 12b563c0e..65febcbad 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -556,6 +556,24 @@ test('ensureBootedSimulator opens Simulator after cold boot by default', async ( ); }); +test('ensureBootedSimulator runs cold boot callback only before cold boot', async () => { + const onColdBootStart = vi.fn(); + mockRunCmdResponses({ + 'xcrun simctl list devices -j': simulatorStateSequence('Shutdown', 'Booted'), + 'xcrun simctl boot sim-1': OK_RESULT, + 'xcrun simctl bootstatus sim-1 -b': OK_RESULT, + 'open -a Simulator': OK_RESULT, + }); + + await ensureBootedSimulator(IOS_TEST_SIMULATOR, { + focusExisting: true, + onColdBootStart, + }); + + assert.equal(onColdBootStart.mock.calls.length, 1); + assert.deepEqual(onColdBootStart.mock.calls[0], [IOS_TEST_SIMULATOR]); +}); + test('openIosSimulatorApp opens Simulator by default', async () => { mockRunCmdResponses({ 'open -a Simulator': OK_RESULT, @@ -607,6 +625,18 @@ test('ensureBootedSimulator opens Simulator when already booted by default', asy ); }); +test('ensureBootedSimulator skips cold boot callback when already booted', async () => { + const onColdBootStart = vi.fn(); + mockRunCmdResponses({ + 'xcrun simctl list devices -j': simulatorListDevicesResult('Booted'), + 'open -a Simulator': OK_RESULT, + }); + + await ensureBootedSimulator(IOS_TEST_SIMULATOR, { focusExisting: true, onColdBootStart }); + + assert.equal(onColdBootStart.mock.calls.length, 0); +}); + test('ensureBootedSimulator opens Device Hub without activation when already booted and opted in', async () => { mockRunCmdResponses({ 'xcrun simctl list devices -j': simulatorListDevicesResult('Booted'), diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index 9fc2f0c07..998d4780f 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -30,6 +30,10 @@ vi.mock('../runner-macos-products.ts', async () => { }); import type { DeviceInfo } from '../../../utils/device.ts'; +import { + type RequestProgressEvent, + withRequestProgressSink, +} from '../../../daemon/request-progress.ts'; import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts'; import { AppError } from '../../../utils/errors.ts'; import { isReadOnlyRunnerCommand } from '../runner-command-traits.ts'; @@ -507,6 +511,25 @@ test('resolveRunnerSandboxBuildArgs disables nested Xcode and Swift sandboxing', ]); }); +test('resolveRunnerSandboxBuildArgs includes Swift runner unit tests only when requested', () => { + const previous = process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS; + try { + process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS = '1'; + assert.deepEqual(resolveRunnerSandboxBuildArgs(), [ + '-IDEPackageSupportDisableManifestSandbox=1', + '-IDEPackageSupportDisablePluginExecutionSandbox=1', + 'ENABLE_USER_SCRIPT_SANDBOXING=NO', + 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS', + ]); + } finally { + if (previous === undefined) { + delete process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS; + } else { + process.env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS = previous; + } + } +}); + test('resolveRunnerBundleBuildSettings returns default bundle identifiers', () => { assert.deepEqual(resolveRunnerBundleBuildSettings({}), [ 'AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=com.callstack.agentdevice.runner', @@ -886,15 +909,19 @@ test('resolveRunnerDerivedPath keys default cache by runner metadata', () => { target: 'desktop', buildDestinationFamily: 'macos', }); - const staleVersionPath = resolveRunnerDerivedPath(iosSimulator, { + const unitTestPath = resolveRunnerDerivedPath(iosSimulator, { ...metadata, - packageVersion: '0.0.0-stale', + runnerSandboxBuildArgs: metadata.runnerSandboxBuildArgs.map((arg) => + arg.startsWith('OTHER_SWIFT_FLAGS=') + ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS' + : arg, + ), }); assert.match(iosPath, /\/ios-runner\/derived\/ios-simulator\/cache-[a-f0-9]{16}$/); assert.match(tvPath, /\/ios-runner\/derived\/tvos-simulator\/cache-[a-f0-9]{16}$/); assert.match(macPath, /\/ios-runner\/derived\/macos\/cache-[a-f0-9]{16}$/); - assert.notEqual(iosPath, staleVersionPath); + assert.notEqual(iosPath, unitTestPath); }); test('resolveRunnerDerivedPath reuses cache path for identical runner source fingerprints', async () => { @@ -1006,7 +1033,11 @@ test('ensureXctestrun rebuilds foreign artifacts when metadata does not match', }); const metadataPath = resolveRunnerCacheMetadataPath(derivedPath); const staleMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); - staleMetadata.packageVersion = '0.0.0-stale'; + staleMetadata.runnerSandboxBuildArgs = staleMetadata.runnerSandboxBuildArgs.map((arg: string) => + arg.startsWith('OTHER_SWIFT_FLAGS=') + ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS' + : arg, + ); fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2)); withRunnerDerivedPathEnv(derivedPath); @@ -1192,13 +1223,18 @@ test('ensureXctestrun falls back to scan when cache manifest is stale', async () assert.deepEqual(mockRepairMacOsRunnerProductsIfNeeded.mock.calls[0]?.[1], [newerProductPath]); }); -test('ensureXctestrun rebuilds cached runner when metadata package version mismatches', async () => { +test('ensureXctestrun rebuilds cached runner when Swift build flags mismatch', async () => { const projectRoot = repoRoot; const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun(); const metadataPath = resolveRunnerCacheMetadataPath(derivedPath); + const expectedMetadata = resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot); const staleMetadata = { - ...resolveExpectedRunnerCacheMetadata(macOsDevice, repoRoot), - packageVersion: '0.0.0-stale', + ...expectedMetadata, + runnerSandboxBuildArgs: expectedMetadata.runnerSandboxBuildArgs.map((arg) => + arg.startsWith('OTHER_SWIFT_FLAGS=') + ? 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS' + : arg, + ), }; fs.writeFileSync(metadataPath, JSON.stringify(staleMetadata, null, 2)); @@ -1258,6 +1294,43 @@ test('ensureXctestrunArtifact passes sandbox-disabling settings to xcodebuild', assert.equal(args.includes('OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox'), true); }); +test('ensureXctestrunArtifact emits build progress on cache miss', async () => { + const projectRoot = repoRoot; + const tmpDir = await makeProjectTmpDir(); + const derivedPath = path.join(tmpDir, 'custom-derived'); + const rebuiltXctestrunPath = path.join(derivedPath, 'Build', 'Products', 'rebuilt.xctestrun'); + const events: RequestProgressEvent[] = []; + + withRunnerDerivedPathEnv(derivedPath); + + mockRunCmdStreaming.mockImplementationOnce(async () => { + await fs.promises.mkdir(path.join(derivedPath, 'Build', 'Products', 'Runner.app'), { + recursive: true, + }); + writeXctestrunFixture(rebuiltXctestrunPath, { + projectRoot, + productRelativePaths: ['Runner.app'], + }); + }); + + const result = await withRequestProgressSink( + (event) => events.push(event), + async () => + await ensureXctestrunArtifact(iosSimulator, { + forceRunnerXctestrunRebuild: true, + }), + ); + + assert.equal(result.xctestrunPath, rebuiltXctestrunPath); + assert.deepEqual(events, [ + { + type: 'command', + status: 'progress', + message: 'Building Apple runner...', + }, + ]); +}); + test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', async () => { const projectRoot = repoRoot; const tmpDir = await makeProjectTmpDir(); diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index 14e6ff3eb..091fc8d29 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -5,6 +5,10 @@ import os from 'node:os'; import path from 'node:path'; import { beforeEach, test, vi } from 'vitest'; import { IOS_DEVICE, IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts'; +import { + type RequestProgressEvent, + withRequestProgressSink, +} from '../../../daemon/request-progress.ts'; import { AppError } from '../../../utils/errors.ts'; import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts'; import type { RunnerSession } from '../runner-session-types.ts'; @@ -611,6 +615,56 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv }); }); +test('runner session emits XCTest startup progress only after a runner rebuild', async () => { + const rebuiltDevice = { ...IOS_SIMULATOR, id: 'runner-session-rebuilt-progress-sim' }; + const rebuiltEvents: RequestProgressEvent[] = []; + + await withRequestProgressSink( + (event) => rebuiltEvents.push(event), + async () => { + await ensureRunnerSession(rebuiltDevice, {}); + }, + ); + + assert.deepEqual(rebuiltEvents, [ + { + type: 'command', + status: 'progress', + message: 'Starting XCTest runner...', + }, + ]); + + await abortAllIosRunnerSessions(); + vi.clearAllMocks(); + mockEnsureXctestrunArtifact.mockResolvedValue({ + xctestrunPath: '/tmp/cached-runner.xctestrun', + derived: '/tmp/derived', + cache: 'hit', + artifact: 'valid', + buildMs: 0, + xctestrunPathSource: 'manifest', + }); + mockGetFreePort.mockResolvedValue(8123); + mockPrepareXctestrunWithEnv.mockResolvedValue({ + xctestrunPath: '/tmp/session-runner.xctestrun', + jsonPath: '/tmp/session-runner.json', + }); + mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({ release: mockRedirectRelease }); + mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); + mockWaitForRunner.mockResolvedValue(runnerResponse({ uptimeMs: 1 })); + + const cachedDevice = { ...IOS_SIMULATOR, id: 'runner-session-cached-progress-sim' }; + const cachedEvents: RequestProgressEvent[] = []; + await withRequestProgressSink( + (event) => cachedEvents.push(event), + async () => { + await ensureRunnerSession(cachedDevice, {}); + }, + ); + + assert.deepEqual(cachedEvents, []); +}); + test('runner session startup diagnostics include logical lease context', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-lease-context-sim' }; diff --git a/src/platforms/ios/__tests__/runner-xctestrun.test.ts b/src/platforms/ios/__tests__/runner-xctestrun.test.ts index c9e78a6ee..b0c8394f8 100644 --- a/src/platforms/ios/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/ios/__tests__/runner-xctestrun.test.ts @@ -15,6 +15,7 @@ import { markRunnerXctestrunArtifactBadForRun, prepareXctestrunWithEnv, resolveExpectedRunnerCacheMetadata, + resolveRunnerDerivedPath, resolveXcodebuildSimulatorDeviceSetPath, scoreXctestrunCandidate, } from '../runner-xctestrun.ts'; @@ -283,6 +284,33 @@ test('setup metadata script matches expected iOS simulator cache metadata', asyn }); }, 15_000); +test('runner cache key ignores package version but honors toolchain and SDK changes', () => { + const metadata = resolveExpectedRunnerCacheMetadata(iosSimulator); + const basePath = resolveRunnerDerivedPath(iosSimulator, metadata); + + assert.equal( + resolveRunnerDerivedPath(iosSimulator, { + ...metadata, + packageVersion: `${metadata.packageVersion}-next`, + }), + basePath, + ); + assert.notEqual( + resolveRunnerDerivedPath(iosSimulator, { + ...metadata, + xcodeBuildVersion: `${metadata.xcodeBuildVersion}-other`, + }), + basePath, + ); + assert.notEqual( + resolveRunnerDerivedPath(iosSimulator, { + ...metadata, + sdkBuildVersion: `${metadata.sdkBuildVersion}-other`, + }), + basePath, + ); +}); + function writeExecutable(filePath: string, contents: string): void { fs.writeFileSync(filePath, `${contents}\n`, { mode: 0o755 }); } diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index dfb323d39..b5039ac66 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -15,6 +15,7 @@ import { type AppleRunnerCommandOptions, type AppleRunnerProvider, } from './runner-provider.ts'; +import { ensureXctestrunArtifact } from './runner-xctestrun.ts'; import { executeRunnerCommand, prepareLocalIosRunner, @@ -59,20 +60,37 @@ export async function runIosRunnerCommand( return provider.runCommand(device, runnerCommand, options); } -type PrewarmIosRunnerSessionOptions = RunnerSessionOptions & { +type PrewarmIosRunnerOptions = RunnerSessionOptions & { propagateError?: boolean; }; +export function prewarmIosRunnerCache( + device: DeviceInfo, + options: PrewarmIosRunnerOptions = {}, +): Promise | undefined { + if (device.platform !== 'ios') { + return undefined; + } + return runBestEffortIosRunnerPrewarm({ + device, + options, + failurePhase: 'ios_runner_cache_prewarm_failed', + task: async (runnerOptions) => { + await ensureXctestrunArtifact(device, runnerOptions); + }, + }); +} + export function prewarmIosRunnerSession( device: DeviceInfo, - options: PrewarmIosRunnerSessionOptions = {}, + options: PrewarmIosRunnerOptions = {}, ): Promise | undefined { if (device.platform !== 'ios') { return undefined; } - const { propagateError = false, ...runnerOptions } = options; - const provider = resolveAppleRunnerRuntime(device, runnerOptions); - if (!provider.prewarm) { + const provider = resolveAppleRunnerRuntime(device, options); + const prewarmRunner = provider.prewarm; + if (!prewarmRunner) { emitDiagnostic({ level: 'debug', phase: 'ios_runner_session_prewarm_unavailable', @@ -80,22 +98,37 @@ export function prewarmIosRunnerSession( }); return undefined; } - const prewarm = provider - .prewarm(device, runnerOptions) - .then(() => {}) - .catch((error: unknown) => { - emitDiagnostic({ - level: 'warn', - phase: 'ios_runner_session_prewarm_failed', - data: { - deviceId: device.id, - error: error instanceof Error ? error.message : String(error), - }, - }); - if (propagateError) { - throw error; - } + return runBestEffortIosRunnerPrewarm({ + device, + options, + failurePhase: 'ios_runner_session_prewarm_failed', + task: async (taskOptions) => { + await prewarmRunner(device, taskOptions); + }, + }); +} + +function runBestEffortIosRunnerPrewarm(params: { + device: DeviceInfo; + options: PrewarmIosRunnerOptions; + failurePhase: 'ios_runner_cache_prewarm_failed' | 'ios_runner_session_prewarm_failed'; + task: (options: RunnerSessionOptions) => Promise; +}): Promise { + const { device, options, failurePhase, task } = params; + const { propagateError = false, ...runnerOptions } = options; + const prewarm = task(runnerOptions).catch((error: unknown) => { + emitDiagnostic({ + level: 'warn', + phase: failurePhase, + data: { + deviceId: device.id, + error: error instanceof Error ? error.message : String(error), + }, }); + if (propagateError) { + throw error; + } + }); void prewarm; return prewarm; } diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 516af68d4..ecf54ad72 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -5,6 +5,7 @@ import { Deadline } from '../../utils/retry.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { RunnerLogicalLeaseContext } from '../../core/runner-lease-context.ts'; import type { AppleRunnerLifecycleOptions } from './runner-provider.ts'; +import { emitRequestProgress } from '../../daemon/request-progress.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; import { runAppleToolCommand, runXcrun } from './tool-provider.ts'; @@ -192,6 +193,13 @@ async function startRunnerSessionWithLease( resolveRunnerDestination(device), ]; try { + if (xctestrunArtifact.buildMs > 0) { + emitRequestProgress({ + type: 'command', + status: 'progress', + message: 'Starting XCTest runner...', + }); + } ({ child, wait: testPromise } = await measureRunnerStartupStep( startupTimings, 'launch_xcodebuild', diff --git a/src/platforms/ios/runner-xctestrun.ts b/src/platforms/ios/runner-xctestrun.ts index e23c8fe60..9562737c1 100644 --- a/src/platforms/ios/runner-xctestrun.ts +++ b/src/platforms/ios/runner-xctestrun.ts @@ -11,6 +11,7 @@ import { isEnvTruthy } from '../../utils/retry.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { DefinedEnvMap as EnvMap } from '../../utils/env-map.ts'; import { withKeyedLock } from '../../utils/keyed-lock.ts'; +import { emitRequestProgress } from '../../daemon/request-progress.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { findProjectRoot, readVersion } from '../../utils/version.ts'; import { resolveRunnerBuildFailureHint } from './runner-contract.ts'; @@ -56,8 +57,10 @@ const RUNNER_SANDBOX_BUILD_ARGS = [ '-IDEPackageSupportDisableManifestSandbox=1', '-IDEPackageSupportDisablePluginExecutionSandbox=1', 'ENABLE_USER_SCRIPT_SANDBOXING=NO', - 'OTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox', ] as const; +const RUNNER_RUNTIME_SWIFT_FLAGS = '$(inherited) -disable-sandbox'; +const RUNNER_UNIT_TEST_SWIFT_FLAGS = + '$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_UNIT_TESTS'; const runnerXctestrunBuildLocks = new Map>(); const badRunnerArtifactsForRun = new Set(); @@ -629,6 +632,11 @@ async function buildXctestrunArtifact(params: { } const buildStartedAt = Date.now(); + emitRequestProgress({ + type: 'command', + status: 'progress', + message: 'Building Apple runner...', + }); await buildRunnerXctestrun(device, projectPath, derived, options); const buildMs = Math.max(0, Date.now() - buildStartedAt); @@ -893,8 +901,8 @@ function evaluateRunnerCacheMetadata( function comparableRunnerCacheMetadata( metadata: RunnerXctestrunCacheMetadata, -): RunnerXctestrunCacheMetadata { - const { artifacts: _artifacts, ...comparable } = metadata; +): Omit { + const { artifacts: _artifacts, packageVersion: _packageVersion, ...comparable } = metadata; return comparable; } @@ -1503,7 +1511,16 @@ export function resolveRunnerPerformanceBuildSettings(): string[] { } export function resolveRunnerSandboxBuildArgs(): string[] { - return [...RUNNER_SANDBOX_BUILD_ARGS]; + return [ + ...RUNNER_SANDBOX_BUILD_ARGS, + `OTHER_SWIFT_FLAGS=${resolveRunnerSwiftFlags(process.env)}`, + ]; +} + +function resolveRunnerSwiftFlags(env: NodeJS.ProcessEnv): string { + return isEnvTruthy(env.AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS) + ? RUNNER_UNIT_TEST_SWIFT_FLAGS + : RUNNER_RUNTIME_SWIFT_FLAGS; } function shouldCleanDerived(): boolean { diff --git a/src/platforms/ios/simulator.ts b/src/platforms/ios/simulator.ts index d4990f59e..06ecbd956 100644 --- a/src/platforms/ios/simulator.ts +++ b/src/platforms/ios/simulator.ts @@ -22,6 +22,7 @@ type OpenIosSimulatorAppOptions = { type EnsureBootedSimulatorOptions = { deviceHub?: boolean; focusExisting?: boolean; + onColdBootStart?: (device: DeviceInfo) => void; }; export function requireSimulatorDevice(device: DeviceInfo, command: string): void { @@ -58,6 +59,7 @@ export async function ensureBootedSimulator( } return; } + options.onColdBootStart?.(device); const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS); let bootResult: