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/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/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-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 {