diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index b091c22..2641f5e 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -76,6 +76,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 0000000..5035a20 --- /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 0000000..ddc10b4 --- /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 0000000..79b8cf3 --- /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 0000000..84493d0 --- /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 0000000..65fdd25 --- /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 0000000..5350117 --- /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 0000000..cb0ff5c --- /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 0000000..c23e61c --- /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 0000000..7370b55 --- /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 8bda196..87d654d 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -30,7 +30,11 @@ import { type CrashSupervisor, } from './crash-supervisor.js'; import { createClientLogListener } from './client-log-handler.js'; -import { logMetroCacheReused, logMetroPrewarmCompleted } from './logs.js'; +import { + logMetroCacheReused, + logMetroPrewarmCompleted, + logNativeCoverageCollected, +} from './logs.js'; export type HarnessRunTestsOptions = Exclude; @@ -243,11 +247,36 @@ const getHarnessInternal = async ( metroInstance.events.addListener(clientLogListener); } + const collectCoverageBeforeDispose = async () => { + const nativeCoverageConfig = config.coverage?.native?.ios; + if (!nativeCoverageConfig?.pods?.length) return; + if (!platformInstance.collectNativeCoverage) return; + + // Stop the app first so SIGTERM triggers profraw flush + await platformInstance.stopApp(); + + // Brief delay for filesystem sync + await new Promise((resolve) => setTimeout(resolve, 500)); + + const outputDir = projectRoot; + const lcovPath = await platformInstance.collectNativeCoverage({ + pods: nativeCoverageConfig.pods, + outputDir, + }); + + if (lcovPath) { + logNativeCoverageCollected(lcovPath); + } + }; + const dispose = async () => { if (config.forwardClientLogs) { metroInstance.events.removeListener(clientLogListener); } serverBridge.off('ready', crashSupervisor.markReady); + + await collectCoverageBeforeDispose(); + await Promise.all([ crashSupervisor.dispose(), serverBridge.dispose(), diff --git a/packages/jest/src/logs.ts b/packages/jest/src/logs.ts index 88b23ad..7cd7eda 100644 --- a/packages/jest/src/logs.ts +++ b/packages/jest/src/logs.ts @@ -35,6 +35,10 @@ export const logMetroCacheReused = (runner: HarnessPlatform): void => { log(`${TAG} Reusing Metro cache for ${chalk.bold(runner.name)}\n`); }; +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 0000000..1047e98 --- /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 1386466..673272b 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -1,5 +1,6 @@ import { AppNotInstalledError, + type CollectNativeCoverageOptions, CreateAppMonitorOptions, DeviceNotFoundError, HarnessPlatformRunner, @@ -21,6 +22,7 @@ import { createIosSimulatorAppMonitor, } from './app-monitor.js'; import { assertLibimobiledeviceInstalled } from './libimobiledevice.js'; +import { collectNativeCoverage } from './coverage-collector.js'; export const getAppleSimulatorPlatformInstance = async ( config: ApplePlatformConfig, @@ -92,6 +94,14 @@ export const getAppleSimulatorPlatformInstance = async ( bundleId: config.bundleId, 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 35369eb..1de241a 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -11,6 +11,7 @@ export type { CrashArtifactWriter, CreateAppMonitorOptions, HarnessPlatform, + CollectNativeCoverageOptions, HarnessPlatformRunner, RunTarget, VegaAppLaunchOptions, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index d61cb10..e70587e 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -100,6 +100,11 @@ export type AppLaunchOptions = | WebAppLaunchOptions | VegaAppLaunchOptions; +export type CollectNativeCoverageOptions = { + pods: string[]; + outputDir: string; +}; + export type HarnessPlatformRunner = { startApp: (options?: AppLaunchOptions) => Promise; restartApp: (options?: AppLaunchOptions) => Promise; @@ -110,6 +115,9 @@ export type HarnessPlatformRunner = { getCrashDetails?: ( options: CrashDetailsLookupOptions ) => Promise; + collectNativeCoverage?: ( + options: CollectNativeCoverageOptions + ) => Promise; }; export type HarnessPlatform> = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e09be73..96abd25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -329,6 +329,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 25580ff..c34a3f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,6 +59,9 @@ }, { "path": "./packages/ui" + }, + { + "path": "./packages/coverage-ios" } ] }