Skip to content
Draft
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
17 changes: 17 additions & 0 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

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
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 var isSetUp = false
private static var flushThread: Thread?

@objc public static func setup() {
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)

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
28 changes: 28 additions & 0 deletions packages/coverage-ios/ios/HarnessCoverageSetup.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#import <Foundation/Foundation.h>

@interface HarnessCoverageSetup : NSObject
@end

@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)];
#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
49 changes: 49 additions & 0 deletions packages/coverage-ios/package.json
Original file line number Diff line number Diff line change
@@ -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/callstackincubator/react-native-harness",
"repository": {
"type": "git",
"url": "https://github.com/callstackincubator/react-native-harness.git"
},
Comment thread
mfazekas marked this conversation as resolved.
"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,
},
},
};
116 changes: 116 additions & 0 deletions packages/coverage-ios/scripts/harness_coverage_hook.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
module HarnessCoverageHook
def run_podfile_post_install_hooks
super

pods = resolve_coverage_pods
return if pods.empty?

Pod::UI.puts "[HarnessCoverage] Instrumenting pods for native coverage: #{pods.join(', ')}"

apply_coverage_flags_to_pods(pods)
enable_harness_coverage_pod
apply_linker_flags
end

private

def resolve_coverage_pods
script = File.expand_path('resolve-coverage-pods.mjs', __dir__)
config_json = `node #{script}`.strip
JSON.parse(config_json)
rescue => e
Pod::UI.warn "[HarnessCoverage] Failed to read config: #{e.message}"
[]
end
Comment on lines +17 to +24
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether we can do something about this. It doesn't seem right to assume the structure of the config file. Maybe we should create a separate reac-config.ts file, include the types from the config package there, and run that instead of using this inlined script?


def apply_coverage_flags_to_pods(pods)
pods_project.targets.each do |target|
next unless pods.include?(target.name)

target.build_configurations.each do |config|
swift_flags = config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'
unless swift_flags.include?('-profile-generate')
config.build_settings['OTHER_SWIFT_FLAGS'] =
"#{swift_flags} -profile-generate -profile-coverage-mapping"
end

c_flags = config.build_settings['OTHER_CFLAGS'] || '$(inherited)'
unless c_flags.include?('-fprofile-instr-generate')
config.build_settings['OTHER_CFLAGS'] =
"#{c_flags} -fprofile-instr-generate -fcoverage-mapping"
end
end

Pod::UI.puts "[HarnessCoverage] -> #{target.name}"
end
end

def enable_harness_coverage_pod
pods_project.targets.each do |target|
next unless target.name == 'HarnessCoverage'

target.build_configurations.each do |config|
swift_conditions = config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] || '$(inherited)'
unless swift_conditions.include?('HARNESS_COVERAGE')
config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] =
"#{swift_conditions} HARNESS_COVERAGE"
end

gcc_defs = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] || '$(inherited)'
unless gcc_defs.include?('HARNESS_COVERAGE')
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] =
"#{gcc_defs} HARNESS_COVERAGE=1"
end
end
end
end

def apply_linker_flags
pods_project.targets.each do |target|
target.build_configurations.each do |config|
ldflags = config.build_settings['OTHER_LDFLAGS'] || '$(inherited)'
unless ldflags.include?('-fprofile-instr-generate')
config.build_settings['OTHER_LDFLAGS'] =
"#{ldflags} -fprofile-instr-generate"
end
end
end

apply_app_target_linker_flags
end

def apply_app_target_linker_flags
sandbox_root = config.sandbox.root
target_support_dir = sandbox_root.join('Target Support Files')

Dir.glob(target_support_dir.join('Pods-*', '*.xcconfig').to_s).each do |xcconfig_path|
content = File.read(xcconfig_path)

modified = false

unless content.include?('-fprofile-instr-generate')
content = content.gsub(
/^(OTHER_LDFLAGS\s*=\s*)/,
"\\1-fprofile-instr-generate "
)
modified = true
end

force_load = '-force_load "${PODS_CONFIGURATION_BUILD_DIR}/HarnessCoverage/libHarnessCoverage.a"'
unless content.include?('libHarnessCoverage.a')
content = content.gsub(
/^(OTHER_LDFLAGS\s*=\s*)/,
"\\1#{force_load} "
)
modified = true
end

if modified
File.write(xcconfig_path, content)
Pod::UI.puts "[HarnessCoverage] -> patched #{File.basename(xcconfig_path)}"
end
end
end
end

Pod::Installer.prepend(HarnessCoverageHook)
9 changes: 9 additions & 0 deletions packages/coverage-ios/scripts/resolve-coverage-pods.mjs
Original file line number Diff line number Diff line change
@@ -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('[]');
}
1 change: 1 addition & 0 deletions packages/coverage-ios/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};
10 changes: 10 additions & 0 deletions packages/coverage-ios/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
14 changes: 14 additions & 0 deletions packages/coverage-ios/tsconfig.lib.json
Original file line number Diff line number Diff line change
@@ -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"]
}
19 changes: 19 additions & 0 deletions packages/jest/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import path from 'node:path';
import {
logMetroCacheReused,
logMetroPortFallback,
logNativeCoverageCollected,
logRunnerStarting,
logRunnerStillWaitingInQueue,
logRunnerWaitingInQueue,
Expand Down Expand Up @@ -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([
Expand Down
Loading
Loading