Skip to content

Commit 6a33bbf

Browse files
committed
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.
1 parent 841c099 commit 6a33bbf

File tree

18 files changed

+533
-1
lines changed

18 files changed

+533
-1
lines changed

packages/config/src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ export const ConfigSchema = z
6969
'Use ".." for create-react-native-library projects where tests run from example/ ' +
7070
"but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option."
7171
),
72+
native: z
73+
.object({
74+
ios: z
75+
.object({
76+
pods: z
77+
.array(z.string())
78+
.min(1, 'At least one pod name is required')
79+
.describe(
80+
'Pod names to instrument for native code coverage. ' +
81+
'Coverage flags are injected at pod install time via a CocoaPods hook. ' +
82+
'After tests, profraw data is collected and converted to lcov format.'
83+
),
84+
})
85+
.optional(),
86+
})
87+
.optional()
88+
.describe('Native code coverage configuration.'),
7289
})
7390
.optional(),
7491

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
require "json"
2+
3+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4+
5+
Pod::Spec.new do |s|
6+
s.name = "HarnessCoverage"
7+
s.version = package["version"]
8+
s.summary = package["description"]
9+
s.homepage = package["homepage"]
10+
s.license = package["license"]
11+
s.authors = package["author"]
12+
13+
s.platforms = { :ios => "13.0" }
14+
s.source = { :git => "https://github.com/margelo/react-native-harness.git", :tag => "#{s.version}" }
15+
16+
s.source_files = "ios/**/*.{h,m,mm,swift}"
17+
18+
install_modules_dependencies(s)
19+
end
20+
21+
require_relative 'scripts/harness_coverage_hook'
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#if HARNESS_COVERAGE
2+
import Foundation
3+
import UIKit
4+
5+
@_silgen_name("__llvm_profile_write_file")
6+
func __llvm_profile_write_file() -> Int32
7+
8+
@_silgen_name("__llvm_profile_set_filename")
9+
func __llvm_profile_set_filename(_ filename: UnsafePointer<CChar>)
10+
11+
@objc public class HarnessCoverageHelper: NSObject {
12+
private static var isSetUp = false
13+
private static var flushThread: Thread?
14+
15+
@objc public static func setup() {
16+
guard !isSetUp else { return }
17+
isSetUp = true
18+
19+
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
20+
let profrawPath = docs.appendingPathComponent("harness-\(ProcessInfo.processInfo.processIdentifier).profraw").path
21+
__llvm_profile_set_filename(profrawPath)
22+
23+
startFlushTimer()
24+
25+
NotificationCenter.default.addObserver(
26+
forName: UIApplication.willTerminateNotification,
27+
object: nil, queue: nil
28+
) { _ in
29+
_ = __llvm_profile_write_file()
30+
}
31+
32+
NotificationCenter.default.addObserver(
33+
forName: UIApplication.didEnterBackgroundNotification,
34+
object: nil, queue: nil
35+
) { _ in
36+
_ = __llvm_profile_write_file()
37+
}
38+
39+
signal(SIGTERM) { _ in
40+
_ = __llvm_profile_write_file()
41+
exit(0)
42+
}
43+
}
44+
45+
private static func startFlushTimer() {
46+
let thread = Thread {
47+
let timer = Timer(timeInterval: 1.0, repeats: true) { _ in
48+
_ = __llvm_profile_write_file()
49+
}
50+
RunLoop.current.add(timer, forMode: .default)
51+
RunLoop.current.run()
52+
}
53+
thread.name = "HarnessCoverageFlush"
54+
thread.qualityOfService = .background
55+
thread.start()
56+
flushThread = thread
57+
}
58+
}
59+
#endif
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#if defined(HARNESS_COVERAGE)
2+
#import <Foundation/Foundation.h>
3+
4+
@interface HarnessCoverageSetup : NSObject
5+
@end
6+
7+
@implementation HarnessCoverageSetup
8+
9+
+ (void)load {
10+
Class helper = NSClassFromString(@"HarnessCoverageHelper");
11+
if (helper) {
12+
#pragma clang diagnostic push
13+
#pragma clang diagnostic ignored "-Wundeclared-selector"
14+
[helper performSelector:@selector(setup)];
15+
#pragma clang diagnostic pop
16+
}
17+
}
18+
19+
@end
20+
#endif

