Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ rn-coverage android pull [--device <serial>] [--output <dir>] [--retries <n>]
rn-coverage android report [--android-dir <path>] [--jacoco-xml <path>]

rn-coverage ios pull --device <udid> [--output <dir>]
rn-coverage ios export --derived-data <path> [--configuration Debug] [--app-name <name>] [--output <path>]
rn-coverage ios report --derived-data <path> [--profdata <path>] [--output-dir <path>]
rn-coverage ios summary --derived-data <path> [--profdata <path>]
rn-coverage ios export --derived-data <path> [--configuration Debug] [--app-name <name>] [--output <path>] [--arch <arch>]
rn-coverage ios report --derived-data <path> [--profdata <path>] [--output-dir <path>] [--arch <arch>]
rn-coverage ios summary --derived-data <path> [--profdata <path>] [--arch <arch>]

rn-coverage assert [--platform ios|android|all] [--lcov <path>] [--jacoco-xml <path>]

Expand Down Expand Up @@ -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 <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)).
1 change: 1 addition & 0 deletions docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
4 changes: 4 additions & 0 deletions react-native-coverage.config.js.example
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
38 changes: 36 additions & 2 deletions scripts/ci/run-ios-e2e-cell.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand All @@ -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.
Expand Down Expand Up @@ -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=<udid>" 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

Expand Down Expand Up @@ -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=""
Expand Down Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions src/__tests__/arch-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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', ''));
});
});
15 changes: 15 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ async function main(): Promise<void> {
.option('--configuration <name>', 'Xcode configuration', 'Debug')
.option('--app-name <name>', 'App product name')
.option('--output <path>', 'LCOV output path', 'coverage/ios/lcov.info')
.option(
'--arch <arch>',
'llvm-cov -arch for universal binaries (default: auto-detect)'
)
.action(async (opts, cmd) => {
const rootOpts = rootOptsFrom(cmd);
const config = applyStrictOverride(
Expand All @@ -186,6 +190,7 @@ async function main(): Promise<void> {
configuration: opts.configuration,
appName: opts.appName ?? config.app.iosProductName,
output: opts.output,
arch: opts.arch,
config,
});
} catch (error) {
Expand All @@ -210,6 +215,10 @@ async function main(): Promise<void> {
'HTML output directory',
'coverage/ios/html'
)
.option(
'--arch <arch>',
'llvm-cov -arch for universal binaries (default: auto-detect)'
)
.action(async (opts, cmd) => {
const rootOpts = rootOptsFrom(cmd);
const config = applyStrictOverride(
Expand All @@ -223,6 +232,7 @@ async function main(): Promise<void> {
appName: opts.appName,
profdata: opts.profdata,
outputDir: opts.outputDir,
arch: opts.arch,
config,
});
} catch (error) {
Expand All @@ -242,6 +252,10 @@ async function main(): Promise<void> {
'Merged profdata path',
'coverage/ios/profdata'
)
.option(
'--arch <arch>',
'llvm-cov -arch for universal binaries (default: auto-detect)'
)
.action(async (opts, cmd) => {
const rootOpts = rootOptsFrom(cmd);
const config = applyStrictOverride(
Expand All @@ -254,6 +268,7 @@ async function main(): Promise<void> {
configuration: opts.configuration,
appName: opts.appName,
profdata: opts.profdata,
arch: opts.arch,
config,
});
} catch (error) {
Expand Down
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -92,6 +104,7 @@ export const DEFAULT_COVERAGE_CONFIG: CoverageConfig = {
},
ios: {
frameworkNamePrefixes: [],
arch: '',
},
android: {
libraryProjectMatchers: [],
Expand Down
Loading
Loading