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 @@ -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(),

Expand Down
21 changes: 21 additions & 0 deletions packages/coverage-ios/HarnessCoverage.podspec
Original file line number Diff line number Diff line change
@@ -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}" }
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.

🫢


s.source_files = "ios/**/*.{h,m,mm,swift}"

install_modules_dependencies(s)
end

require_relative 'scripts/harness_coverage_hook'
59 changes: 59 additions & 0 deletions packages/coverage-ios/ios/HarnessCoverageHelper.swift
Original file line number Diff line number Diff line change
@@ -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<CChar>)

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

@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
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/margelo/react-native-harness",
"repository": {
"type": "git",
"url": "https://github.com/margelo/react-native-harness.git"
},
Comment on lines +39 to +47
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.

🙈

"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,
},
},
};
87 changes: 87 additions & 0 deletions packages/coverage-ios/scripts/harness_coverage_hook.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +17 to +30
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
end
end

Pod::Installer.prepend(HarnessCoverageHook)
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"]
}
31 changes: 30 additions & 1 deletion packages/jest/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestExecutionOptions, 'platform'>;

Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions packages/jest/src/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
};
Loading
Loading