packages/coverage-ios/package.json

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "@react-native-harness/coverage-ios",
3+
"description": "Native iOS code coverage support for React Native Harness.",
4+
"version": "1.0.0",
5+
"type": "module",
6+
"main": "./dist/index.js",
7+
"module": "./dist/index.js",
8+
"types": "./dist/index.d.ts",
9+
"files": [
10+
"src",
11+
"dist",
12+
"ios",
13+
"scripts",
14+
"*.podspec",
15+
"react-native.config.cjs",
16+
"!**/__tests__",
17+
"!**/__fixtures__",
18+
"!**/__mocks__",
19+
"!**/.*"
20+
],
21+
"exports": {
22+
"./package.json": "./package.json",
23+
".": {
24+
"development": "./src/index.ts",
25+
"types": "./dist/index.d.ts",
26+
"import": "./dist/index.js",
27+
"default": "./dist/index.js"
28+
}
29+
},
30+
"peerDependencies": {
31+
"react-native": "*"
32+
},
33+
"dependencies": {
34+
"tslib": "^2.3.0"
35+
},
36+
"devDependencies": {
37+
"react-native": "*"
38+
},
39+
"author": {
40+
"name": "Margelo",
41+
"email": "hello@margelo.com"
42+
},
43+
"homepage": "https://github.com/margelo/react-native-harness",
44+
"repository": {
45+
"type": "git",
46+
"url": "https://github.com/margelo/react-native-harness.git"
47+
},
48+
"license": "MIT"
49+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module.exports = {
2+
dependency: {
3+
platforms: {
4+
ios: {
5+
configurations: ['debug'],
6+
},
7+
android: null,
8+
},
9+
},
10+
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
module HarnessCoverageHook
2+
def run_podfile_post_install_hooks
3+
super
4+
5+
pods = resolve_coverage_pods
6+
return if pods.empty?
7+
8+
Pod::UI.puts "[HarnessCoverage] Instrumenting pods for native coverage: #{pods.join(', ')}"
9+
10+
apply_coverage_flags_to_pods(pods)
11+
enable_harness_coverage_pod
12+
apply_linker_flags
13+
end
14+
15+
private
16+
17+
def resolve_coverage_pods
18+
project_dir = Dir.pwd
19+
config_json = `node -e "
20+
import('#{project_dir}/rn-harness.config.mjs')
21+
.then(m => console.log(JSON.stringify(
22+
m.default?.coverage?.native?.ios?.pods || []
23+
)))
24+
.catch(() => console.log('[]'))
25+
"`.strip
26+
JSON.parse(config_json)
27+
rescue => e
28+
Pod::UI.warn "[HarnessCoverage] Failed to read config: #{e.message}"
29+
[]
30+
end
31+
32+
def apply_coverage_flags_to_pods(pods)
33+
pods_project.targets.each do |target|
34+
next unless pods.include?(target.name)
35+
36+
target.build_configurations.each do |config|
37+
swift_flags = config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'
38+
unless swift_flags.include?('-profile-generate')
39+
config.build_settings['OTHER_SWIFT_FLAGS'] =
40+
"#{swift_flags} -profile-generate -profile-coverage-mapping"
41+
end
42+
43+
c_flags = config.build_settings['OTHER_CFLAGS'] || '$(inherited)'
44+
unless c_flags.include?('-fprofile-instr-generate')
45+
config.build_settings['OTHER_CFLAGS'] =
46+
"#{c_flags} -fprofile-instr-generate -fcoverage-mapping"
47+
end
48+
end
49+
50+
Pod::UI.puts "[HarnessCoverage] -> #{target.name}"
51+
end
52+
end
53+
54+
def enable_harness_coverage_pod
55+
pods_project.targets.each do |target|
56+
next unless target.name == 'HarnessCoverage'
57+
58+
target.build_configurations.each do |config|
59+
swift_conditions = config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] || '$(inherited)'
60+
unless swift_conditions.include?('HARNESS_COVERAGE')
61+
config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] =
62+
"#{swift_conditions} HARNESS_COVERAGE"
63+
end
64+
65+
gcc_defs = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] || '$(inherited)'
66+
unless gcc_defs.include?('HARNESS_COVERAGE')
67+
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] =
68+
"#{gcc_defs} HARNESS_COVERAGE=1"
69+
end
70+
end
71+
end
72+
end
73+
74+
def apply_linker_flags
75+
pods_project.targets.each do |target|
76+
target.build_configurations.each do |config|
77+
ldflags = config.build_settings['OTHER_LDFLAGS'] || '$(inherited)'
78+
unless ldflags.include?('-fprofile-instr-generate')
79+
config.build_settings['OTHER_LDFLAGS'] =
80+
"#{ldflags} -fprofile-instr-generate"
81+
end
82+
end
83+
end
84+
end
85+
end
86+
87+
Pod::Installer.prepend(HarnessCoverageHook)

packages/coverage-ios/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"files": [],
4+
"include": [],
5+
"references": [
6+
{
7+
"path": "./tsconfig.lib.json"
8+
}
9+
]
10+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"baseUrl": ".",
5+
"rootDir": "src",
6+
"outDir": "dist",
7+
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
8+
"emitDeclarationOnly": false,
9+
"forceConsistentCasingInFileNames": true,
10+
"types": ["node"],
11+
"lib": ["DOM", "ES2022"]
12+
},
13+
"include": ["src/**/*.ts"]
14+
}

0 commit comments

Comments
 (0)