From 34a59e7a6c291e00c0a731bbada17980315d76ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 25 Mar 2026 11:28:43 +0100 Subject: [PATCH 1/2] chore: add Swift code coverage via LLVM profiling Opt-in with RIVE_SWIFT_COVERAGE=1 before pod install. Uses LLVM source-based coverage to measure which Swift code in ios/ gets exercised by harness tests. Run scripts/ios-coverage.sh after tests to extract and report coverage. --- .gitignore | 3 +++ RNRive.podspec | 9 +++++++ example/ios/Podfile | 11 +++++++++ ios/CoverageHelper.swift | 53 ++++++++++++++++++++++++++++++++++++++++ ios/CoverageSetup.m | 21 ++++++++++++++++ scripts/ios-coverage.sh | 52 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+) create mode 100644 ios/CoverageHelper.swift create mode 100644 ios/CoverageSetup.m create mode 100755 scripts/ios-coverage.sh diff --git a/.gitignore b/.gitignore index 0610dbd3..d644f9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ example/src/reproducers/local/* # Test coverage coverage/ +coverage.profdata +coverage-swift.lcov +coverage-html/ diff --git a/RNRive.podspec b/RNRive.podspec index c01a610e..0daa1451 100644 --- a/RNRive.podspec +++ b/RNRive.podspec @@ -72,4 +72,13 @@ Pod::Spec.new do |s| s.dependency "RiveRuntime", rive_ios_version install_modules_dependencies(s) + + if ENV['RIVE_SWIFT_COVERAGE'] == '1' + existing = s.attributes_hash['pod_target_xcconfig'] || {} + s.pod_target_xcconfig = existing.merge({ + 'OTHER_SWIFT_FLAGS' => '$(inherited) -profile-generate -profile-coverage-mapping -DRIVE_COVERAGE', + 'OTHER_LDFLAGS' => '$(inherited) -fprofile-instr-generate', + }) + Pod::UI.puts "@rive-app/react-native: Swift code coverage ENABLED" + end end diff --git a/example/ios/Podfile b/example/ios/Podfile index c04206ab..9be25cd5 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -33,5 +33,16 @@ target 'RiveExample' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) + + if ENV['RIVE_SWIFT_COVERAGE'] == '1' + app_project = Xcodeproj::Project.open("#{installer.sandbox.root}/../RiveExample.xcodeproj") + app_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['OTHER_LDFLAGS'] = ['$(inherited)', '-fprofile-instr-generate', '-Xlinker', '-u', '-Xlinker', '___llvm_profile_runtime'] + end + end + app_project.save + Pod::UI.puts "Swift code coverage: added -fprofile-instr-generate to RiveExample linker flags" + end end end diff --git a/ios/CoverageHelper.swift b/ios/CoverageHelper.swift new file mode 100644 index 00000000..45de0bf8 --- /dev/null +++ b/ios/CoverageHelper.swift @@ -0,0 +1,53 @@ +#if RIVE_COVERAGE +import Foundation +import UIKit + +@_silgen_name("__llvm_profile_write_file") +func llvmProfileWriteFile() -> Int32 + +@_silgen_name("__llvm_profile_set_filename") +func llvmProfileSetFilename(_ filename: UnsafePointer) + +@objc final class CoverageHelper: NSObject { + private static var profrawPath: String? + + @objc static func setup() { + guard let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + // Each process gets its own profraw (merged later) so fast restarts don't lose data + let pid = ProcessInfo.processInfo.processIdentifier + profrawPath = docsDir.appendingPathComponent("coverage-\(pid).profraw").path + + NotificationCenter.default.addObserver( + forName: UIApplication.willTerminateNotification, object: nil, queue: nil + ) { _ in flush() } + + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil + ) { _ in flush() } + + // Catch SIGTERM (sent by simctl terminate) — runs on a background dispatch queue + let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global()) + source.setEventHandler { flush(); exit(0) } + source.resume() + signal(SIGTERM, SIG_IGN) // let DispatchSource handle it instead of default handler + + // Background thread with its own runloop for periodic flushes + let thread = Thread { + let timer = Timer(timeInterval: 1.0, repeats: true) { _ in flush() } + RunLoop.current.add(timer, forMode: .default) + RunLoop.current.run() + } + thread.name = "CoverageFlush" + thread.qualityOfService = .background + thread.start() + + NSLog("[Coverage] pid=%d, flushing to %@", pid, profrawPath ?? "nil") + } + + @objc static func flush() { + guard let path = profrawPath else { return } + path.withCString { llvmProfileSetFilename($0) } + llvmProfileWriteFile() + } +} +#endif diff --git a/ios/CoverageSetup.m b/ios/CoverageSetup.m new file mode 100644 index 00000000..760d6083 --- /dev/null +++ b/ios/CoverageSetup.m @@ -0,0 +1,21 @@ +#import + +// Forward-declare the Swift-generated class method. +// CoverageHelper is only compiled when RIVE_COVERAGE is defined. +@interface CoverageHelper : NSObject ++ (void)setup; +@end + +@interface RNRiveCoverageSetup : NSObject +@end + +@implementation RNRiveCoverageSetup + ++ (void)load { + Class cls = NSClassFromString(@"RNRive.CoverageHelper"); + if (cls && [cls respondsToSelector:@selector(setup)]) { + [cls performSelector:@selector(setup)]; + } +} + +@end diff --git a/scripts/ios-coverage.sh b/scripts/ios-coverage.sh new file mode 100755 index 00000000..ffa4a182 --- /dev/null +++ b/scripts/ios-coverage.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -euo pipefail + +BUNDLE_ID="${1:-rive.example}" +DERIVED_DATA="${2:-example/ios/build}" +REPO_ROOT=$(git rev-parse --show-toplevel) +ARCH="${3:-arm64}" + +# Background the app to trigger didEnterBackground flush, then terminate for SIGTERM flush +xcrun simctl launch booted com.apple.Preferences 2>/dev/null || true +sleep 1 +xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true +sleep 1 + +APP_CONTAINER=$(xcrun simctl get_app_container booted "$BUNDLE_ID" data) +DOCS_DIR="$APP_CONTAINER/Documents" + +# Collect all profraw files (one per process) +PROFRAW_FILES=$(find "$DOCS_DIR" -name "*.profraw" 2>/dev/null) +[ -n "$PROFRAW_FILES" ] || { echo "No profraw files in $DOCS_DIR"; exit 1; } +echo "Found $(echo "$PROFRAW_FILES" | wc -l | tr -d ' ') profraw file(s)" + +# Xcode 26+ uses a debug.dylib that contains the actual code and coverage data. +APP_DIR=$(find "$DERIVED_DATA" -name "RiveExample.app" -path "*/Debug-iphonesimulator/*" -type d | head -1) +[ -n "$APP_DIR" ] || { echo "RiveExample.app not found in $DERIVED_DATA"; exit 1; } + +if [ -f "$APP_DIR/RiveExample.debug.dylib" ]; then + BINARY="$APP_DIR/RiveExample.debug.dylib" +else + BINARY="$APP_DIR/RiveExample" +fi +echo "Using binary: $BINARY (arch: $ARCH)" + +# Merge all profraw files into one profdata +# shellcheck disable=SC2086 +xcrun llvm-profdata merge -sparse $PROFRAW_FILES -o "$REPO_ROOT/coverage.profdata" + +xcrun llvm-cov export "$BINARY" \ + -instr-profile="$REPO_ROOT/coverage.profdata" \ + -format=lcov \ + -arch "$ARCH" \ + -ignore-filename-regex='.*/(Pods|nitrogen|node_modules|DerivedData)/.*' \ + -sources "$REPO_ROOT/ios/" \ + > "$REPO_ROOT/coverage-swift.lcov" + +xcrun llvm-cov report "$BINARY" \ + -instr-profile="$REPO_ROOT/coverage.profdata" \ + -arch "$ARCH" \ + -ignore-filename-regex='.*/(Pods|nitrogen|node_modules|DerivedData)/.*' \ + -sources "$REPO_ROOT/ios/" + +echo "→ coverage-swift.lcov" From a418986f3b7315ef097066ac78e16f31ea1cb6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 25 Mar 2026 13:06:44 +0100 Subject: [PATCH 2/2] docs: add Swift code coverage instructions to CONTRIBUTING.md --- CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75f29e77..aa29d2a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,37 @@ The `package.json` file contains various scripts for common tasks: - `yarn example android`: run the example app on Android. - `yarn example ios`: run the example app on iOS. +### Swift code coverage + +You can measure which Swift code in `ios/` gets exercised by the harness tests. This uses LLVM source-based profiling and is opt-in. + +```sh +# 1. Install pods with coverage enabled +cd example/ios && RIVE_SWIFT_COVERAGE=1 bundle exec pod install && cd ../.. + +# 2. Build the example app +yarn example build:ios + +# 3. Run harness tests +yarn test:harness:ios + +# 4. Extract coverage and print a summary +bash scripts/ios-coverage.sh +``` + +This produces `coverage-swift.lcov` (machine-readable) in the repo root. For an HTML report: + +```sh +xcrun llvm-cov show \ + example/ios/build/Build/Products/Debug-iphonesimulator/RiveExample.app/RiveExample.debug.dylib \ + -instr-profile=coverage.profdata -arch arm64 -format=html -output-dir=coverage-html \ + -ignore-filename-regex='.*/(Pods|nitrogen|node_modules|DerivedData)/.*' \ + -sources "$PWD/ios/" +open coverage-html/index.html +``` + +To go back to normal (non-coverage) builds, just run `pod install` without the env var. + ### Sending a pull request When you're sending a pull request: