From 8b3acde717142bb50ef6f0fc72fd728beeaa4c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 25 Mar 2026 14:10:20 +0100 Subject: [PATCH 1/8] feat: add native iOS code coverage support Add LLVM source-based profiling for native Swift/ObjC code via a new @react-native-harness/coverage-ios package. Users specify pods to instrument in rn-harness.config.mjs and coverage flags are injected automatically at pod install time via Module.prepend on Pod::Installer. --- packages/config/src/types.ts | 17 ++ packages/coverage-ios/HarnessCoverage.podspec | 21 ++ .../ios/HarnessCoverageHelper.swift | 59 ++++++ .../coverage-ios/ios/HarnessCoverageSetup.m | 20 ++ packages/coverage-ios/package.json | 49 +++++ packages/coverage-ios/react-native.config.cjs | 10 + .../scripts/harness_coverage_hook.rb | 87 +++++++++ packages/coverage-ios/src/index.ts | 1 + packages/coverage-ios/tsconfig.json | 10 + packages/coverage-ios/tsconfig.lib.json | 14 ++ packages/jest/src/harness.ts | 19 ++ packages/jest/src/logs.ts | 4 + .../platform-ios/src/coverage-collector.ts | 179 ++++++++++++++++++ packages/platform-ios/src/instance.ts | 10 + packages/platforms/src/index.ts | 1 + packages/platforms/src/types.ts | 8 + pnpm-lock.yaml | 10 + tsconfig.json | 3 + 18 files changed, 522 insertions(+) create mode 100644 packages/coverage-ios/HarnessCoverage.podspec create mode 100644 packages/coverage-ios/ios/HarnessCoverageHelper.swift create mode 100644 packages/coverage-ios/ios/HarnessCoverageSetup.m create mode 100644 packages/coverage-ios/package.json create mode 100644 packages/coverage-ios/react-native.config.cjs create mode 100644 packages/coverage-ios/scripts/harness_coverage_hook.rb create mode 100644 packages/coverage-ios/src/index.ts create mode 100644 packages/coverage-ios/tsconfig.json create mode 100644 packages/coverage-ios/tsconfig.lib.json create mode 100644 packages/platform-ios/src/coverage-collector.ts diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 056c9f60..c46864e6 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -103,6 +103,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..5035a20c --- /dev/null +++ b/packages/coverage-ios/HarnessCoverage.podspec @@ -0,0 +1,21 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +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/margelo/react-native-harness.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm,swift}" + + install_modules_dependencies(s) +end + +require_relative 'scripts/harness_coverage_hook' diff --git a/packages/coverage-ios/ios/HarnessCoverageHelper.swift b/packages/coverage-ios/ios/HarnessCoverageHelper.swift new file mode 100644 index 00000000..ddc10b4c --- /dev/null +++ b/packages/coverage-ios/ios/HarnessCoverageHelper.swift @@ -0,0 +1,59 @@ +#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 public class HarnessCoverageHelper: NSObject { + private static var isSetUp = false + private static var flushThread: Thread? + + @objc public static func setup() { + guard !isSetUp else { return } + isSetUp = true + + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let profrawPath = docs.appendingPathComponent("harness-\(ProcessInfo.processInfo.processIdentifier).profraw").path + __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..79b8cf3a --- /dev/null +++ b/packages/coverage-ios/ios/HarnessCoverageSetup.m @@ -0,0 +1,20 @@ +#if defined(HARNESS_COVERAGE) +#import + +@interface HarnessCoverageSetup : NSObject +@end + +@implementation HarnessCoverageSetup + ++ (void)load { + Class helper = NSClassFromString(@"HarnessCoverageHelper"); + if (helper) { + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wundeclared-selector" + [helper performSelector:@selector(setup)]; + #pragma clang diagnostic pop + } +} + +@end +#endif diff --git a/packages/coverage-ios/package.json b/packages/coverage-ios/package.json new file mode 100644 index 00000000..84493d08 --- /dev/null +++ b/packages/coverage-ios/package.json @@ -0,0 +1,49 @@ +{ + "name": "@react-native-harness/coverage-ios", + "description": "Native iOS code coverage support for React Native Harness.", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "src", + "dist", + "ios", + "scripts", + "*.podspec", + "react-native.config.cjs", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*" + ], + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "peerDependencies": { + "react-native": "*" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "devDependencies": { + "react-native": "*" + }, + "author": { + "name": "Margelo", + "email": "hello@margelo.com" + }, + "homepage": "https://github.com/margelo/react-native-harness", + "repository": { + "type": "git", + "url": "https://github.com/margelo/react-native-harness.git" + }, + "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..53501173 --- /dev/null +++ b/packages/coverage-ios/scripts/harness_coverage_hook.rb @@ -0,0 +1,87 @@ +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 + project_dir = Dir.pwd + config_json = `node -e " + import('#{project_dir}/rn-harness.config.mjs') + .then(m => console.log(JSON.stringify( + m.default?.coverage?.native?.ios?.pods || [] + ))) + .catch(() => console.log('[]')) + "`.strip + JSON.parse(config_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 + end +end + +Pod::Installer.prepend(HarnessCoverageHook) 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.ts b/packages/jest/src/harness.ts index 91a9539c..7e1c400b 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -50,6 +50,7 @@ import path from 'node:path'; import { logMetroCacheReused, logMetroPortFallback, + logNativeCoverageCollected, logRunnerStarting, logRunnerStillWaitingInQueue, logRunnerWaitingInQueue, @@ -686,6 +687,24 @@ const getHarnessInternal = async ( serverBridge.off('ready', onReady); serverBridge.off('disconnect', onDisconnect); serverBridge.off('event', bridgeEventListener); + + const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios; + if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) { + try { + await platformInstance.stopApp(); + await new Promise((resolve) => setTimeout(resolve, 500)); + const lcovPath = await platformInstance.collectNativeCoverage({ + pods: nativeCoverageConfig.pods, + outputDir: projectRoot, + }); + if (lcovPath) { + logNativeCoverageCollected(lcovPath); + } + } catch (error) { + harnessLogger.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..1047e98b --- /dev/null +++ b/packages/platform-ios/src/coverage-collector.ts @@ -0,0 +1,179 @@ +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(); +}; + +export const collectProfrawFiles = (dataContainer: string): string[] => { + const documentsDir = path.join(dataContainer, 'Documents'); + if (!fs.existsSync(documentsDir)) { + logger.debug('[coverage] Documents directory does not exist'); + return []; + } + + return fs + .readdirSync(documentsDir) + .filter((f) => f.endsWith('.profraw')) + .map((f) => path.join(documentsDir, f)); +}; + +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 }); + + let dataContainer: string; + try { + dataContainer = await getAppDataContainer(udid, bundleId); + } catch (error) { + logger.debug('[coverage] Failed to get app data container', error); + return null; + } + + const profrawFiles = collectProfrawFiles(dataContainer); + if (profrawFiles.length === 0) { + logger.debug('[coverage] No .profraw files found'); + 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, + }); + } + + 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..4c95c363 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 } from './coverage-collector.js'; const iosInstanceLogger = logger.child('ios-instance'); @@ -193,6 +195,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 c9eac857..d86200eb 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -12,6 +12,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 d11394d2..6044d3cb 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 b3621a9f..781f3398 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,6 +341,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" } ] } From 4035c231b925df87029654651d145e3b03bf8a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 7 May 2026 16:14:01 +0200 Subject: [PATCH 2/8] fix: address PR review comments for iOS coverage Fix repo URLs (margelo -> callstackincubator) and extract config loading into a separate script using @react-native-harness/config. --- packages/coverage-ios/HarnessCoverage.podspec | 2 +- packages/coverage-ios/package.json | 4 ++-- packages/coverage-ios/scripts/harness_coverage_hook.rb | 10 ++-------- .../coverage-ios/scripts/resolve-coverage-pods.mjs | 9 +++++++++ 4 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 packages/coverage-ios/scripts/resolve-coverage-pods.mjs diff --git a/packages/coverage-ios/HarnessCoverage.podspec b/packages/coverage-ios/HarnessCoverage.podspec index 5035a20c..0c96de3e 100644 --- a/packages/coverage-ios/HarnessCoverage.podspec +++ b/packages/coverage-ios/HarnessCoverage.podspec @@ -11,7 +11,7 @@ Pod::Spec.new do |s| s.authors = package["author"] s.platforms = { :ios => "13.0" } - s.source = { :git => "https://github.com/margelo/react-native-harness.git", :tag => "#{s.version}" } + s.source = { :git => "https://github.com/callstackincubator/react-native-harness.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" diff --git a/packages/coverage-ios/package.json b/packages/coverage-ios/package.json index 84493d08..da12f3d5 100644 --- a/packages/coverage-ios/package.json +++ b/packages/coverage-ios/package.json @@ -40,10 +40,10 @@ "name": "Margelo", "email": "hello@margelo.com" }, - "homepage": "https://github.com/margelo/react-native-harness", + "homepage": "https://github.com/callstackincubator/react-native-harness", "repository": { "type": "git", - "url": "https://github.com/margelo/react-native-harness.git" + "url": "https://github.com/callstackincubator/react-native-harness.git" }, "license": "MIT" } diff --git a/packages/coverage-ios/scripts/harness_coverage_hook.rb b/packages/coverage-ios/scripts/harness_coverage_hook.rb index 53501173..03155e6a 100644 --- a/packages/coverage-ios/scripts/harness_coverage_hook.rb +++ b/packages/coverage-ios/scripts/harness_coverage_hook.rb @@ -15,14 +15,8 @@ def run_podfile_post_install_hooks private def resolve_coverage_pods - project_dir = Dir.pwd - config_json = `node -e " - import('#{project_dir}/rn-harness.config.mjs') - .then(m => console.log(JSON.stringify( - m.default?.coverage?.native?.ios?.pods || [] - ))) - .catch(() => console.log('[]')) - "`.strip + script = File.expand_path('resolve-coverage-pods.mjs', __dir__) + config_json = `node #{script}`.strip JSON.parse(config_json) rescue => e Pod::UI.warn "[HarnessCoverage] Failed to read config: #{e.message}" 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..33408b3d --- /dev/null +++ b/packages/coverage-ios/scripts/resolve-coverage-pods.mjs @@ -0,0 +1,9 @@ +import { getConfig } from '@react-native-harness/config'; + +try { + const { config } = await getConfig(process.cwd()); + const pods = config.coverage?.native?.ios?.pods ?? []; + console.log(JSON.stringify(pods)); +} catch { + console.log('[]'); +} From c709d7059edfd0b3e10fa95a61519ca8db1d01aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 8 May 2026 07:16:08 +0200 Subject: [PATCH 3/8] fix: address integration issues found during rive-nitro testing - Guard podspec require_relative and install_modules_dependencies - Add @objc name annotation to prevent Swift name mangling - Defer +load class lookup to main queue for Xcode 16+ dylib compat - Read config directly instead of through Zod-validated schema - Patch app xcconfig with -force_load and -fprofile-instr-generate --- packages/coverage-ios/HarnessCoverage.podspec | 12 +++++-- .../ios/HarnessCoverageHelper.swift | 2 +- .../coverage-ios/ios/HarnessCoverageSetup.m | 26 +++++++++----- .../scripts/harness_coverage_hook.rb | 35 +++++++++++++++++++ .../scripts/resolve-coverage-pods.mjs | 34 ++++++++++++++++-- 5 files changed, 93 insertions(+), 16 deletions(-) diff --git a/packages/coverage-ios/HarnessCoverage.podspec b/packages/coverage-ios/HarnessCoverage.podspec index 0c96de3e..e6b98806 100644 --- a/packages/coverage-ios/HarnessCoverage.podspec +++ b/packages/coverage-ios/HarnessCoverage.podspec @@ -2,6 +2,10 @@ 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"] @@ -15,7 +19,9 @@ Pod::Spec.new do |s| s.source_files = "ios/**/*.{h,m,mm,swift}" - install_modules_dependencies(s) + if defined?(install_modules_dependencies) + install_modules_dependencies(s) + else + s.dependency "React-Core" + end end - -require_relative 'scripts/harness_coverage_hook' diff --git a/packages/coverage-ios/ios/HarnessCoverageHelper.swift b/packages/coverage-ios/ios/HarnessCoverageHelper.swift index ddc10b4c..880c00d8 100644 --- a/packages/coverage-ios/ios/HarnessCoverageHelper.swift +++ b/packages/coverage-ios/ios/HarnessCoverageHelper.swift @@ -8,7 +8,7 @@ func __llvm_profile_write_file() -> Int32 @_silgen_name("__llvm_profile_set_filename") func __llvm_profile_set_filename(_ filename: UnsafePointer) -@objc public class HarnessCoverageHelper: NSObject { +@objc(HarnessCoverageHelper) public class HarnessCoverageHelper: NSObject { private static var isSetUp = false private static var flushThread: Thread? diff --git a/packages/coverage-ios/ios/HarnessCoverageSetup.m b/packages/coverage-ios/ios/HarnessCoverageSetup.m index 79b8cf3a..18355ee3 100644 --- a/packages/coverage-ios/ios/HarnessCoverageSetup.m +++ b/packages/coverage-ios/ios/HarnessCoverageSetup.m @@ -1,4 +1,3 @@ -#if defined(HARNESS_COVERAGE) #import @interface HarnessCoverageSetup : NSObject @@ -7,14 +6,23 @@ @interface HarnessCoverageSetup : NSObject @implementation HarnessCoverageSetup + (void)load { - Class helper = NSClassFromString(@"HarnessCoverageHelper"); - if (helper) { - #pragma clang diagnostic push - #pragma clang diagnostic ignored "-Wundeclared-selector" - [helper performSelector:@selector(setup)]; - #pragma clang diagnostic pop - } +#if defined(HARNESS_COVERAGE) + NSLog(@"[HarnessCoverage] +load called, HARNESS_COVERAGE is defined"); + dispatch_async(dispatch_get_main_queue(), ^{ + Class helper = NSClassFromString(@"HarnessCoverageHelper"); + if (helper) { + NSLog(@"[HarnessCoverage] Found HarnessCoverageHelper, calling setup"); +#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"); + } + }); +#else + NSLog(@"[HarnessCoverage] +load called but HARNESS_COVERAGE is NOT defined"); +#endif } @end -#endif diff --git a/packages/coverage-ios/scripts/harness_coverage_hook.rb b/packages/coverage-ios/scripts/harness_coverage_hook.rb index 03155e6a..b28d3757 100644 --- a/packages/coverage-ios/scripts/harness_coverage_hook.rb +++ b/packages/coverage-ios/scripts/harness_coverage_hook.rb @@ -75,6 +75,41 @@ def apply_linker_flags 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 diff --git a/packages/coverage-ios/scripts/resolve-coverage-pods.mjs b/packages/coverage-ios/scripts/resolve-coverage-pods.mjs index 33408b3d..717a1211 100644 --- a/packages/coverage-ios/scripts/resolve-coverage-pods.mjs +++ b/packages/coverage-ios/scripts/resolve-coverage-pods.mjs @@ -1,8 +1,36 @@ -import { getConfig } from '@react-native-harness/config'; +import path from 'node:path'; +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; + +const extensions = ['.mjs', '.js', '.cjs']; + +function findConfig(dir) { + for (const ext of extensions) { + const filePath = path.join(dir, `rn-harness.config${ext}`); + if (fs.existsSync(filePath)) return filePath; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + return findConfig(parent); +} try { - const { config } = await getConfig(process.cwd()); - const pods = config.coverage?.native?.ios?.pods ?? []; + const configPath = findConfig(process.cwd()); + if (!configPath) { + console.log('[]'); + process.exit(0); + } + + let rawConfig; + if (configPath.endsWith('.mjs')) { + rawConfig = await import(pathToFileURL(configPath).href).then(m => m.default); + } else { + const require = createRequire(import.meta.url); + rawConfig = require(configPath); + } + + const pods = rawConfig?.coverage?.native?.ios?.pods ?? []; console.log(JSON.stringify(pods)); } catch { console.log('[]'); From f15a893e55e09cdf362cf3b3641455b76ef67e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 8 May 2026 07:44:11 +0200 Subject: [PATCH 4/8] fix: write profraw to /tmp/harness-coverage/ to survive app reinstalls The harness reinstalls the app between test files, wiping the app container. Move profraw output to /tmp/harness-coverage/ which persists across app reinstalls on the simulator. Clean the directory at test run start and after collection. --- .../ios/HarnessCoverageHelper.swift | 5 +-- .../platform-ios/src/coverage-collector.ts | 36 ++++++++++--------- packages/platform-ios/src/instance.ts | 6 +++- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/coverage-ios/ios/HarnessCoverageHelper.swift b/packages/coverage-ios/ios/HarnessCoverageHelper.swift index 880c00d8..35042c60 100644 --- a/packages/coverage-ios/ios/HarnessCoverageHelper.swift +++ b/packages/coverage-ios/ios/HarnessCoverageHelper.swift @@ -16,8 +16,9 @@ func __llvm_profile_set_filename(_ filename: UnsafePointer) guard !isSetUp else { return } isSetUp = true - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - let profrawPath = docs.appendingPathComponent("harness-\(ProcessInfo.processInfo.processIdentifier).profraw").path + let profrawDir = "/tmp/harness-coverage" + try? FileManager.default.createDirectory(atPath: profrawDir, withIntermediateDirectories: true) + let profrawPath = "\(profrawDir)/harness-\(ProcessInfo.processInfo.processIdentifier).profraw" __llvm_profile_set_filename(profrawPath) startFlushTimer() diff --git a/packages/platform-ios/src/coverage-collector.ts b/packages/platform-ios/src/coverage-collector.ts index 1047e98b..0a214398 100644 --- a/packages/platform-ios/src/coverage-collector.ts +++ b/packages/platform-ios/src/coverage-collector.ts @@ -29,17 +29,27 @@ export const getAppBundlePath = async ( return stdout.trim(); }; -export const collectProfrawFiles = (dataContainer: string): string[] => { - const documentsDir = path.join(dataContainer, 'Documents'); - if (!fs.existsSync(documentsDir)) { - logger.debug('[coverage] Documents directory does not exist'); +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(documentsDir) + .readdirSync(PROFRAW_DIR) .filter((f) => f.endsWith('.profraw')) - .map((f) => path.join(documentsDir, f)); + .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 ( @@ -120,17 +130,9 @@ export const collectNativeCoverage = async ( logger.debug('[coverage] Collecting native iOS coverage', { udid, bundleId, pods }); - let dataContainer: string; - try { - dataContainer = await getAppDataContainer(udid, bundleId); - } catch (error) { - logger.debug('[coverage] Failed to get app data container', error); - return null; - } - - const profrawFiles = collectProfrawFiles(dataContainer); + const profrawFiles = collectProfrawFiles(); if (profrawFiles.length === 0) { - logger.debug('[coverage] No .profraw files found'); + logger.debug('[coverage] No .profraw files found in %s', PROFRAW_DIR); return null; } @@ -174,6 +176,8 @@ export const collectNativeCoverage = async ( }); } + 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 4c95c363..36c62cd1 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -28,7 +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 } from './coverage-collector.js'; +import { collectNativeCoverage, cleanProfrawDir } from './coverage-collector.js'; const iosInstanceLogger = logger.child('ios-instance'); @@ -63,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, From 15fc68e9dcba86aff505bdbb5282ff7ed9de440e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 8 May 2026 09:22:39 +0200 Subject: [PATCH 5/8] refactor: use getConfig() for resolve-coverage-pods The direct config reading was a workaround for testing with the old published config package. Since this PR updates the config schema, they'll be published together and getConfig() will work correctly. --- .../scripts/resolve-coverage-pods.mjs | 34 ++----------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/packages/coverage-ios/scripts/resolve-coverage-pods.mjs b/packages/coverage-ios/scripts/resolve-coverage-pods.mjs index 717a1211..33408b3d 100644 --- a/packages/coverage-ios/scripts/resolve-coverage-pods.mjs +++ b/packages/coverage-ios/scripts/resolve-coverage-pods.mjs @@ -1,36 +1,8 @@ -import path from 'node:path'; -import fs from 'node:fs'; -import { pathToFileURL } from 'node:url'; -import { createRequire } from 'node:module'; - -const extensions = ['.mjs', '.js', '.cjs']; - -function findConfig(dir) { - for (const ext of extensions) { - const filePath = path.join(dir, `rn-harness.config${ext}`); - if (fs.existsSync(filePath)) return filePath; - } - const parent = path.dirname(dir); - if (parent === dir) return null; - return findConfig(parent); -} +import { getConfig } from '@react-native-harness/config'; try { - const configPath = findConfig(process.cwd()); - if (!configPath) { - console.log('[]'); - process.exit(0); - } - - let rawConfig; - if (configPath.endsWith('.mjs')) { - rawConfig = await import(pathToFileURL(configPath).href).then(m => m.default); - } else { - const require = createRequire(import.meta.url); - rawConfig = require(configPath); - } - - const pods = rawConfig?.coverage?.native?.ios?.pods ?? []; + const { config } = await getConfig(process.cwd()); + const pods = config.coverage?.native?.ios?.pods ?? []; console.log(JSON.stringify(pods)); } catch { console.log('[]'); From 6b4134f59f98577ed4add511ea595a0bc0c75df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Mon, 11 May 2026 10:03:23 +0200 Subject: [PATCH 6/8] cleanup: review polish for coverage-ios - Extract profrawDir as class-level constant - Remove debug NSLogs, keep only error log - Add one-line comment explaining ObjC +load bridge - Rename config_json to pods_json - Remove silent try/catch in resolve-coverage-pods - Remove unnecessary 500ms delay before collection - Align package.json with other packages, bump to 1.1.0 --- .../ios/HarnessCoverageHelper.swift | 2 +- .../coverage-ios/ios/HarnessCoverageSetup.m | 5 +-- packages/coverage-ios/package.json | 32 ++++++------------- .../scripts/harness_coverage_hook.rb | 4 +-- .../scripts/resolve-coverage-pods.mjs | 10 ++---- packages/jest/src/harness.ts | 1 - 6 files changed, 17 insertions(+), 37 deletions(-) diff --git a/packages/coverage-ios/ios/HarnessCoverageHelper.swift b/packages/coverage-ios/ios/HarnessCoverageHelper.swift index 35042c60..3f0a9220 100644 --- a/packages/coverage-ios/ios/HarnessCoverageHelper.swift +++ b/packages/coverage-ios/ios/HarnessCoverageHelper.swift @@ -9,6 +9,7 @@ func __llvm_profile_write_file() -> Int32 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? @@ -16,7 +17,6 @@ func __llvm_profile_set_filename(_ filename: UnsafePointer) guard !isSetUp else { return } isSetUp = true - let profrawDir = "/tmp/harness-coverage" try? FileManager.default.createDirectory(atPath: profrawDir, withIntermediateDirectories: true) let profrawPath = "\(profrawDir)/harness-\(ProcessInfo.processInfo.processIdentifier).profraw" __llvm_profile_set_filename(profrawPath) diff --git a/packages/coverage-ios/ios/HarnessCoverageSetup.m b/packages/coverage-ios/ios/HarnessCoverageSetup.m index 18355ee3..ba73fa01 100644 --- a/packages/coverage-ios/ios/HarnessCoverageSetup.m +++ b/packages/coverage-ios/ios/HarnessCoverageSetup.m @@ -1,3 +1,4 @@ +// ObjC boot hook — Swift has no +load equivalent, so this bridges to HarnessCoverageHelper.setup(). #import @interface HarnessCoverageSetup : NSObject @@ -7,11 +8,9 @@ @implementation HarnessCoverageSetup + (void)load { #if defined(HARNESS_COVERAGE) - NSLog(@"[HarnessCoverage] +load called, HARNESS_COVERAGE is defined"); dispatch_async(dispatch_get_main_queue(), ^{ Class helper = NSClassFromString(@"HarnessCoverageHelper"); if (helper) { - NSLog(@"[HarnessCoverage] Found HarnessCoverageHelper, calling setup"); #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wundeclared-selector" [helper performSelector:@selector(setup)]; @@ -20,8 +19,6 @@ + (void)load { NSLog(@"[HarnessCoverage] ERROR: HarnessCoverageHelper class not found"); } }); -#else - NSLog(@"[HarnessCoverage] +load called but HARNESS_COVERAGE is NOT defined"); #endif } diff --git a/packages/coverage-ios/package.json b/packages/coverage-ios/package.json index da12f3d5..5dd15c5e 100644 --- a/packages/coverage-ios/package.json +++ b/packages/coverage-ios/package.json @@ -1,11 +1,17 @@ { "name": "@react-native-harness/coverage-ios", "description": "Native iOS code coverage support for React Native Harness.", - "version": "1.0.0", + "version": "1.1.0", "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", + "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", @@ -18,15 +24,6 @@ "!**/__mocks__", "!**/.*" ], - "exports": { - "./package.json": "./package.json", - ".": { - "development": "./src/index.ts", - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - } - }, "peerDependencies": { "react-native": "*" }, @@ -36,14 +33,5 @@ "devDependencies": { "react-native": "*" }, - "author": { - "name": "Margelo", - "email": "hello@margelo.com" - }, - "homepage": "https://github.com/callstackincubator/react-native-harness", - "repository": { - "type": "git", - "url": "https://github.com/callstackincubator/react-native-harness.git" - }, "license": "MIT" } diff --git a/packages/coverage-ios/scripts/harness_coverage_hook.rb b/packages/coverage-ios/scripts/harness_coverage_hook.rb index b28d3757..686174ee 100644 --- a/packages/coverage-ios/scripts/harness_coverage_hook.rb +++ b/packages/coverage-ios/scripts/harness_coverage_hook.rb @@ -16,8 +16,8 @@ def run_podfile_post_install_hooks def resolve_coverage_pods script = File.expand_path('resolve-coverage-pods.mjs', __dir__) - config_json = `node #{script}`.strip - JSON.parse(config_json) + 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}" [] diff --git a/packages/coverage-ios/scripts/resolve-coverage-pods.mjs b/packages/coverage-ios/scripts/resolve-coverage-pods.mjs index 33408b3d..f34d3b2c 100644 --- a/packages/coverage-ios/scripts/resolve-coverage-pods.mjs +++ b/packages/coverage-ios/scripts/resolve-coverage-pods.mjs @@ -1,9 +1,5 @@ import { getConfig } from '@react-native-harness/config'; -try { - const { config } = await getConfig(process.cwd()); - const pods = config.coverage?.native?.ios?.pods ?? []; - console.log(JSON.stringify(pods)); -} catch { - console.log('[]'); -} +const { config } = await getConfig(process.cwd()); +const pods = config.coverage?.native?.ios?.pods ?? []; +console.log(JSON.stringify(pods)); diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index 7e1c400b..80a1c0ff 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -692,7 +692,6 @@ const getHarnessInternal = async ( if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) { try { await platformInstance.stopApp(); - await new Promise((resolve) => setTimeout(resolve, 500)); const lcovPath = await platformInstance.collectNativeCoverage({ pods: nativeCoverageConfig.pods, outputDir: projectRoot, From aee66761a94547f05d80f080e626d90d45878751 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 12 May 2026 10:14:08 +0200 Subject: [PATCH 7/8] docs: add native coverage docs --- .../add-experimental-ios-native-coverage.md | 5 + packages/coverage-ios/README.md | 103 ++++++++++++++++++ .../docs/getting-started/configuration.mdx | 20 ++++ website/src/docs/guides/_meta.json | 5 + website/src/docs/guides/native-coverage.mdx | 92 ++++++++++++++++ 5 files changed, 225 insertions(+) create mode 100644 .nx/version-plans/add-experimental-ios-native-coverage.md create mode 100644 packages/coverage-ios/README.md create mode 100644 website/src/docs/guides/native-coverage.mdx 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/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/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index e638cd8f..bc41dac8 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -86,6 +86,7 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` ## All Configuration Options +<<<<<<< HEAD | Option | Description | | :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- | | `entryPoint` | **Required.** Path to your React Native app's entry point file. | @@ -107,6 +108,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`). | @@ -347,3 +349,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 From 1b930c8a6cf0db78f626a90b05fdcedeacfe865f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 12 May 2026 10:26:47 +0200 Subject: [PATCH 8/8] fix: remove stray docs merge marker --- website/src/docs/getting-started/configuration.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index bc41dac8..129deea9 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -86,7 +86,6 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` ## All Configuration Options -<<<<<<< HEAD | Option | Description | | :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- | | `entryPoint` | **Required.** Path to your React Native app's entry point file. |