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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/add-experimental-ios-native-coverage.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Expand Down
27 changes: 27 additions & 0 deletions packages/coverage-ios/HarnessCoverage.podspec
Original file line number Diff line number Diff line change
@@ -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
103 changes: 103 additions & 0 deletions packages/coverage-ios/README.md
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions packages/coverage-ios/ios/HarnessCoverageHelper.swift
Original file line number Diff line number Diff line change
@@ -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<CChar>)

@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
25 changes: 25 additions & 0 deletions packages/coverage-ios/ios/HarnessCoverageSetup.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// ObjC boot hook — Swift has no +load equivalent, so this bridges to HarnessCoverageHelper.setup().
#import <Foundation/Foundation.h>

@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
37 changes: 37 additions & 0 deletions packages/coverage-ios/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
10 changes: 10 additions & 0 deletions packages/coverage-ios/react-native.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module.exports = {
dependency: {
platforms: {
ios: {
configurations: ['debug'],
},
android: null,
},
},
};
Loading
Loading