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/scripts/ci/run-ios-e2e-cell.sh b/scripts/ci/run-ios-e2e-cell.sh index 1e73db9..0ee55ff 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. @@ -168,14 +176,21 @@ 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" \ build fi @@ -205,6 +220,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="" @@ -308,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 diff --git a/src/__tests__/arch-selection.test.ts b/src/__tests__/arch-selection.test.ts new file mode 100644 index 0000000..f6bd224 --- /dev/null +++ b/src/__tests__/arch-selection.test.ts @@ -0,0 +1,116 @@ +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'); + }); + + 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'); + }); +}); + +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/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..7f6b250 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. */ +export 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 `[]`. */ +export 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), ]);