diff --git a/.nx/version-plans/add-experimental-ios-native-coverage.md b/.nx/version-plans/add-experimental-ios-native-coverage.md new file mode 100644 index 00000000..7561de41 --- /dev/null +++ b/.nx/version-plans/add-experimental-ios-native-coverage.md @@ -0,0 +1,5 @@ +--- +__default__: minor +--- + +Harness now offers experimental native iOS coverage for selected CocoaPods, so you can see which native code paths your Harness tests exercise. After a covered run, Harness produces `native-coverage.lcov`, giving you a concrete way to inspect and report native coverage alongside your existing test results. diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index ae22b31b..fc0d5143 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -104,6 +104,23 @@ export const ConfigSchema = z 'Use ".." for create-react-native-library projects where tests run from example/ ' + "but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option." ), + native: z + .object({ + ios: z + .object({ + pods: z + .array(z.string()) + .min(1, 'At least one pod name is required') + .describe( + 'Pod names to instrument for native code coverage. ' + + 'Coverage flags are injected at pod install time via a CocoaPods hook. ' + + 'After tests, profraw data is collected and converted to lcov format.' + ), + }) + .optional(), + }) + .optional() + .describe('Native code coverage configuration.'), }) .optional(), diff --git a/packages/coverage-ios/HarnessCoverage.podspec b/packages/coverage-ios/HarnessCoverage.podspec new file mode 100644 index 00000000..e6b98806 --- /dev/null +++ b/packages/coverage-ios/HarnessCoverage.podspec @@ -0,0 +1,27 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +if defined?(Pod::Installer) + require_relative 'scripts/harness_coverage_hook' +end + +Pod::Spec.new do |s| + s.name = "HarnessCoverage" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => "13.0" } + s.source = { :git => "https://github.com/callstackincubator/react-native-harness.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm,swift}" + + if defined?(install_modules_dependencies) + install_modules_dependencies(s) + else + s.dependency "React-Core" + end +end diff --git a/packages/coverage-ios/README.md b/packages/coverage-ios/README.md new file mode 100644 index 00000000..5c81eb8c --- /dev/null +++ b/packages/coverage-ios/README.md @@ -0,0 +1,103 @@ +![harness-banner](https://react-native-harness.dev/harness-banner.jpg) + +### Experimental iOS Native Coverage for React Native Harness + +[![mit licence][license-badge]][license] +[![npm downloads][npm-downloads-badge]][npm-downloads] +[![Chat][chat-badge]][chat] +[![PRs Welcome][prs-welcome-badge]][prs-welcome] + +⚠️ **EXPERIMENTAL** ⚠️ + +`@react-native-harness/coverage-ios` adds native iOS code coverage collection for React Native Harness. It instruments selected CocoaPods, collects LLVM `.profraw` files from the app during test runs, and writes a `native-coverage.lcov` report after the run finishes. + +At the moment, coverage collection is supported on **iOS simulators only**. + +## Installation + +```bash +npm install --save-dev @react-native-harness/coverage-ios +# or +pnpm add -D @react-native-harness/coverage-ios +# or +yarn add -D @react-native-harness/coverage-ios +``` + +After installation, run your iOS pod install step and rebuild the app. + +## Usage + +Add the pods you want to instrument in `rn-harness.config.mjs`: + +```javascript +import { applePlatform, appleSimulator } from '@react-native-harness/platform-apple'; + +export default { + runners: [ + applePlatform({ + name: 'ios', + device: appleSimulator('iPhone 16 Pro', '18.0'), + bundleId: 'com.example.app', + }), + ], + coverage: { + native: { + ios: { + pods: ['MyLibrary'], + }, + }, + }, +}; +``` + +Run Harness with coverage enabled: + +```bash +react-native-harness --coverage --harnessRunner ios +``` + +When coverage is collected successfully, Harness writes: + +- `native-coverage.profdata` +- `native-coverage.lcov` + +to the project root. + +## How it works + +- Injects coverage compiler and linker flags into the selected CocoaPods during `pod install` +- Links a small helper pod that periodically flushes LLVM profile data from the running app +- Stops the app before disposal so the final profile data is written +- Merges `.profraw` files and exports them as LCOV + +## Requirements + +- macOS with Xcode installed +- iOS runner configured with `@react-native-harness/platform-apple` +- CocoaPods-based iOS project +- Debug build of the app +- `xcrun llvm-profdata` and `xcrun llvm-cov` available in Xcode toolchain + +## Limitations + +- iOS only +- iOS simulator only for now +- Experimental and subject to change +- Designed for pod-based native dependencies listed in `coverage.native.ios.pods` +- Coverage collection currently writes reports to the project root + +## Made with ❤️ at Callstack + +`@react-native-harness/coverage-ios` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! + +Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥 + +[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=react-native-harness&utm_term=readme-with-love +[license-badge]: https://img.shields.io/npm/l/@react-native-harness/coverage-ios?style=for-the-badge +[license]: https://github.com/callstackincubator/react-native-harness/blob/main/LICENSE +[npm-downloads-badge]: https://img.shields.io/npm/dm/@react-native-harness/coverage-ios?style=for-the-badge +[npm-downloads]: https://www.npmjs.com/package/@react-native-harness/coverage-ios +[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge +[prs-welcome]: ../../CONTRIBUTING.md +[chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge +[chat]: https://discord.gg/xgGt7KAjxv diff --git a/packages/coverage-ios/ios/HarnessCoverageHelper.swift b/packages/coverage-ios/ios/HarnessCoverageHelper.swift new file mode 100644 index 00000000..3f0a9220 --- /dev/null +++ b/packages/coverage-ios/ios/HarnessCoverageHelper.swift @@ -0,0 +1,60 @@ +#if HARNESS_COVERAGE +import Foundation +import UIKit + +@_silgen_name("__llvm_profile_write_file") +func __llvm_profile_write_file() -> Int32 + +@_silgen_name("__llvm_profile_set_filename") +func __llvm_profile_set_filename(_ filename: UnsafePointer) + +@objc(HarnessCoverageHelper) public class HarnessCoverageHelper: NSObject { + private static let profrawDir = "/tmp/harness-coverage" + private static var isSetUp = false + private static var flushThread: Thread? + + @objc public static func setup() { + guard !isSetUp else { return } + isSetUp = true + + try? FileManager.default.createDirectory(atPath: profrawDir, withIntermediateDirectories: true) + let profrawPath = "\(profrawDir)/harness-\(ProcessInfo.processInfo.processIdentifier).profraw" + __llvm_profile_set_filename(profrawPath) + + startFlushTimer() + + NotificationCenter.default.addObserver( + forName: UIApplication.willTerminateNotification, + object: nil, queue: nil + ) { _ in + _ = __llvm_profile_write_file() + } + + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, queue: nil + ) { _ in + _ = __llvm_profile_write_file() + } + + signal(SIGTERM) { _ in + _ = __llvm_profile_write_file() + exit(0) + } + } + + private static func startFlushTimer() { + let thread = Thread { + let timer = Timer(timeInterval: 1.0, repeats: true) { _ in + _ = __llvm_profile_write_file() + } + RunLoop.current.add(timer, forMode: .default) + RunLoop.current.run() + } + thread.name = "HarnessCoverageFlush" + thread.qualityOfService = .background + thread.start() + flushThread = thread + } +} +#endif diff --git a/packages/coverage-ios/ios/HarnessCoverageSetup.m b/packages/coverage-ios/ios/HarnessCoverageSetup.m new file mode 100644 index 00000000..ba73fa01 --- /dev/null +++ b/packages/coverage-ios/ios/HarnessCoverageSetup.m @@ -0,0 +1,25 @@ +// ObjC boot hook — Swift has no +load equivalent, so this bridges to HarnessCoverageHelper.setup(). +#import + +@interface HarnessCoverageSetup : NSObject +@end + +@implementation HarnessCoverageSetup + ++ (void)load { +#if defined(HARNESS_COVERAGE) + dispatch_async(dispatch_get_main_queue(), ^{ + Class helper = NSClassFromString(@"HarnessCoverageHelper"); + if (helper) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wundeclared-selector" + [helper performSelector:@selector(setup)]; +#pragma clang diagnostic pop + } else { + NSLog(@"[HarnessCoverage] ERROR: HarnessCoverageHelper class not found"); + } + }); +#endif +} + +@end diff --git a/packages/coverage-ios/package.json b/packages/coverage-ios/package.json new file mode 100644 index 00000000..5dd15c5e --- /dev/null +++ b/packages/coverage-ios/package.json @@ -0,0 +1,37 @@ +{ + "name": "@react-native-harness/coverage-ios", + "description": "Native iOS code coverage support for React Native Harness.", + "version": "1.1.0", + "type": "module", + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "src", + "dist", + "ios", + "scripts", + "*.podspec", + "react-native.config.cjs", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*" + ], + "peerDependencies": { + "react-native": "*" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "devDependencies": { + "react-native": "*" + }, + "license": "MIT" +} diff --git a/packages/coverage-ios/react-native.config.cjs b/packages/coverage-ios/react-native.config.cjs new file mode 100644 index 00000000..65fdd250 --- /dev/null +++ b/packages/coverage-ios/react-native.config.cjs @@ -0,0 +1,10 @@ +module.exports = { + dependency: { + platforms: { + ios: { + configurations: ['debug'], + }, + android: null, + }, + }, +}; diff --git a/packages/coverage-ios/scripts/harness_coverage_hook.rb b/packages/coverage-ios/scripts/harness_coverage_hook.rb new file mode 100644 index 00000000..686174ee --- /dev/null +++ b/packages/coverage-ios/scripts/harness_coverage_hook.rb @@ -0,0 +1,116 @@ +module HarnessCoverageHook + def run_podfile_post_install_hooks + super + + pods = resolve_coverage_pods + return if pods.empty? + + Pod::UI.puts "[HarnessCoverage] Instrumenting pods for native coverage: #{pods.join(', ')}" + + apply_coverage_flags_to_pods(pods) + enable_harness_coverage_pod + apply_linker_flags + end + + private + + def resolve_coverage_pods + script = File.expand_path('resolve-coverage-pods.mjs', __dir__) + pods_json = `NODE_OPTIONS="--preserve-symlinks" node #{script}`.strip + JSON.parse(pods_json) + rescue => e + Pod::UI.warn "[HarnessCoverage] Failed to read config: #{e.message}" + [] + end + + def apply_coverage_flags_to_pods(pods) + pods_project.targets.each do |target| + next unless pods.include?(target.name) + + target.build_configurations.each do |config| + swift_flags = config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)' + unless swift_flags.include?('-profile-generate') + config.build_settings['OTHER_SWIFT_FLAGS'] = + "#{swift_flags} -profile-generate -profile-coverage-mapping" + end + + c_flags = config.build_settings['OTHER_CFLAGS'] || '$(inherited)' + unless c_flags.include?('-fprofile-instr-generate') + config.build_settings['OTHER_CFLAGS'] = + "#{c_flags} -fprofile-instr-generate -fcoverage-mapping" + end + end + + Pod::UI.puts "[HarnessCoverage] -> #{target.name}" + end + end + + def enable_harness_coverage_pod + pods_project.targets.each do |target| + next unless target.name == 'HarnessCoverage' + + target.build_configurations.each do |config| + swift_conditions = config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] || '$(inherited)' + unless swift_conditions.include?('HARNESS_COVERAGE') + config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] = + "#{swift_conditions} HARNESS_COVERAGE" + end + + gcc_defs = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] || '$(inherited)' + unless gcc_defs.include?('HARNESS_COVERAGE') + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = + "#{gcc_defs} HARNESS_COVERAGE=1" + end + end + end + end + + def apply_linker_flags + pods_project.targets.each do |target| + target.build_configurations.each do |config| + ldflags = config.build_settings['OTHER_LDFLAGS'] || '$(inherited)' + unless ldflags.include?('-fprofile-instr-generate') + config.build_settings['OTHER_LDFLAGS'] = + "#{ldflags} -fprofile-instr-generate" + end + end + end + + apply_app_target_linker_flags + end + + def apply_app_target_linker_flags + sandbox_root = config.sandbox.root + target_support_dir = sandbox_root.join('Target Support Files') + + Dir.glob(target_support_dir.join('Pods-*', '*.xcconfig').to_s).each do |xcconfig_path| + content = File.read(xcconfig_path) + + modified = false + + unless content.include?('-fprofile-instr-generate') + content = content.gsub( + /^(OTHER_LDFLAGS\s*=\s*)/, + "\\1-fprofile-instr-generate " + ) + modified = true + end + + force_load = '-force_load "${PODS_CONFIGURATION_BUILD_DIR}/HarnessCoverage/libHarnessCoverage.a"' + unless content.include?('libHarnessCoverage.a') + content = content.gsub( + /^(OTHER_LDFLAGS\s*=\s*)/, + "\\1#{force_load} " + ) + modified = true + end + + if modified + File.write(xcconfig_path, content) + Pod::UI.puts "[HarnessCoverage] -> patched #{File.basename(xcconfig_path)}" + end + end + end +end + +Pod::Installer.prepend(HarnessCoverageHook) diff --git a/packages/coverage-ios/scripts/resolve-coverage-pods.mjs b/packages/coverage-ios/scripts/resolve-coverage-pods.mjs new file mode 100644 index 00000000..f34d3b2c --- /dev/null +++ b/packages/coverage-ios/scripts/resolve-coverage-pods.mjs @@ -0,0 +1,5 @@ +import { getConfig } from '@react-native-harness/config'; + +const { config } = await getConfig(process.cwd()); +const pods = config.coverage?.native?.ios?.pods ?? []; +console.log(JSON.stringify(pods)); diff --git a/packages/coverage-ios/src/index.ts b/packages/coverage-ios/src/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/coverage-ios/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/coverage-ios/tsconfig.json b/packages/coverage-ios/tsconfig.json new file mode 100644 index 00000000..c23e61c8 --- /dev/null +++ b/packages/coverage-ios/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/coverage-ios/tsconfig.lib.json b/packages/coverage-ios/tsconfig.lib.json new file mode 100644 index 00000000..7370b55e --- /dev/null +++ b/packages/coverage-ios/tsconfig.lib.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "forceConsistentCasingInFileNames": true, + "types": ["node"], + "lib": ["DOM", "ES2022"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index e79c0a5c..a19b2387 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -64,6 +64,7 @@ import { getAdditionalCliArgs } from './cli-args.js'; import { logMetroCacheReused, logMetroPortFallback, + logNativeCoverageCollected, logRunnerStarting, logRunnerStillWaitingInQueue, logRunnerWaitingInQueue, @@ -557,6 +558,22 @@ export const createHarnessSession = async ( bridge.off('disconnected', onDisconnected); bridge.off('event', bridgeEventListener); + const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios; + if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) { + try { + await platformInstance.stopApp(); + const lcovPath = await platformInstance.collectNativeCoverage({ + pods: nativeCoverageConfig.pods, + outputDir: projectRoot, + }); + if (lcovPath) { + logNativeCoverageCollected(lcovPath); + } + } catch (error) { + sessionLogger.warn('failed to collect native coverage: %s', error); + } + } + let cleanupError: unknown; try { await Promise.all([ diff --git a/packages/jest/src/logs.ts b/packages/jest/src/logs.ts index cdde600d..d7f33aeb 100644 --- a/packages/jest/src/logs.ts +++ b/packages/jest/src/logs.ts @@ -56,6 +56,10 @@ export const logMetroPortFallback = ( ); }; +export const logNativeCoverageCollected = (lcovPath: string): void => { + log(`${TAG} Native coverage written to ${chalk.bold(lcovPath)}\n`); +}; + export const getErrorMessage = (error: HarnessError): string => { return `${ERROR_TAG} ${error.message}\n`; }; diff --git a/packages/platform-ios/src/coverage-collector.ts b/packages/platform-ios/src/coverage-collector.ts new file mode 100644 index 00000000..0a214398 --- /dev/null +++ b/packages/platform-ios/src/coverage-collector.ts @@ -0,0 +1,183 @@ +import { spawn, logger } from '@react-native-harness/tools'; +import fs from 'node:fs'; +import path from 'node:path'; + +export const getAppDataContainer = async ( + udid: string, + bundleId: string +): Promise => { + const { stdout } = await spawn('xcrun', [ + 'simctl', + 'get_app_container', + udid, + bundleId, + 'data', + ]); + return stdout.trim(); +}; + +export const getAppBundlePath = async ( + udid: string, + bundleId: string +): Promise => { + const { stdout } = await spawn('xcrun', [ + 'simctl', + 'get_app_container', + udid, + bundleId, + ]); + return stdout.trim(); +}; + +const PROFRAW_DIR = '/tmp/harness-coverage'; + +export const collectProfrawFiles = (): string[] => { + if (!fs.existsSync(PROFRAW_DIR)) { + logger.debug('[coverage] Profraw directory does not exist: %s', PROFRAW_DIR); + return []; + } + + return fs + .readdirSync(PROFRAW_DIR) + .filter((f) => f.endsWith('.profraw')) + .map((f) => path.join(PROFRAW_DIR, f)); +}; + +export const cleanProfrawDir = (): void => { + if (fs.existsSync(PROFRAW_DIR)) { + for (const f of fs.readdirSync(PROFRAW_DIR)) { + fs.unlinkSync(path.join(PROFRAW_DIR, f)); + } + logger.debug('[coverage] Cleaned profraw directory: %s', PROFRAW_DIR); + } +}; + +export const mergeProfdata = async ( + profrawFiles: string[], + outputPath: string +): Promise => { + await spawn('xcrun', [ + 'llvm-profdata', + 'merge', + '-sparse', + ...profrawFiles, + '-o', + outputPath, + ]); +}; + +export const findAppExecutable = async ( + appBundlePath: string +): Promise => { + const infoPlistPath = path.join(appBundlePath, 'Info.plist'); + const { stdout } = await spawn('plutil', [ + '-extract', + 'CFBundleExecutable', + 'raw', + infoPlistPath, + ]); + const executableName = stdout.trim(); + + // Xcode 26+ may use a debug.dylib + const debugDylibPath = path.join( + appBundlePath, + `${executableName}.debug.dylib` + ); + if (fs.existsSync(debugDylibPath)) { + return debugDylibPath; + } + + return path.join(appBundlePath, executableName); +}; + +export const generateLcov = async (options: { + profdataPath: string; + binaryPath: string; + outputPath: string; + sourceFilters?: string[]; +}): Promise => { + const { profdataPath, binaryPath, outputPath, sourceFilters } = options; + + const args = [ + 'llvm-cov', + 'export', + '-format=lcov', + `-instr-profile=${profdataPath}`, + binaryPath, + ]; + + if (sourceFilters) { + for (const filter of sourceFilters) { + args.push(`--sources=${filter}`); + } + } + + const { stdout } = await spawn('xcrun', args); + fs.writeFileSync(outputPath, stdout); +}; + +export type CollectNativeCoverageOptions = { + udid: string; + bundleId: string; + pods: string[]; + outputDir: string; +}; + +export const collectNativeCoverage = async ( + options: CollectNativeCoverageOptions +): Promise => { + const { udid, bundleId, pods, outputDir } = options; + + logger.debug('[coverage] Collecting native iOS coverage', { udid, bundleId, pods }); + + const profrawFiles = collectProfrawFiles(); + if (profrawFiles.length === 0) { + logger.debug('[coverage] No .profraw files found in %s', PROFRAW_DIR); + return null; + } + + logger.debug(`[coverage] Found ${profrawFiles.length} .profraw file(s)`); + + const profdataPath = path.join(outputDir, 'native-coverage.profdata'); + await mergeProfdata(profrawFiles, profdataPath); + + let appBundlePath: string; + try { + appBundlePath = await getAppBundlePath(udid, bundleId); + } catch (error) { + logger.debug('[coverage] Failed to get app bundle path', error); + return null; + } + + const binaryPath = await findAppExecutable(appBundlePath); + logger.debug(`[coverage] Using binary: ${binaryPath}`); + + const lcovPath = path.join(outputDir, 'native-coverage.lcov'); + + // Filter sources to only include code from the specified pods. + // Pod source files are typically in the Pods directory under each pod name. + const podSourceDirs = pods.map((pod) => + path.join(path.dirname(appBundlePath), '..', 'Pods', pod) + ); + + try { + await generateLcov({ + profdataPath, + binaryPath, + outputPath: lcovPath, + sourceFilters: podSourceDirs, + }); + } catch (error) { + logger.debug('[coverage] Failed to generate lcov, trying without source filters', error); + await generateLcov({ + profdataPath, + binaryPath, + outputPath: lcovPath, + }); + } + + cleanProfrawDir(); + + logger.debug(`[coverage] Native coverage written to: ${lcovPath}`); + return lcovPath; +}; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 55b53a4e..36c62cd1 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -1,6 +1,7 @@ import { AppMonitor, AppNotInstalledError, + type CollectNativeCoverageOptions, CreateAppMonitorOptions, DeviceNotFoundError, type HarnessPlatformInitOptions, @@ -27,6 +28,7 @@ import { logger } from '@react-native-harness/tools'; import fs from 'node:fs'; import { createXCTestAgentController } from './xctest-agent.js'; import { createPermissionPromptAutoAcceptCapability } from './xctest-agent-capabilities.js'; +import { collectNativeCoverage, cleanProfrawDir } from './coverage-collector.js'; const iosInstanceLogger = logger.child('ios-instance'); @@ -61,6 +63,10 @@ export const getAppleSimulatorPlatformInstance = async ( const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; const permissionsEnabled = harnessConfig.permissions ?? false; + if (harnessConfig.coverage?.native?.ios?.pods?.length) { + cleanProfrawDir(); + } + const udid = await simctl.getSimulatorId( config.device.name, config.device.systemVersion, @@ -193,6 +199,14 @@ export const getAppleSimulatorPlatformInstance = async ( crashArtifactWriter: options?.crashArtifactWriter, }); }, + collectNativeCoverage: async (options: CollectNativeCoverageOptions) => { + return await collectNativeCoverage({ + udid, + bundleId: config.bundleId, + pods: options.pods, + outputDir: options.outputDir, + }); + }, }; }; diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index 0ebfa3c6..043a4f30 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -15,6 +15,7 @@ export type { CreateAppMonitorOptions, HarnessPlatform, HarnessPlatformInitOptions, + CollectNativeCoverageOptions, HarnessPlatformRunner, RunTarget, VegaAppLaunchOptions, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index e89d9980..f4d16819 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -98,6 +98,11 @@ export type AppLaunchOptions = | WebAppLaunchOptions | VegaAppLaunchOptions; +export type CollectNativeCoverageOptions = { + pods: string[]; + outputDir: string; +}; + export type HarnessPlatformRunner = { startApp: (options?: AppLaunchOptions) => Promise; restartApp: (options?: AppLaunchOptions) => Promise; @@ -108,6 +113,9 @@ export type HarnessPlatformRunner = { getCrashDetails?: ( options: CrashDetailsLookupOptions, ) => Promise; + collectNativeCoverage?: ( + options: CollectNativeCoverageOptions + ) => Promise; }; export type HarnessPlatformInitOptions = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f877eea..50b36526 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,16 @@ importers: specifier: ^3.25.67 version: 3.25.67 + packages/coverage-ios: + dependencies: + tslib: + specifier: ^2.3.0 + version: 2.8.1 + devDependencies: + react-native: + specifier: '*' + version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) + packages/github-action: dependencies: '@react-native-harness/config': diff --git a/tsconfig.json b/tsconfig.json index 9636d917..4d6eb235 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,6 +59,9 @@ }, { "path": "./packages/ui" + }, + { + "path": "./packages/coverage-ios" } ] } diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 81b8220f..f2003098 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -107,6 +107,7 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` | `disableViewFlattening` | Disable view flattening in React Native (default: `false`). | | `coverage` | Coverage configuration object. | | `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | +| `coverage.native.ios.pods` | Experimental list of CocoaPods target names to instrument for iOS native coverage. | | `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | | `unstable__enableMetroCache` | Enable Metro transformation cache under `.harness/metro-cache` and log when reusing it (default: `false`). | @@ -349,3 +350,21 @@ Without specifying `coverage.root`, babel-plugin-istanbul may skip instrumenting :::tip When to use coverage.root Set `coverage.root` when you notice 0% coverage in your reports or when source files are not being instrumented for coverage. This commonly occurs in create-react-native-library projects and other monorepo setups. ::: + +## Native iOS Coverage + +Harness also supports experimental native iOS coverage for selected CocoaPods. + +```javascript +{ + coverage: { + native: { + ios: { + pods: ['MyLibrary'], + }, + }, + }, +} +``` + +Use this when you want coverage data for native code exercised by your Harness tests. Native coverage currently supports iOS simulators only, with Android planned later. For setup details, see [Native Coverage](/docs/guides/native-coverage). diff --git a/website/src/docs/guides/_meta.json b/website/src/docs/guides/_meta.json index eedc60f2..0e48b745 100644 --- a/website/src/docs/guides/_meta.json +++ b/website/src/docs/guides/_meta.json @@ -9,6 +9,11 @@ "name": "ui-testing", "label": "UI Testing" }, + { + "type": "file", + "name": "native-coverage", + "label": "Native Coverage" + }, { "type": "file", "name": "ci-cd", diff --git a/website/src/docs/guides/native-coverage.mdx b/website/src/docs/guides/native-coverage.mdx new file mode 100644 index 00000000..5ed6e559 --- /dev/null +++ b/website/src/docs/guides/native-coverage.mdx @@ -0,0 +1,92 @@ +import { PackageManagerTabs } from '@theme'; + +# Native Coverage + +React Native Harness provides experimental support for collecting native coverage during test runs. + +:::warning Experimental +`@react-native-harness/coverage-ios` is an **experimental** feature. Expect rough edges and API changes while the integration matures. +::: + +Today, native coverage support is available for **iOS simulators only**. Physical iOS devices are not supported yet. Android support is planned and this guide will expand as it lands. + +## What you get + +- Coverage instrumentation for supported native dependencies +- Automatic `.profraw` collection from the app sandbox +- `native-coverage.lcov` output after the test run finishes +- A way to measure native code exercised by Harness tests, alongside JavaScript coverage + +## Installation + +Current package: + + + +After installation: + +1. Run your iOS pod install step. +2. Rebuild the app. + +## Configuration + +Current iOS configuration: + +Add the pods you want to instrument in `rn-harness.config.mjs`: + +```javascript +import { applePlatform, appleSimulator } from '@react-native-harness/platform-apple'; + +export default { + runners: [ + applePlatform({ + name: 'ios', + device: appleSimulator('iPhone 16 Pro', '18.0'), + bundleId: 'com.example.app', + }), + ], + coverage: { + native: { + ios: { + pods: ['MyLibrary'], + }, + }, + }, +}; +``` + +The `pods` array should contain CocoaPods target names that you want Harness to instrument for coverage. + +## Running tests + +Current iOS command: + +Run Harness with coverage enabled: + +```bash +react-native-harness --coverage --harnessRunner ios +``` + +Harness will stop the app before cleanup, collect generated `.profraw` files, merge them with `llvm-profdata`, and export LCOV with `llvm-cov`. + +## Output + +When native coverage is collected successfully, Harness writes these files to the project root: + +- `native-coverage.profdata` +- `native-coverage.lcov` + +## Requirements + +- macOS with Xcode installed +- An iOS runner configured with `@react-native-harness/platform-apple` +- A CocoaPods-based iOS app setup +- iOS Simulator +- Debug app build + +## Limitations + +- iOS simulator support is available today; physical iOS devices and Android are not supported yet +- Current implementation targets pod-based native code +- The feature is experimental and may change without much notice +- If LCOV source filtering fails for pod paths, Harness falls back to exporting broader coverage data