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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,6 @@ example/src/reproducers/local/*

# Test coverage
coverage/
coverage.profdata
coverage-swift.lcov
coverage-html/
31 changes: 31 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions RNRive.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions example/ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 53 additions & 0 deletions ios/CoverageHelper.swift
Original file line number Diff line number Diff line change
@@ -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<CChar>)

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

// 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
52 changes: 52 additions & 0 deletions scripts/ios-coverage.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading