From f3afbb211749e716942e2d3489e004fe851cbc87 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Thu, 24 Sep 2026 15:06:33 -0500 Subject: [PATCH 1/4] fix(ios): select an arch when exporting coverage from universal binaries llvm-cov cannot read coverage from a universal (multi-arch) Mach-O without -arch, so a simulator build carrying both arm64 and x86_64 slices exported 0% LCOV. This is why consumers building universal simulator binaries (e.g. react-native-firebase) saw an ios-native flag stuck at 0%, while this repo's thin (ONLY_ACTIVE_ARCH=YES) e2e builds were unaffected. ios export/report/summary now: - auto-detect the app binary's slices via 'lipo -archs'; - pass -arch only for fat binaries, choosing the host arch when present else the first slice (thin binaries are unchanged); - accept an override via --arch or config ios.arch. Adds unit tests for the selection logic and documents the flag. --- docs/cli.mdx | 20 +++++- docs/config.mdx | 1 + react-native-coverage.config.js.example | 4 ++ src/__tests__/arch-selection.test.ts | 50 ++++++++++++++ src/cli/index.ts | 15 ++++ src/config.ts | 13 ++++ src/process-ios-native-coverage.ts | 91 +++++++++++++++++++++++++ 7 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/arch-selection.test.ts diff --git a/docs/cli.mdx b/docs/cli.mdx index 6aaef5f..7bc7133 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -10,9 +10,9 @@ rn-coverage android pull [--device ] [--output ] [--retries ] rn-coverage android report [--android-dir ] [--jacoco-xml ] rn-coverage ios pull --device [--output ] -rn-coverage ios export --derived-data [--configuration Debug] [--app-name ] [--output ] -rn-coverage ios report --derived-data [--profdata ] [--output-dir ] -rn-coverage ios summary --derived-data [--profdata ] +rn-coverage ios export --derived-data [--configuration Debug] [--app-name ] [--output ] [--arch ] +rn-coverage ios report --derived-data [--profdata ] [--output-dir ] [--arch ] +rn-coverage ios summary --derived-data [--profdata ] [--arch ] rn-coverage assert [--platform ios|android|all] [--lcov ] [--jacoco-xml ] @@ -46,6 +46,20 @@ Commands that enforce the guard: | `ios report` / `ios summary` | Missing `profdata` (run `ios export` first) | | `assert` | Dedicated post-pipeline check for LCOV and/or Jacoco XML | +## Universal (multi-arch) binaries + +`llvm-cov` cannot read coverage from a **universal** (multi-arch) Mach-O without an +`-arch` selector, so a simulator build that carries both `arm64` and `x86_64` slices +would otherwise export **0%**. `ios export`, `ios report`, and `ios summary` handle this +automatically: + +- **Thin** binary (one slice) → no `-arch` is passed (unchanged behavior). +- **Universal** binary → the host architecture is selected when present, else the first slice. +- Override with `--arch ` (e.g. `--arch arm64`) or `ios.arch` in config. + +Thin simulator builds (Xcode's default `ONLY_ACTIVE_ARCH=YES` for Debug) never needed this; +the selection only kicks in for universal builds. + `rn-coverage assert` is the package-owned replacement for one-off shell presence scripts. Prefer wiring this CLI (exit 2) into consumer CI rather than maintaining a permanent bespoke assert. Matchers and default artifact paths live under `assert.*` in config (see [config.md](./config.md)). diff --git a/docs/config.mdx b/docs/config.mdx index 98cf432..ff3621c 100644 --- a/docs/config.mdx +++ b/docs/config.mdx @@ -11,6 +11,7 @@ Key fields: | `app.iosBundleId` | simctl container lookup | | `app.iosProductName` | App binary / `.app` name | | `ios.frameworkNamePrefixes` | Extra llvm-cov `-object` frameworks | +| `ios.arch` | `llvm-cov -arch` for universal binaries (empty = auto-detect; set e.g. `arm64` to force) | | `android.coverageRelativePath` | On-device `.ec` path under app files | | `android.libraryProjectMatchers` | Fallback Jacoco package substrings for assert | | `android.jacocoReportXml` | Default Jacoco XML path after `android report` | diff --git a/react-native-coverage.config.js.example b/react-native-coverage.config.js.example index fa60520..8306217 100644 --- a/react-native-coverage.config.js.example +++ b/react-native-coverage.config.js.example @@ -14,6 +14,10 @@ module.exports = { ios: { // e.g. ['MyLib'] to include MyLib*.framework as llvm-cov objects frameworkNamePrefixes: [], + // llvm-cov -arch for universal (multi-arch) simulator binaries. + // '' = auto-detect (thin binaries need none; fat picks the host arch); + // set e.g. 'arm64' to force a slice. + arch: '', }, android: { libraryProjectMatchers: [], diff --git a/src/__tests__/arch-selection.test.ts b/src/__tests__/arch-selection.test.ts new file mode 100644 index 0000000..a9f9a4d --- /dev/null +++ b/src/__tests__/arch-selection.test.ts @@ -0,0 +1,50 @@ +import { + chooseLlvmArch, + normalizeHostArch, +} from '../process-ios-native-coverage'; + +describe('normalizeHostArch', () => { + it('maps node x64 to x86_64', () => { + expect(normalizeHostArch('x64')).toBe('x86_64'); + }); + + it('passes arm64 through unchanged', () => { + expect(normalizeHostArch('arm64')).toBe('arm64'); + }); +}); + +describe('chooseLlvmArch', () => { + it('returns undefined for a thin binary (no -arch needed)', () => { + expect(chooseLlvmArch(['arm64'], 'arm64')).toBeUndefined(); + }); + + it('returns undefined when no archs could be detected', () => { + expect(chooseLlvmArch([], 'arm64')).toBeUndefined(); + }); + + it('selects the host arch from a universal binary (Apple Silicon)', () => { + expect(chooseLlvmArch(['x86_64', 'arm64'], 'arm64')).toBe('arm64'); + }); + + it('selects the host arch from a universal binary (Intel)', () => { + expect(chooseLlvmArch(['x86_64', 'arm64'], 'x64')).toBe('x86_64'); + }); + + it('falls back to the first slice when the host arch is absent', () => { + expect(chooseLlvmArch(['x86_64', 'i386'], 'arm64')).toBe('x86_64'); + }); + + it('honors an explicit config/CLI arch over auto-detection', () => { + expect(chooseLlvmArch(['x86_64', 'arm64'], 'arm64', 'x86_64')).toBe( + 'x86_64' + ); + }); + + it('honors an explicit arch even for a thin binary', () => { + expect(chooseLlvmArch(['arm64'], 'arm64', 'x86_64')).toBe('x86_64'); + }); + + it('ignores blank explicit arch and falls back to auto', () => { + expect(chooseLlvmArch(['x86_64', 'arm64'], 'arm64', ' ')).toBe('arm64'); + }); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index 33f3c60..51c8689 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -174,6 +174,10 @@ async function main(): Promise { .option('--configuration ', 'Xcode configuration', 'Debug') .option('--app-name ', 'App product name') .option('--output ', 'LCOV output path', 'coverage/ios/lcov.info') + .option( + '--arch ', + 'llvm-cov -arch for universal binaries (default: auto-detect)' + ) .action(async (opts, cmd) => { const rootOpts = rootOptsFrom(cmd); const config = applyStrictOverride( @@ -186,6 +190,7 @@ async function main(): Promise { configuration: opts.configuration, appName: opts.appName ?? config.app.iosProductName, output: opts.output, + arch: opts.arch, config, }); } catch (error) { @@ -210,6 +215,10 @@ async function main(): Promise { 'HTML output directory', 'coverage/ios/html' ) + .option( + '--arch ', + 'llvm-cov -arch for universal binaries (default: auto-detect)' + ) .action(async (opts, cmd) => { const rootOpts = rootOptsFrom(cmd); const config = applyStrictOverride( @@ -223,6 +232,7 @@ async function main(): Promise { appName: opts.appName, profdata: opts.profdata, outputDir: opts.outputDir, + arch: opts.arch, config, }); } catch (error) { @@ -242,6 +252,10 @@ async function main(): Promise { 'Merged profdata path', 'coverage/ios/profdata' ) + .option( + '--arch ', + 'llvm-cov -arch for universal binaries (default: auto-detect)' + ) .action(async (opts, cmd) => { const rootOpts = rootOptsFrom(cmd); const config = applyStrictOverride( @@ -254,6 +268,7 @@ async function main(): Promise { configuration: opts.configuration, appName: opts.appName, profdata: opts.profdata, + arch: opts.arch, config, }); } catch (error) { diff --git a/src/config.ts b/src/config.ts index 5d498ad..5108ed2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -53,6 +53,18 @@ export type CoverageConfig = { ios: { /** Framework basename prefixes to include as llvm-cov `-object`s. */ frameworkNamePrefixes: string[]; + /** + * Architecture to select for `llvm-cov` (`export`/`show`/`report`) when the + * app binary is a universal (multi-arch) Mach-O — e.g. a simulator build + * containing both `arm64` and `x86_64` slices. `llvm-cov` cannot read + * coverage from a fat binary without `-arch`, which is why universal + * simulator builds otherwise report 0%. + * + * Empty (default) → auto: single-arch (thin) binaries need no selection; + * fat binaries pick the host arch when present, else the first slice. + * Set explicitly (e.g. `'arm64'`) to override the auto choice. + */ + arch: string; }; android: { libraryProjectMatchers: string[]; @@ -92,6 +104,7 @@ export const DEFAULT_COVERAGE_CONFIG: CoverageConfig = { }, ios: { frameworkNamePrefixes: [], + arch: '', }, android: { libraryProjectMatchers: [], diff --git a/src/process-ios-native-coverage.ts b/src/process-ios-native-coverage.ts index 8d7c1cf..e873881 100644 --- a/src/process-ios-native-coverage.ts +++ b/src/process-ios-native-coverage.ts @@ -15,6 +15,11 @@ export type IosExportOptions = { appName: string; output: string; config?: CoverageConfig; + /** + * Architecture to pass to `llvm-cov` for a universal (fat) binary. Overrides + * `config.ios.arch`. Empty/undefined → auto-detect (see {@link resolveLlvmArch}). + */ + arch?: string; /** When true, delete processed `.profraw` files after a successful export. */ deleteProfraw?: boolean; /** @@ -229,6 +234,78 @@ function buildObjectArgs(coverageObjects: string[]): string[] { return args; } +/** Map a Node `process.arch` value to the llvm/Mach-O arch name. */ +export function normalizeHostArch(nodeArch: string): string { + if (nodeArch === 'x64') { + return 'x86_64'; + } + return nodeArch; // 'arm64' (and anything already normalized) passes through +} + +/** + * Read the architecture slices present in a Mach-O via `lipo -archs`. + * Returns `[]` when the file is missing or `lipo` is unavailable (callers then + * fall back to the current, arch-agnostic behavior). + */ +export function detectBinaryArchs(binaryPath: string): string[] { + try { + const out = execFileSync('xcrun', ['lipo', '-archs', binaryPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return out.trim().split(/\s+/).filter(Boolean); + } catch { + return []; + } +} + +/** + * Decide the `-arch` value for `llvm-cov`. + * + * `llvm-cov` cannot read coverage from a universal (multi-arch) binary without + * `-arch`, so a simulator build carrying both `arm64` and `x86_64` slices would + * otherwise export 0%. Selection order: + * 1. explicit `configArch` (from CLI `--arch` or `config.ios.arch`), + * 2. thin binary (≤1 slice) → `undefined` (no `-arch`; preserves prior behavior), + * 3. fat binary → the host arch when present, else the first available slice. + */ +export function chooseLlvmArch( + availableArchs: string[], + hostArch: string = process.arch, + configArch?: string +): string | undefined { + const explicit = configArch?.trim(); + if (explicit) { + return explicit; + } + if (availableArchs.length <= 1) { + return undefined; + } + const host = normalizeHostArch(hostArch); + if (availableArchs.includes(host)) { + return host; + } + return availableArchs[0]; +} + +/** Resolve the `-arch` value for a given app binary, honoring overrides. */ +function resolveLlvmArch( + appBinary: string, + configArch: string, + override?: string +): string | undefined { + const explicit = (override ?? configArch)?.trim(); + if (explicit) { + return explicit; + } + return chooseLlvmArch(detectBinaryArchs(appBinary), process.arch); +} + +/** `['-arch=arm64']` when an arch is selected, otherwise `[]`. */ +function buildArchArgs(arch: string | undefined): string[] { + return arch ? [`-arch=${arch}`] : []; +} + /** * Merge profraw → profdata → LCOV with path rewrite + optional presence assert. */ @@ -272,6 +349,11 @@ export async function exportIosLcov( profdataPath, ]); + const arch = resolveLlvmArch(ctx.appBinary, config.ios.arch, options.arch); + if (arch) { + console.log(`[rn-coverage] llvm-cov selecting -arch=${arch}`); + } + const rawLcovPath = path.join(path.dirname(options.output), 'lcov.raw'); try { const exportArgs = [ @@ -279,6 +361,7 @@ export async function exportIosLcov( 'export', '-instr-profile', profdataPath, + ...buildArchArgs(arch), ...buildObjectArgs(ctx.coverageObjects), '-format=lcov', ]; @@ -325,6 +408,8 @@ export type IosReportOptions = { profdata?: string; outputDir?: string; config?: CoverageConfig; + /** Override `config.ios.arch` for universal binaries (see export). */ + arch?: string; }; /** @@ -360,6 +445,7 @@ export function reportIosHtml(options: IosReportOptions): string { } fs.mkdirSync(outputDir, { recursive: true }); + const arch = resolveLlvmArch(ctx.appBinary, config.ios.arch, options.arch); runOrThrow('xcrun', [ 'llvm-cov', 'show', @@ -367,6 +453,7 @@ export function reportIosHtml(options: IosReportOptions): string { `-output-dir=${outputDir}`, '-instr-profile', profdataPath, + ...buildArchArgs(arch), ...buildObjectArgs(ctx.coverageObjects), ]); @@ -380,6 +467,8 @@ export type IosSummaryOptions = { appName?: string; profdata?: string; config?: CoverageConfig; + /** Override `config.ios.arch` for universal binaries (see export). */ + arch?: string; }; /** @@ -412,11 +501,13 @@ export function summarizeIos(options: IosSummaryOptions): string { throw new Error(`No coverage objects found under ${ctx.productsDir}`); } + const arch = resolveLlvmArch(ctx.appBinary, config.ios.arch, options.arch); const report = runOrThrow('xcrun', [ 'llvm-cov', 'report', '-instr-profile', profdataPath, + ...buildArchArgs(arch), ...buildObjectArgs(ctx.coverageObjects), ]); From d127ee803441a24d9496e46f8e37c2da37a7eee6 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Thu, 24 Sep 2026 15:42:19 -0500 Subject: [PATCH 2/4] test(ci): dogfood arch selection by building the static cell universal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the static e2e cell to ONLY_ACTIVE_ARCH=NO so it produces a universal (arm64 + x86_64) simulator binary, exercising the new llvm-cov -arch selection in 'rn-coverage ios export'. If the arch fix regresses, that cell's export drops to 0% and the strict assert fails it — an in-repo regression guard at no extra CI cost (no new cell). The dynamic cell stays thin (ONLY_ACTIVE_ARCH=YES) to keep the primary cell fast. Adds a lipo-based assertion that the static binary is actually multi-arch so the guard can't silently degrade to thin. --- scripts/ci/run-ios-e2e-cell.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/scripts/ci/run-ios-e2e-cell.sh b/scripts/ci/run-ios-e2e-cell.sh index 1e73db9..e9fe6a5 100755 --- a/scripts/ci/run-ios-e2e-cell.sh +++ b/scripts/ci/run-ios-e2e-cell.sh @@ -121,6 +121,9 @@ if [[ "$CELL" == "dynamic" ]]; then CONFIG_PATH="$APP_DIR/react-native-coverage.config.js" WORKSPACE="$APP_DIR/ios/CoverageDynamic.xcworkspace" SCHEME="CoverageDynamic" + # Thin (active-arch) build keeps the primary cell fast; the llvm-cov -arch + # selection is dogfooded by the static cell's universal build instead. + ONLY_ACTIVE_ARCH_SETTING="ONLY_ACTIVE_ARCH=YES" # Same folder GMA uses via `react-native run-ios --buildFolder build`. DERIVED="$APP_DIR/ios/build" POD_CMD=( @@ -135,6 +138,11 @@ elif [[ "$CELL" == "static" ]]; then WORKSPACE="$APP_DIR/ios/CoverageExample.xcworkspace" SCHEME="CoverageExample" DERIVED="$APP_DIR/ios/build" + # Build a universal (arm64 + x86_64) simulator binary so this cell dogfoods + # the llvm-cov -arch selection in `rn-coverage ios export`. Without that fix a + # fat Mach-O exports 0% LCOV and the strict assert (exit 2) fails this cell — + # i.e. this is the in-repo regression guard for universal-binary coverage. + ONLY_ACTIVE_ARCH_SETTING="ONLY_ACTIVE_ARCH=NO" # Expo ios/ is generated and gitignored. Stale Podfile.lock vs Pods/Local # Podspecs (e.g. ExpoModulesWorklets after an SDK patch) makes `pod install` # fail; retries of the same command cannot recover. @@ -176,6 +184,7 @@ if [[ "${SKIP_BUILD:-0}" != "1" ]]; then -destination "id=${IOS_UDID}" \ -derivedDataPath "$DERIVED" \ CODE_SIGNING_ALLOWED=NO \ + "$ONLY_ACTIVE_ARCH_SETTING" \ build fi @@ -205,6 +214,22 @@ if [[ "$CELL" == "dynamic" ]]; then echo "Dynamic framework OK: $FW" | tee "$LOG_DIR/framework-ok.txt" fi +# Prove the static cell built a universal binary — the whole point of the +# coverage arch-selection dogfood. llvm-cov needs -arch for a fat Mach-O; if a +# toolchain change ever silently reverts this to a thin build the guard would be +# meaningless, so fail loudly. +if [[ "$CELL" == "static" ]]; then + echo "==> Assert universal app binary" + APP_BIN="$APP_PATH/$PRODUCT_NAME" + ARCHS_FOUND="$(xcrun lipo -archs "$APP_BIN" 2>/dev/null | tee "$LOG_DIR/app-archs.txt" || true)" + echo "app binary archs: $ARCHS_FOUND" + if [[ "$(echo "$ARCHS_FOUND" | wc -w | tr -d ' ')" -lt 2 ]]; then + echo "Expected a universal (multi-arch) binary to exercise llvm-cov -arch, got: '$ARCHS_FOUND'" >&2 + exit 1 + fi + echo "Universal binary OK: $ARCHS_FOUND" | tee "$LOG_DIR/app-universal-ok.txt" +fi + METRO_PID="" APPIUM_PID="" SIM_LOG_PID="" From ee130c483a3c3cc96dd9c887c82de13c17341223 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Thu, 24 Sep 2026 15:50:16 -0500 Subject: [PATCH 3/4] test(ios): unit-cover arch detection helpers for patch coverage Export resolveLlvmArch/buildArchArgs and add tests for detectBinaryArchs (mocked lipo), buildArchArgs, and resolveLlvmArch (override/config/auto) so the new arch-selection lines are covered by unit tests, not only by the universal e2e cell. --- src/__tests__/arch-selection.test.ts | 66 ++++++++++++++++++++++++++++ src/process-ios-native-coverage.ts | 4 +- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/__tests__/arch-selection.test.ts b/src/__tests__/arch-selection.test.ts index a9f9a4d..f6bd224 100644 --- a/src/__tests__/arch-selection.test.ts +++ b/src/__tests__/arch-selection.test.ts @@ -1,8 +1,23 @@ +import { execFileSync } from 'node:child_process'; + import { + buildArchArgs, chooseLlvmArch, + detectBinaryArchs, normalizeHostArch, + resolveLlvmArch, } from '../process-ios-native-coverage'; +jest.mock('node:child_process', () => ({ + execFileSync: jest.fn(), +})); + +const mockExec = execFileSync as unknown as jest.Mock; + +beforeEach(() => { + mockExec.mockReset(); +}); + describe('normalizeHostArch', () => { it('maps node x64 to x86_64', () => { expect(normalizeHostArch('x64')).toBe('x86_64'); @@ -48,3 +63,54 @@ describe('chooseLlvmArch', () => { expect(chooseLlvmArch(['x86_64', 'arm64'], 'arm64', ' ')).toBe('arm64'); }); }); + +describe('detectBinaryArchs', () => { + it('parses the arch list from lipo output', () => { + mockExec.mockReturnValue('arm64 x86_64\n'); + expect(detectBinaryArchs('/path/to/App')).toEqual(['arm64', 'x86_64']); + expect(mockExec).toHaveBeenCalledWith( + 'xcrun', + ['lipo', '-archs', '/path/to/App'], + expect.anything() + ); + }); + + it('returns [] when lipo fails (missing file / no lipo)', () => { + mockExec.mockImplementation(() => { + throw new Error('lipo: can’t open input file'); + }); + expect(detectBinaryArchs('/nope')).toEqual([]); + }); +}); + +describe('buildArchArgs', () => { + it('emits no flag when no arch is selected', () => { + expect(buildArchArgs(undefined)).toEqual([]); + }); + + it('emits a single -arch= token when selected', () => { + expect(buildArchArgs('arm64')).toEqual(['-arch=arm64']); + }); +}); + +describe('resolveLlvmArch', () => { + it('returns the CLI override without inspecting the binary', () => { + expect(resolveLlvmArch('/path/App', '', 'x86_64')).toBe('x86_64'); + expect(mockExec).not.toHaveBeenCalled(); + }); + + it('returns the config arch when no override is given', () => { + expect(resolveLlvmArch('/path/App', 'arm64')).toBe('arm64'); + expect(mockExec).not.toHaveBeenCalled(); + }); + + it('auto-returns undefined for a thin detected binary', () => { + mockExec.mockReturnValue('arm64\n'); + expect(resolveLlvmArch('/path/App', '')).toBeUndefined(); + }); + + it('auto-selects a slice for a universal detected binary', () => { + mockExec.mockReturnValue('arm64 x86_64\n'); + expect(['arm64', 'x86_64']).toContain(resolveLlvmArch('/path/App', '')); + }); +}); diff --git a/src/process-ios-native-coverage.ts b/src/process-ios-native-coverage.ts index e873881..7f6b250 100644 --- a/src/process-ios-native-coverage.ts +++ b/src/process-ios-native-coverage.ts @@ -289,7 +289,7 @@ export function chooseLlvmArch( } /** Resolve the `-arch` value for a given app binary, honoring overrides. */ -function resolveLlvmArch( +export function resolveLlvmArch( appBinary: string, configArch: string, override?: string @@ -302,7 +302,7 @@ function resolveLlvmArch( } /** `['-arch=arm64']` when an arch is selected, otherwise `[]`. */ -function buildArchArgs(arch: string | undefined): string[] { +export function buildArchArgs(arch: string | undefined): string[] { return arch ? [`-arch=${arch}`] : []; } From a051ea6a88c2436ce499396e5b3a36acd3cc0812 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Thu, 24 Sep 2026 15:56:26 -0500 Subject: [PATCH 4/4] ci(ios): build for a generic simulator to kill the destination flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app and WDA builds targeted "-destination id=", which intermittently fails on GitHub runners with "Unable to find a device matching the provided destination specifier" — a CoreSimulator availability race, even after the booted UDID is ready. A simulator is only needed to install and run (simctl + Appium), never to build for the iphonesimulator SDK, so build with "generic/platform=iOS Simulator" instead. This is the same device-agnostic build Detox uses in RNFB's e2e; the specific UDID is still used for install/launch. Complements the existing 'xcrun simctl list' availability workaround in ci.yml. --- scripts/ci/run-ios-e2e-cell.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run-ios-e2e-cell.sh b/scripts/ci/run-ios-e2e-cell.sh index e9fe6a5..0ee55ff 100755 --- a/scripts/ci/run-ios-e2e-cell.sh +++ b/scripts/ci/run-ios-e2e-cell.sh @@ -176,12 +176,18 @@ if [[ "${SKIP_BUILD:-0}" != "1" ]]; then retry_logged 3 "$LOG_DIR/pod-install.log" "${POD_CMD[@]}" echo "==> xcodebuild ($CELL)" + # Build device-agnostically. A booted simulator is only needed to install and + # run the app (simctl + Appium, below), never to build for the simulator SDK. + # Targeting a specific "id=" here intermittently fails with "Unable to + # find a device matching the provided destination specifier" on GitHub runners + # (a CoreSimulator availability race). `generic/platform=iOS Simulator` avoids + # the device lookup entirely — the same pattern Detox uses in RNFB's e2e. run_logged "$LOG_DIR/xcodebuild.log" xcodebuild \ -workspace "$WORKSPACE" \ -scheme "$SCHEME" \ -configuration Debug \ -sdk iphonesimulator \ - -destination "id=${IOS_UDID}" \ + -destination "generic/platform=iOS Simulator" \ -derivedDataPath "$DERIVED" \ CODE_SIGNING_ALLOWED=NO \ "$ONLY_ACTIVE_ARCH_SETTING" \ @@ -333,11 +339,14 @@ WDA_PROJECT="$( cd "$ROOT/e2e" node -e "const p=require.resolve('appium-webdriveragent/package.json'); console.log(require('path').join(require('path').dirname(p), 'WebDriverAgent.xcodeproj'))" )" +# Same rationale as the app build: build WDA for the generic simulator so a +# CoreSimulator availability race can't fail the build. The prebuilt Runner.app +# is installed onto the specific booted UDID via simctl just below. run_logged "$LOG_DIR/wda-xcodebuild.log" xcodebuild \ -project "$WDA_PROJECT" \ -scheme WebDriverAgentRunner \ -sdk iphonesimulator \ - -destination "id=${IOS_UDID}" \ + -destination "generic/platform=iOS Simulator" \ -derivedDataPath "$WDA_DERIVED" \ CODE_SIGNING_ALLOWED=NO \ build-for-testing