diff --git a/.github/homebrew-rnfb/Formula/applesimutils.rb b/.github/homebrew-rnfb/Formula/applesimutils.rb new file mode 100644 index 0000000000..6cb1f3b7e0 --- /dev/null +++ b/.github/homebrew-rnfb/Formula/applesimutils.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +# RNFB CI vendored formula — do not install from third-party taps in workflows. +# Upstream: wix/homebrew-brew @ 8f636f84541e — AppleSimulatorUtils 0.9.12 +# Update: see okf-bundle/ci-workflows/ios.md#pinned-homebrew-utilities + +class Applesimutils < Formula + desc "Apple simulator utilities" + homepage "https://github.com/wix/AppleSimulatorUtils" + url "https://github.com/wix/AppleSimulatorUtils/releases/download/0.9.12/AppleSimulatorUtils-0.9.12.tar.gz" + sha256 "4d6d02311959388ff5c28e2f4781848dbe1ca07f34b1d81d273940e099020b09" + + bottle do + root_url "https://github.com/wix/AppleSimulatorUtils/releases/download/0.9.12" + + sha256 arm64_big_sur: "3373d85ea6051e77865b0a22960eb0b5d63f3126c7c99232d0b6b9c83ee2c133" + sha256 catalina: "530e29950dba6d11ca6da6841d24b84d835f8215836664d6e6ce8b23acce4a51" + sha256 mojave: "3373d85ea6051e77865b0a22960eb0b5d63f3126c7c99232d0b6b9c83ee2c133" + sha256 high_sierra: "3373d85ea6051e77865b0a22960eb0b5d63f3126c7c99232d0b6b9c83ee2c133" + sha256 sierra: "3373d85ea6051e77865b0a22960eb0b5d63f3126c7c99232d0b6b9c83ee2c133" + sha256 big_sur: "3373d85ea6051e77865b0a22960eb0b5d63f3126c7c99232d0b6b9c83ee2c133" + end + + depends_on xcode: ["8.0", :build] + + def install + system "./buildForBrew.sh", prefix + end + + test do + system "#{bin}/applesimutils", "--help" + end +end diff --git a/.github/homebrew-rnfb/Formula/xcbeautify.rb b/.github/homebrew-rnfb/Formula/xcbeautify.rb new file mode 100644 index 0000000000..fc7f016db6 --- /dev/null +++ b/.github/homebrew-rnfb/Formula/xcbeautify.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true +# RNFB CI vendored formula — do not install from live homebrew-core in workflows. +# Upstream: Homebrew/homebrew-core @ f2e343d17882 — xcbeautify 3.2.1 +# Update: see okf-bundle/ci-workflows/ios.md#pinned-homebrew-utilities + +class Xcbeautify < Formula + desc "Little beautifier tool for xcodebuild" + homepage "https://github.com/cpisciotta/xcbeautify" + url "https://github.com/cpisciotta/xcbeautify/archive/refs/tags/3.2.1.tar.gz" + sha256 "7575dcb90e4650f8d8f66b92ca2f3eaa5ad9feddda7bf3c63aeb0edca199aa80" + license "MIT" + + bottle do + sha256 cellar: :any_skip_relocation, arm64_tahoe: "55d10b6b29942408802a3f7c141c8245b310054331c39be16f027f93867ad005" + sha256 cellar: :any_skip_relocation, arm64_sequoia: "f1094ef28d3e6f734cc58b43201a7112218b2518ed5b47b0c4e3242071a90742" + sha256 cellar: :any, arm64_sonoma: "f65b81e0e1d354fc026fda8e4006579b99e764a5bee9cdb20343a432c902f84a" + sha256 cellar: :any, sonoma: "da329e9b36ffc742e9dcba04f9bc2d2dcb05e41ecb9d902b5ab016145a46ac2c" + sha256 cellar: :any_skip_relocation, arm64_linux: "0699fc7ed411b8e6875d2273dd230b83947a10f38471e59ff953a57283cb4c26" + sha256 cellar: :any_skip_relocation, x86_64_linux: "1121ea99822c46089101d09aa4d43ad5679c5883f6c2212e712ba016db5a3ffe" + end + + # needs Swift tools version 6.1.0 + uses_from_macos "swift" => :build, since: :sequoia + uses_from_macos "libxml2" + + on_sequoia do + # Workaround for https://github.com/apple/swift-argument-parser/issues/827 + # Conditional should really be Swift >= 6.2 but not available so using + # a check on the specific ld version included with Xcode >= 26 + depends_on xcode: :build if DevelopmentTools.ld64_version >= "1221.4" + end + + def install + args = if OS.mac? + %w[--disable-sandbox] + else + %w[--static-swift-stdlib -Xswiftc -use-ld=ld] + end + system "swift", "build", *args, "--configuration", "release" + bin.install ".build/release/xcbeautify" + generate_completions_from_executable(bin/"xcbeautify", "--generate-completion-script") + end + + test do + log = "CompileStoryboard /Users/admin/MyApp/MyApp/Main.storyboard (in target: MyApp)" + assert_match "[MyApp] Compiling Main.storyboard", + pipe_output("#{bin}/xcbeautify --disable-colored-output", log).chomp + assert_match version.to_s, + shell_output("#{bin}/xcbeautify --version").chomp + end +end diff --git a/.github/workflows/create_test_patches.yml b/.github/workflows/create_test_patches.yml index f251b0457a..bea076e2e9 100644 --- a/.github/workflows/create_test_patches.yml +++ b/.github/workflows/create_test_patches.yml @@ -29,13 +29,13 @@ jobs: runs-on: ubuntu-latest steps: # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 # Future ideas: # - make into an action, parameterize directories to pack, and package names to install # - name patches w/PR as "semver prerelease" and SHA as "semver build info". Needs patch-package enhancement. # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 diff --git a/.github/workflows/deploy-api-reference.yml b/.github/workflows/deploy-api-reference.yml index c7251657b6..fcd72b0f30 100644 --- a/.github/workflows/deploy-api-reference.yml +++ b/.github/workflows/deploy-api-reference.yml @@ -19,11 +19,11 @@ jobs: runs-on: ubuntu-latest steps: # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 0 # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: lts/* registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 589c6f7b8f..9205c7f6d1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,11 +19,11 @@ jobs: runs-on: ubuntu-latest steps: # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 1 # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 # https://github.com/actions/cache/releases diff --git a/.github/workflows/issue-labels.yaml b/.github/workflows/issue-labels.yaml index 3c3aa5c265..44f21150ca 100644 --- a/.github/workflows/issue-labels.yaml +++ b/.github/workflows/issue-labels.yaml @@ -23,7 +23,7 @@ jobs: - name: Add 'Needs Attention' label if OP responded and it was open if: env.op_comment == 'true' && github.event.issue.state == 'open' # https://github.com/actions-ecosystem/action-add-labels/releases - uses: actions-ecosystem/action-add-labels@18f1af5e3544586314bbe15c0273249c770b2daf # v1 + uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7d8 # v1.1.0 with: labels: 'Needs Attention' env: @@ -32,7 +32,7 @@ jobs: - name: Remove 'blocked customer-response' label if OP responded and it was open if: env.op_comment == 'true' && github.event.issue.state == 'open' # https://github.com/actions-ecosystem/action-remove-labels/releases - uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 # v1 + uses: actions-ecosystem/action-remove-labels@f5dccab59b9ed79c1a5ddd2ab6d8771449b0250f # v1.3.0 with: labels: 'blocked: customer-response' env: @@ -41,7 +41,7 @@ jobs: - name: Add comment if OP responded but issue was closed if: env.op_comment == 'true' && github.event.issue.state == 'closed' # https://github.com/actions-ecosystem/action-create-comment/releases - uses: actions-ecosystem/action-create-comment@e23bc59fbff7aac7f9044bd66c2dc0fe1286f80b # v1 + uses: actions-ecosystem/action-create-comment@5b43c092bf96ebc715dbbe5682ecf3b771223855 # v1.0.0 with: github_token: ${{ secrets.GH_TOKEN }} body: | diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 0bb2d4acd5..6b583bbee7 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -20,16 +20,16 @@ jobs: runs-on: ubuntu-latest steps: # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 1 # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 - name: Configure JDK # https://github.com/actions/setup-java/releases - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '21' @@ -74,11 +74,11 @@ jobs: timeout-minutes: 30 steps: # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 1 # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 # https://github.com/actions/cache/releases @@ -116,11 +116,11 @@ jobs: timeout-minutes: 30 steps: # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 1 # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 # https://github.com/actions/cache/releases @@ -158,11 +158,11 @@ jobs: timeout-minutes: 30 steps: # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 1 # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 # https://github.com/actions/cache/releases diff --git a/.github/workflows/pr_title.yml b/.github/workflows/pr_title.yml index 941a9be749..cddbee627a 100644 --- a/.github/workflows/pr_title.yml +++ b/.github/workflows/pr_title.yml @@ -18,7 +18,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} steps: # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 # https://github.com/amannn/action-semantic-pull-request/releases diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5ffb4ccb15..611908919e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,13 +14,13 @@ jobs: contents: read steps: # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 0 # Repository admin required to evade PR+checks branch protection token: ${{ secrets.GH_TOKEN }} # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: lts/* registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/rnfb-js-sdk-comparison.yml b/.github/workflows/rnfb-js-sdk-comparison.yml index 7f74d9f652..8b7ce19c52 100644 --- a/.github/workflows/rnfb-js-sdk-comparison.yml +++ b/.github/workflows/rnfb-js-sdk-comparison.yml @@ -32,11 +32,11 @@ jobs: steps: # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 50 # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 # https://github.com/actions/cache/releases diff --git a/.github/workflows/scripts/boot-simulator.sh b/.github/workflows/scripts/boot-simulator.sh index 915f2bface..739956df07 100755 --- a/.github/workflows/scripts/boot-simulator.sh +++ b/.github/workflows/scripts/boot-simulator.sh @@ -1,30 +1,158 @@ #!/bin/bash -# Any command here that exits non-zero is an error -set -e +# Boot the Detox iOS simulator, wait until it is fully ready for testing (including +# first-boot data migration on fresh simulators), then install the test app. +# Uses the device *name* from tests/.detoxrc.js — no pinned UDID in the workflow. +set -euo pipefail + +BOOT_POLL_INTERVAL_SECONDS="${BOOT_POLL_INTERVAL_SECONDS:-20}" +BOOT_PROBE_TIMEOUT_SECONDS="${BOOT_PROBE_TIMEOUT_SECONDS:-12}" +BOOT_MAX_WAIT_SECONDS="${BOOT_MAX_WAIT_SECONDS:-660}" + +run_with_timeout() { + local max="$1" + shift + "$@" & + local cmd_pid=$! + local waited=0 + while kill -0 "$cmd_pid" 2>/dev/null && (( waited < max )); do + sleep 1 + waited=$((waited + 1)) + done + if kill -0 "$cmd_pid" 2>/dev/null; then + kill "$cmd_pid" 2>/dev/null + wait "$cmd_pid" 2>/dev/null || true + return 124 + fi + wait "$cmd_pid" +} + +log_boot_status() { + echo "[boot-status] $*" +} + +describe_booted_device() { + local device="$1" + xcrun simctl list devices booted 2>/dev/null \ + | grep -i "${device} (" \ + | grep -v 'Phone:' \ + | grep -v 'unavailable' \ + | grep -v CoreSimulator \ + | head -1 \ + || true +} + +log_migration_status() { + local device="$1" + local migration_output probe_rc + + log_boot_status "probing data migration (bootstatus -d, up to ${BOOT_PROBE_TIMEOUT_SECONDS}s)..." + set +e + migration_output="$(run_with_timeout "$BOOT_PROBE_TIMEOUT_SECONDS" xcrun simctl bootstatus "$device" -d 2>&1)" + probe_rc=$? + set -e + + if [[ "$probe_rc" -eq 124 ]]; then + log_boot_status " data migration / system bring-up still in progress" + return 1 + fi + + if [[ -n "$migration_output" ]]; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + log_boot_status " ${line}" + done <<<"$migration_output" + else + log_boot_status " no migration details reported" + fi + return 0 +} + +wait_for_simulator_ready() { + local device="$1" + local start=$SECONDS + + while (( SECONDS - start < BOOT_MAX_WAIT_SECONDS )); do + local elapsed=$(( SECONDS - start )) + local booted_line ready_rc + + log_boot_status "elapsed=${elapsed}s phase=wait_for_full_boot device=\"${device}\"" + + booted_line="$(describe_booted_device "$device")" + if [[ -z "$booted_line" ]]; then + log_boot_status " simctl list: not in Booted state yet" + else + log_boot_status " simctl list: ${booted_line}" + log_migration_status "$device" || true + fi + + set +e + run_with_timeout "$BOOT_PROBE_TIMEOUT_SECONDS" xcrun simctl bootstatus "$device" >/dev/null 2>&1 + ready_rc=$? + set -e + + if [[ "$ready_rc" -eq 0 ]]; then + log_boot_status "bootstatus: simulator ready after ${elapsed}s" + log_migration_status "$device" || true + return 0 + fi + + if [[ "$ready_rc" -eq 124 ]]; then + log_boot_status "bootstatus: still booting (probe timed out after ${BOOT_PROBE_TIMEOUT_SECONDS}s)" + else + log_boot_status "bootstatus: probe exited with status ${ready_rc}" + fi + + sleep "$BOOT_POLL_INTERVAL_SECONDS" + done + + log_boot_status "ERROR: timed out after ${BOOT_MAX_WAIT_SECONDS}s waiting for simulator to become ready" + return 1 +} # Get our simulator name from our test Detox config -pushd "$(dirname "$0")/../../../tests" || exit 1 -SIM="$(cat .detoxrc.js | grep iPhone | cut -d"'" -f2)" -echo "Attempting to boot iOS Simulator $SIM..." +pushd "$(dirname "$0")/../../../tests" >/dev/null || exit 1 +SIM="$(grep iPhone .detoxrc.js | head -1 | cut -d"'" -f2)" +popd >/dev/null || exit 1 + +log_boot_status "phase=resolve_device name=\"${SIM}\" (from tests/.detoxrc.js)" # Clear up any existing attempts in case we are re-trying -echo "...killing any existing Simulator processes..." -killall Simulator || true +log_boot_status "phase=shutdown_existing killing Simulator.app if running..." +killall Simulator 2>/dev/null || true +xcrun simctl shutdown "$SIM" 2>/dev/null || true + +log_boot_status "phase=boot_command starting simctl boot..." +set +e +boot_output="$(xcrun simctl boot "$SIM" 2>&1)" +boot_rc=$? +set -e +if [[ "$boot_rc" -ne 0 ]]; then + log_boot_status "simctl boot exited ${boot_rc}: ${boot_output}" +else + log_boot_status "simctl boot command returned (device may still be migrating data)" +fi + +log_boot_status "phase=foreground_simulator opening Simulator.app..." +open -a Simulator.app -# Boot the simulator if not booted, make sure it is in the foreground -echo "...booting $SIM and foregrounding Simulator..." -(xcrun simctl boot "$SIM" || true) && open -a Simulator.app +if ! wait_for_simulator_ready "$SIM"; then + exit 1 +fi -# Is it booted? -echo "...waiting to make sure $SIM is booted..." -xcrun simctl list |grep -i "$SIM ("|grep -v 'Phone:'|grep -v 'unavailable'|grep -v CoreSimulator|grep Booted +pushd "$(dirname "$0")/../../../tests" >/dev/null || exit 1 +BUILDDIR="$(find ios/build/Build/Products -type d -name 'testing.app' 2>/dev/null | head -1)" -# Are we a Debug or Release build? -BUILDDIR="$( find ios/build/Build/Products -type d |grep 'testing.app$' | head -1)" +if [[ -z "$BUILDDIR" || ! -d "$BUILDDIR" ]]; then + log_boot_status "ERROR: could not find tests/ios/build/.../testing.app" + popd >/dev/null || exit 1 + exit 1 +fi -# Install our app (glob so Release or Debug works) -echo "...installing the Test app build on $SIM..." +log_boot_status "phase=install_app bundle=\"${BUILDDIR}\"" +install_start=$SECONDS xcrun simctl install "$SIM" "$BUILDDIR" +log_boot_status "install complete in $((SECONDS - install_start))s" +popd >/dev/null || exit 1 -echo "Successfully booted $SIM and installed test app." \ No newline at end of file +log_boot_status "phase=complete device=\"${SIM}\" ready with test app installed" diff --git a/.github/workflows/scripts/install-homebrew-rnfb.sh b/.github/workflows/scripts/install-homebrew-rnfb.sh new file mode 100755 index 0000000000..9db214a97f --- /dev/null +++ b/.github/workflows/scripts/install-homebrew-rnfb.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Install vendored RNFB Homebrew formulae (Brew 6+ requires a tap, not bare --formula paths). +# Pin/update docs: okf-bundle/ci-workflows/ios.md#pinned-homebrew-utilities +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 [ ...]" >&2 + exit 1 +fi + +TAP_ROOT="$(brew --repository)/Library/Taps/invertase/homebrew-rnfb" +mkdir -p "$TAP_ROOT" +cp -R .github/homebrew-rnfb/Formula "$TAP_ROOT/" + +export HOMEBREW_NO_AUTO_UPDATE=1 +brew trust invertase/rnfb + +# GHA images often ship homebrew-core formulae with the same names. Brew refuses to +# install invertase/rnfb/ while core is still in the Cellar. +for formula in "$@"; do + if brew list --formula "$formula" &>/dev/null; then + echo "Uninstalling existing ${formula} before invertase/rnfb install..." + brew uninstall --formula "$formula" + fi +done + +install_args=() +for formula in "$@"; do + install_args+=("invertase/rnfb/${formula}") +done +brew install "${install_args[@]}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a7c43eaab0..dc5f0410a7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: # https://github.com/actions/stale/releases - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: operations-per-run: 1000 stale-issue-message: | diff --git a/.github/workflows/tests_e2e_android.yml b/.github/workflows/tests_e2e_android.yml index 89a9c6202d..05da6e12f2 100644 --- a/.github/workflows/tests_e2e_android.yml +++ b/.github/workflows/tests_e2e_android.yml @@ -134,19 +134,19 @@ jobs: libxext6 libxfixes3 libxi6 libxkbfile1 pulseaudio socat zlib1g # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 50 # Set up tool versions # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 - name: Configure JDK # https://github.com/actions/setup-java/releases - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '21' @@ -263,7 +263,7 @@ jobs: yarn tests:android:test:jacoco-report # https://github.com/codecov/codecov-action/releases - - uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6.0.0 + - uses: codecov/codecov-action@e53489f4d376d79066609109e7a95a29eb3740b1 # v7.0.0 with: verbose: true diff --git a/.github/workflows/tests_e2e_ios.yml b/.github/workflows/tests_e2e_ios.yml index 6cf329cdb9..49a5c68493 100644 --- a/.github/workflows/tests_e2e_ios.yml +++ b/.github/workflows/tests_e2e_ios.yml @@ -87,10 +87,10 @@ jobs: # it will run unit tests on whatever OS combinations are desired ios: name: iOS (${{ matrix.buildmode }}, ${{ matrix.iteration }}) - runs-on: macos-15 + runs-on: macos-26 needs: matrix_prep # TODO matrix across APIs, at least 11 and 15 (lowest to highest) - timeout-minutes: 80 + timeout-minutes: 87 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CCACHE_SLOPPINESS: clang_index_store,file_stat_matches,include_file_ctime,include_file_mtime,ivfsoverlay,pch_defines,modules,system_headers,time_macros @@ -98,8 +98,7 @@ jobs: CCACHE_DEPEND: true CCACHE_INODECACHE: true CCACHE_LIMIT_MULTIPLE: 0.95 - # possibly pin to higher-performance versions (e.g. 26.0.1 was better than 26.2) - # but firebase-ios-sdk requires Xcode 26.2+ now, so unpinned at the moment. + # macos-26 + latest-stable matches local Xcode 26.5; firebase-ios-sdk requires Xcode 26.2+ XCODE_VERSION: latest-stable strategy: fail-fast: false @@ -107,7 +106,7 @@ jobs: steps: - name: Setup Environment for Screen Recording # https://github.com/guidepup/setup-action/releases - uses: guidepup/setup-action@4aa2ebd687ff687a31d7c17184beac3c606d64cd # v0.19.0 + uses: guidepup/setup-action@5ab89fbb6641406f6f6c91057225f3fb397c2eea # v0.20.0 continue-on-error: true with: record: true @@ -123,13 +122,13 @@ jobs: # Set up tool versions # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 - name: Configure JDK # https://github.com/actions/setup-java/releases - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '21' @@ -157,7 +156,7 @@ jobs: xcrun simctl list # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 50 @@ -208,7 +207,7 @@ jobs: - name: Setup Ruby # https://github.com/ruby/setup-ruby/releases - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0 + uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0 with: ruby-version: 3 @@ -265,6 +264,7 @@ jobs: launchctl unload -w /System/Library/LaunchAgents/com.apple.ReportCrash.plist sudo launchctl unload -w /System/Library/LaunchDaemons/com.apple.ReportCrash.Root.plist + # Vendored Homebrew formulae — pin/update: okf-bundle/ci-workflows/ios.md#pinned-homebrew-utilities - name: Install brew utilities # https://github.com/nick-fields/retry/releases uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 @@ -272,7 +272,7 @@ jobs: timeout_minutes: 5 retry_wait_seconds: 60 max_attempts: 3 - command: HOMEBREW_NO_AUTO_UPDATE=1 brew tap wix/brew && HOMEBREW_NO_AUTO_UPDATE=1 brew install applesimutils xcbeautify + command: bash .github/workflows/scripts/install-homebrew-rnfb.sh applesimutils xcbeautify - name: Build iOS App Debug if: contains(matrix.buildmode, 'debug') @@ -323,39 +323,43 @@ jobs: curl --output /dev/null --silent --head --fail "http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false&inlineSourceMap=true" echo "...javascript bundle ready" - - name: Start Screen and Simulator Recordings and System Logging - # With a little delay so the detox test below has time to spawn it, missing the first part of boot is fine + - name: Start Screen and System Logging continue-on-error: true run: | nohup sh -c "sleep 314159265 | screencapture -v -C -k -T0 -g screenrecording.mov > screenrecording.log 2>&1 &" nohup sh -c "log stream --backtrace --color none --style syslog > syslog.log 2>&1 &" - nohup sh -c "sleep 110 && xcrun simctl io booted recordVideo --codec=h264 -f simulator.mp4 2>&1 &" - - - name: Create Simulator Log - # With a little delay so the detox test below has time to spawn it, missing the first part of boot is fine - # If you boot the simulator separately from detox, some other race fails and detox testee never sends ready to proxy - continue-on-error: true - run: nohup sh -c "sleep 110 && xcrun simctl spawn booted log stream --level debug --style compact > simulator.log 2>&1 &" - name: Pre-Boot Simulator - # The goal here is to separate Simulator boot from Detox run, - # So that Simulator boot issues we seem to have may be handled separately + # Separate Simulator boot from Detox run. boot-simulator.sh polls bootstatus and logs + # migration progress so long first-boot waits are visible in the step log. # https://github.com/nick-fields/retry/releases uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: - timeout_minutes: 5 + timeout_minutes: 12 retry_wait_seconds: 60 max_attempts: 3 command: ./.github/workflows/scripts/boot-simulator.sh + - name: Start Simulator Recordings and Log + # Start after Pre-Boot so booted exists and logging covers the Detox run (not a fixed delay). + continue-on-error: true + run: | + nohup sh -c "xcrun simctl io booted recordVideo --codec=h264 -f simulator.mp4 2>&1 &" + nohup sh -c "xcrun simctl spawn booted log stream --level debug --style compact > simulator.log 2>&1 &" + - name: Detox Test Debug if: contains(matrix.buildmode, 'debug') - timeout-minutes: 55 + timeout-minutes: 62 run: yarn tests:ios:test-cover + - name: Process iOS native coverage + if: always() && contains(matrix.buildmode, 'debug') + continue-on-error: true + run: yarn tests:ios:test:process-coverage + - name: Detox Test Release if: contains(matrix.buildmode, 'release') - timeout-minutes: 55 + timeout-minutes: 62 run: yarn tests:ios:test:release - name: Stop Screen and App Video and System Logging @@ -403,7 +407,7 @@ jobs: path: .github/workflows/scripts/*.log # https://github.com/codecov/codecov-action/releases - - uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6.0.0 + - uses: codecov/codecov-action@e53489f4d376d79066609109e7a95a29eb3740b1 # v7.0.0 if: contains(matrix.buildmode, 'debug') continue-on-error: true with: diff --git a/.github/workflows/tests_e2e_other.yml b/.github/workflows/tests_e2e_other.yml index eb7fc3a801..2049249d14 100644 --- a/.github/workflows/tests_e2e_other.yml +++ b/.github/workflows/tests_e2e_other.yml @@ -99,7 +99,7 @@ jobs: steps: - name: Setup Environment for Screen Recording # https://github.com/guidepup/setup-action/releases - uses: guidepup/setup-action@4aa2ebd687ff687a31d7c17184beac3c606d64cd # v0.19.0 + uses: guidepup/setup-action@5ab89fbb6641406f6f6c91057225f3fb397c2eea # v0.20.0 continue-on-error: true with: record: true @@ -114,13 +114,13 @@ jobs: path: ./recordings/ # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 - name: Configure JDK # https://github.com/actions/setup-java/releases - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '21' @@ -131,7 +131,7 @@ jobs: xcode-version: 'latest-stable' # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 50 @@ -172,7 +172,7 @@ jobs: - name: Setup Ruby # https://github.com/ruby/setup-ruby/releases - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0 + uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0 with: ruby-version: 3 @@ -219,6 +219,7 @@ jobs: - name: Start Firestore Emulator run: yarn tests:emulator:start-ci + # Vendored Homebrew formulae — pin/update: okf-bundle/ci-workflows/ios.md#pinned-homebrew-utilities - name: Install brew utilities # https://github.com/nick-fields/retry/releases uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 @@ -226,7 +227,7 @@ jobs: timeout_minutes: 5 retry_wait_seconds: 60 max_attempts: 3 - command: HOMEBREW_NO_AUTO_UPDATE=1 brew tap wix/brew && HOMEBREW_NO_AUTO_UPDATE=1 brew install xcbeautify + command: bash .github/workflows/scripts/install-homebrew-rnfb.sh xcbeautify - name: Build macos App run: | @@ -273,7 +274,7 @@ jobs: run: yarn tests:macos:test-cover # https://github.com/codecov/codecov-action/releases - - uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6.0.0 + - uses: codecov/codecov-action@e53489f4d376d79066609109e7a95a29eb3740b1 # v7.0.0 continue-on-error: true with: verbose: true diff --git a/.github/workflows/tests_jest.yml b/.github/workflows/tests_jest.yml index d286cdb39f..b166cbb224 100644 --- a/.github/workflows/tests_jest.yml +++ b/.github/workflows/tests_jest.yml @@ -32,11 +32,11 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} steps: # https://github.com/actions/checkout/releases - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: fetch-depth: 50 # https://github.com/actions/setup-node/releases - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 # https://github.com/actions/cache/releases @@ -59,7 +59,7 @@ jobs: - name: Jest run: yarn tests:jest-coverage # https://github.com/codecov/codecov-action/releases - - uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6.0.0 + - uses: codecov/codecov-action@e53489f4d376d79066609109e7a95a29eb3740b1 # v7.0.0 with: verbose: true # https://github.com/actions/cache/releases diff --git a/.yarn/patches/detox-npm-20.51.0-3e13b6e309.patch b/.yarn/patches/detox-npm-20.51.0-3e13b6e309.patch index 00adf326cb..00c4cb24e6 100644 --- a/.yarn/patches/detox-npm-20.51.0-3e13b6e309.patch +++ b/.yarn/patches/detox-npm-20.51.0-3e13b6e309.patch @@ -102,3 +102,47 @@ index d6db5d5d7a4173eae88d97bf06eaeaf8b38c3b94..a85ebdf5a30607459be68463b3238b6f } override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { +diff --git a/src/server/handlers/AnonymousConnectionHandler.js b/src/server/handlers/AnonymousConnectionHandler.js +index 10743c87d17de135317e1bac3e65159b9872412f..abbb302307fbd5eabbbff875983f284a4bb9e738 100644 +--- a/src/server/handlers/AnonymousConnectionHandler.js ++++ b/src/server/handlers/AnonymousConnectionHandler.js +@@ -8,6 +8,7 @@ const TesterConnectionHandler = require('./TesterConnectionHandler'); + class AnonymousConnectionHandler { + constructor({ api }) { + this._api = api; ++ this._pendingEarlyReadyAction = null; + } + + handle(action) { +@@ -74,7 +75,17 @@ class AnonymousConnectionHandler { + }, + }); + ++ const pendingEarlyReadyAction = this._pendingEarlyReadyAction; ++ this._pendingEarlyReadyAction = null; ++ + session.notify(); ++ ++ // iOS DetoxManager sends proactive "ready" on webSocketDidConnect before the ++ // anonymous server's "login" handler runs; replay it after app login completes. ++ if (action.params.role === 'app' && pendingEarlyReadyAction && session.tester) { ++ this._api.log.debug('Replaying app "ready" action that arrived before login completed.'); ++ session.tester.sendAction(pendingEarlyReadyAction); ++ } + } + + _handleUnknownAction(action) { +@@ -85,8 +96,11 @@ class AnonymousConnectionHandler { + }); + } + +- _handleEarlyReadyAction() { +- this._api.log.debug('The app has dispatched "ready" action too early.'); ++ _handleEarlyReadyAction(action) { ++ this._api.log.debug( ++ 'The app has dispatched "ready" action too early; buffering until login completes.', ++ ); ++ this._pendingEarlyReadyAction = action; + } + } + diff --git a/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch b/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch new file mode 100644 index 0000000000..68560fd194 --- /dev/null +++ b/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch @@ -0,0 +1,174 @@ +diff --git a/lib/commonjs/cli.js b/lib/commonjs/cli.js +index 35c04da87c63a79bc26707aaffe3156fb98eca79..435c9959f456318c5e0069fce1f3e922891acf56 100644 +--- a/lib/commonjs/cli.js ++++ b/lib/commonjs/cli.js +@@ -72,6 +72,9 @@ function cleanup() { + }); + } + async function startServer(server, config, after) { ++ let reconnectGraceTimer = null; ++ let pendingDisconnectCode = null; ++ let disconnectStartedAt = 0; + server.on('started', s => { + const url = s.url; + console.log(`[🟩] Jet remote server listening at "${url}".`); +@@ -79,13 +82,46 @@ async function startServer(server, config, after) { + server.on('connection', (_, req) => { + console.log(`[🟩] Jet client connected from "${req.socket.remoteAddress + ':' + req.socket.remotePort}".`); ++ if (reconnectGraceTimer) { ++ const recoveredCode = pendingDisconnectCode; ++ const elapsedMs = Date.now() - disconnectStartedAt; ++ clearTimeout(reconnectGraceTimer); ++ reconnectGraceTimer = null; ++ pendingDisconnectCode = null; ++ console.warn(`[jet-ws] reconnect_recovered code=${recoveredCode} elapsed_ms=${elapsedMs}`); ++ } + }); + server.on('disconnection', (_, code, reason) => { + const print = code === 1000 ? console.log : console.warn; + const msg = code === 1000 ? 'normal closure' : reason || 'for no particular reason'; + print(`[🟨] Jet client disconnected - ${msg} (code = ${code}).`); +- if (code !== 1000 && config.exitOnError) { ++ if (code === 1000 || !config.exitOnError) { ++ return; ++ } ++ const graceMs = config.reconnectGraceMs ?? 15000; ++ const transientCodes = [1006, 1001]; ++ if (transientCodes.includes(code)) { ++ if (reconnectGraceTimer) { ++ clearTimeout(reconnectGraceTimer); ++ } ++ pendingDisconnectCode = code; ++ disconnectStartedAt = Date.now(); ++ console.warn( ++ `[jet-ws] transient_disconnect code=${code} reason="${reason || ''}" grace_ms=${graceMs} waiting_for_reconnect`, ++ ); ++ reconnectGraceTimer = setTimeout(() => { ++ reconnectGraceTimer = null; ++ console.error(`[jet-ws] fatal_disconnect code=${code} grace_expired_ms=${graceMs}`); ++ console.error(`[jet-ws] RETRYABLE_DISCONNECT code=${code}`); ++ print(`[🟥] Exiting after an abnormal disconnect.`); ++ process.exitCode = 1; ++ cleanup(); ++ }, graceMs); ++ return; ++ } ++ console.error(`[jet-ws] fatal_disconnect code=${code} reason="${reason || ''}" immediate=true`); ++ if (code !== 1000 && config.exitOnError) { + print(`[🟥] Exiting after an abnormal disconnect.`); + process.exitCode = 1; + cleanup(); + } + }); +@@ -99,8 +138,16 @@ async function startServer(server, config, after) { + if (config.exitOnError) { + process.exitCode = 1; + cleanup(); + } + }); ++ server.on('coverage-data', coverage => { ++ const incomingKeys = Object.keys(coverage).length; ++ console.log(`[jet-coverage] WS received ${incomingKeys} file(s)`); ++ coverageMap.merge(coverage); ++ global.__coverage__ = coverageMap.toJSON(); ++ const totalKeys = Object.keys(global.__coverage__).length; ++ console.log(`[jet-coverage] WS merged total ${totalKeys} file(s)`); ++ }); + cleanupTasks.add(async () => { + if (server.listening) { + await server.stop(); +@@ -129,8 +137,13 @@ function attachHttpServer(wss) { + }); + req.on('end', () => { + try { +- coverageMap.merge(JSON.parse(body)); ++ const incoming = JSON.parse(body); ++ const incomingKeys = Object.keys(incoming).length; ++ console.log(`[jet-coverage] POST body ${body.length} bytes, ${incomingKeys} file(s)`); ++ coverageMap.merge(incoming); + global.__coverage__ = coverageMap.toJSON(); ++ const totalKeys = Object.keys(global.__coverage__).length; ++ console.log(`[jet-coverage] POST received ${incomingKeys} file(s), merged total ${totalKeys}`); + res.end(JSON.stringify({ + message: 'OK' + })); +@@ -302,12 +315,11 @@ function attachHttpServer(wss) { + slow: finalConfig.slow + }); + return startServer(server, finalConfig, target.after).then(() => { +- if (finalConfig.coverage) { +- attachHttpServer(server.wss); +- } + if (!finalConfig.watch) { + server.run(async failures => { + global.__coverage__ = coverageMap.toJSON(); ++ const mergedKeys = Object.keys(global.__coverage__).length; ++ console.log(`[jet-coverage] merged ${mergedKeys} file(s) before NYC shutdown`); + await cleanup(); + process.exit(failures > 0 ? 1 : 0); + }); +diff --git a/lib/commonjs/index.js b/lib/commonjs/index.js +index a72380ed2ace01b19b028bb8e6f9d29dc3affbcd..030475cff75349b2b7f820cf8a3556fb1d7bea45 100644 +--- a/lib/commonjs/index.js ++++ b/lib/commonjs/index.js +@@ -63,15 +63,7 @@ function JetProvider(props) { + }); + after(async () => { + if (_config.coverage) { +- const coverage = global.__coverage__ ?? {}; +- const url = (props.url ?? 'ws://localhost:8090').replace('ws://', 'http://') + '/coverage'; +- return fetch(url, { +- method: 'POST', +- headers: { +- 'Content-Type': 'application/json' +- }, +- body: JSON.stringify(coverage) +- }); ++ return client.uploadCoverage(); + } + return Promise.resolve(); + }); +diff --git a/lib/module/index.js b/lib/module/index.js +index 897b62288ae128e1ce071142fa3de136caa99239..2f8dd9edf79f6c095b4357307bdac5ea1efa00eb 100644 +--- a/lib/module/index.js ++++ b/lib/module/index.js +@@ -51,15 +51,7 @@ export function JetProvider(props) { + }); + after(async () => { + if (_config.coverage) { +- const coverage = global.__coverage__ ?? {}; +- const url = (props.url ?? 'ws://localhost:8090').replace('ws://', 'http://') + '/coverage'; +- return fetch(url, { +- method: 'POST', +- headers: { +- 'Content-Type': 'application/json' +- }, +- body: JSON.stringify(coverage) +- }); ++ return client.uploadCoverage(); + } + return Promise.resolve(); + }); +diff --git a/src/index.tsx b/src/index.tsx +index 1b28aca3fe817fee3e8701ac5096df6fc608bc39..17c631309f88a8017a9fd01e3420df2b4bab140e 100644 +--- a/src/index.tsx ++++ b/src/index.tsx +@@ -82,17 +82,7 @@ export function JetProvider(props: JetProviderProps): React.JSX.Element { + }); + after(async () => { + if (_config.coverage) { +- const coverage = (global as any).__coverage__ ?? {}; +- const url = +- (props.url ?? 'ws://localhost:8090').replace('ws://', 'http://') + +- '/coverage'; +- return fetch(url, { +- method: 'POST', +- headers: { +- 'Content-Type': 'application/json', +- }, +- body: JSON.stringify(coverage), +- }); ++ return client.uploadCoverage(); + } + return Promise.resolve(); + }); diff --git a/.yarn/patches/mocha-remote-client-npm-1.13.2-a2e7596aba.patch b/.yarn/patches/mocha-remote-client-npm-1.13.2-a2e7596aba.patch new file mode 100644 index 0000000000..0905faa506 --- /dev/null +++ b/.yarn/patches/mocha-remote-client-npm-1.13.2-a2e7596aba.patch @@ -0,0 +1,153 @@ +diff --git a/dist/browser.bundle.mjs b/dist/browser.bundle.mjs +index eb7d0bd7a2f43daa9124a2171dc708db76e8882e..b315b0e647a479038d9d05f87a5f177e41d8da2f 100644 +--- a/dist/browser.bundle.mjs ++++ b/dist/browser.bundle.mjs +@@ -11563,6 +11563,19 @@ class Client extends ClientEventEmitter { + } + }); + } ++ else if (msg.action === "pull-coverage") { ++ const g = typeof globalThis !== "undefined" ? globalThis : global; ++ const coverage = g.__coverage__ ?? {}; ++ this.send({ action: "coverage-data", coverage }); ++ } ++ else if (msg.action === "coverage-ack") { ++ if (this._coverageUploadResolve) { ++ this._coverageUploadResolve(msg); ++ this._coverageUploadResolve = null; ++ this._coverageUploadReject = null; ++ this._coverageUploadPromise = null; ++ } ++ } + else if (msg.action === "error") { + if (typeof msg.message === "string") { + this.emit("error", new Error(msg.message)); +@@ -11645,6 +11658,26 @@ class Client extends ClientEventEmitter { + }, + }; + } ++ uploadCoverage() { ++ if (this._coverageUploadPromise) { ++ return this._coverageUploadPromise; ++ } ++ this._coverageUploadPromise = new Promise((resolve, reject) => { ++ this._coverageUploadResolve = resolve; ++ this._coverageUploadReject = reject; ++ this.send({ action: "coverage-ready" }); ++ setTimeout(() => { ++ if (this._coverageUploadResolve) { ++ const err = new Error("coverage upload timed out waiting for coverage-ack"); ++ this._coverageUploadReject(err); ++ this._coverageUploadResolve = null; ++ this._coverageUploadReject = null; ++ this._coverageUploadPromise = null; ++ } ++ }, 120000); ++ }); ++ return this._coverageUploadPromise; ++ } + } + + Client.WebSocket = WebSocket; +diff --git a/dist/node.bundle.cjs b/dist/node.bundle.cjs +index a692e25a30f24e8299661c190ec09c3be5e5e2da..e80e7c24bf8534d42db32dd077ace1bdea50a028 100644 +--- a/dist/node.bundle.cjs ++++ b/dist/node.bundle.cjs +@@ -5900,6 +5900,19 @@ class Client extends ClientEventEmitter { + } + }); + } ++ else if (msg.action === "pull-coverage") { ++ const g = typeof globalThis !== "undefined" ? globalThis : global; ++ const coverage = g.__coverage__ ?? {}; ++ this.send({ action: "coverage-data", coverage }); ++ } ++ else if (msg.action === "coverage-ack") { ++ if (this._coverageUploadResolve) { ++ this._coverageUploadResolve(msg); ++ this._coverageUploadResolve = null; ++ this._coverageUploadReject = null; ++ this._coverageUploadPromise = null; ++ } ++ } + else if (msg.action === "error") { + if (typeof msg.message === "string") { + this.emit("error", new Error(msg.message)); +@@ -5982,6 +5995,26 @@ class Client extends ClientEventEmitter { + }, + }; + } ++ uploadCoverage() { ++ if (this._coverageUploadPromise) { ++ return this._coverageUploadPromise; ++ } ++ this._coverageUploadPromise = new Promise((resolve, reject) => { ++ this._coverageUploadResolve = resolve; ++ this._coverageUploadReject = reject; ++ this.send({ action: "coverage-ready" }); ++ setTimeout(() => { ++ if (this._coverageUploadResolve) { ++ const err = new Error("coverage upload timed out waiting for coverage-ack"); ++ this._coverageUploadReject(err); ++ this._coverageUploadResolve = null; ++ this._coverageUploadReject = null; ++ this._coverageUploadPromise = null; ++ } ++ }, 120000); ++ }); ++ return this._coverageUploadPromise; ++ } + } + + Client.WebSocket = WebSocket; +diff --git a/dist/node.bundle.mjs b/dist/node.bundle.mjs +index 77d3d8299505196a826971ba46e0ff14b75c3d0a..8e60e33e49fb5b9c3b4a7d6cfc0207e804f16162 100644 +--- a/dist/node.bundle.mjs ++++ b/dist/node.bundle.mjs +@@ -5879,6 +5879,19 @@ class Client extends ClientEventEmitter { + } + }); + } ++ else if (msg.action === "pull-coverage") { ++ const g = typeof globalThis !== "undefined" ? globalThis : global; ++ const coverage = g.__coverage__ ?? {}; ++ this.send({ action: "coverage-data", coverage }); ++ } ++ else if (msg.action === "coverage-ack") { ++ if (this._coverageUploadResolve) { ++ this._coverageUploadResolve(msg); ++ this._coverageUploadResolve = null; ++ this._coverageUploadReject = null; ++ this._coverageUploadPromise = null; ++ } ++ } + else if (msg.action === "error") { + if (typeof msg.message === "string") { + this.emit("error", new Error(msg.message)); +@@ -5961,6 +5974,26 @@ class Client extends ClientEventEmitter { + }, + }; + } ++ uploadCoverage() { ++ if (this._coverageUploadPromise) { ++ return this._coverageUploadPromise; ++ } ++ this._coverageUploadPromise = new Promise((resolve, reject) => { ++ this._coverageUploadResolve = resolve; ++ this._coverageUploadReject = reject; ++ this.send({ action: "coverage-ready" }); ++ setTimeout(() => { ++ if (this._coverageUploadResolve) { ++ const err = new Error("coverage upload timed out waiting for coverage-ack"); ++ this._coverageUploadReject(err); ++ this._coverageUploadResolve = null; ++ this._coverageUploadReject = null; ++ this._coverageUploadPromise = null; ++ } ++ }, 120000); ++ }); ++ return this._coverageUploadPromise; ++ } + } + + Client.WebSocket = WebSocket; diff --git a/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch b/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch new file mode 100644 index 0000000000..4bccb154b5 --- /dev/null +++ b/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch @@ -0,0 +1,22 @@ +diff --git a/dist/Server.js b/dist/Server.js +index ad9debe2086ab9b96e97a69aec966da8114ad102..e3c1c964956023df876dfcac3f73000b4eaf1bba 100644 +--- a/dist/Server.js ++++ b/dist/Server.js +@@ -130,6 +130,17 @@ class Server extends ServerEventEmitter_1.ServerEventEmitter { + throw new Error("Received a message from the client, but server wasn't running"); + } + } ++ else if (msg.action === "coverage-ready") { ++ this.send({ action: "pull-coverage" }); ++ } ++ else if (msg.action === "coverage-data") { ++ const coverage = msg.coverage || {}; ++ this.emit("coverage-data", coverage); ++ this.send({ ++ action: "coverage-ack", ++ fileCount: Object.keys(coverage).length, ++ }); ++ } + else if (msg.action === "error") { + if (typeof msg.message !== "string") { + throw new Error("Expected 'error' action to have an error argument with a message"); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf56f44d06..a8f39c6c51 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,8 +99,7 @@ cd react-native-firebase ```bash yarn -brew tap wix/brew -brew install applesimutils xcbeautify +bash .github/workflows/scripts/install-homebrew-rnfb.sh applesimutils xcbeautify ``` > Note that this project is a mono-repo, so you only need to install NPM dependencies once at the root of the project with `yarn`. diff --git a/okf-bundle/ci-workflows/android.md b/okf-bundle/ci-workflows/android.md new file mode 100644 index 0000000000..930ab3a7e8 --- /dev/null +++ b/okf-bundle/ci-workflows/android.md @@ -0,0 +1,3 @@ +# Android CI workflows + +TBD — Gradle/Detox emulator reliability, `coverage.ec` pull behavior, and artifact troubleshooting. diff --git a/okf-bundle/ci-workflows/index.md b/okf-bundle/ci-workflows/index.md new file mode 100644 index 0000000000..a951f24f81 --- /dev/null +++ b/okf-bundle/ci-workflows/index.md @@ -0,0 +1,13 @@ +# CI workflows + +Knowledge for GitHub Actions workflows in this repository: how jobs are structured, platform-specific reliability concerns, and how to debug failures from CI artifacts. + +## Platforms + +* [iOS](ios.md) — simulator boot reliability, logging, and troubleshooting +* [Android](android.md) — TBD +* [Other](other.md) — macOS Detox (non-iOS), Windows, and shared workflow concerns — TBD + +## Related + +* [Testing / coverage design](../testing/coverage-design.md) — e2e coverage collection that runs inside the iOS workflow diff --git a/okf-bundle/ci-workflows/ios.md b/okf-bundle/ci-workflows/ios.md new file mode 100644 index 0000000000..353caed7d0 --- /dev/null +++ b/okf-bundle/ci-workflows/ios.md @@ -0,0 +1,313 @@ +# iOS CI workflows + +This document covers the **Testing E2E iOS** workflow (`.github/workflows/tests_e2e_ios.yml`) and scripts it uses under `.github/workflows/scripts/`. + +## Simulator reliability + +### Problem + +On GitHub Actions macOS runners (currently `macos-26` with `XCODE_VERSION: latest-stable`), booting an iOS Simulator for Detox is not instantaneous. A simulator can report `Booted` in `simctl list` while it is still unusable: + +1. **First-boot data migration** — `com.apple.datamigrator` can run for several minutes on a fresh simulator (observed ~4+ minutes on iOS 26.5). SpringBoard and app install are not reliable until migration finishes. +2. **Ambiguous device names** — runners often have multiple simulators with the same marketing name (e.g. several `iPhone 17` entries across iOS runtimes). We intentionally use the **device name** from `tests/.detoxrc.js`, not a pinned UDID, so we do not churn workflow YAML when runner images change. +3. **`Booted` ≠ ready for testing** — installing or launching the app during migration can block or fail; Detox may time out while the simulator is still migrating. + +### What we do + +**Pre-boot step** (`.github/workflows/scripts/boot-simulator.sh`), run via `nick-fields/retry` before Detox: + +| Phase | What happens | +|--------|----------------| +| `resolve_device` | Read simulator name from `tests/.detoxrc.js` (e.g. `iPhone 17`) | +| `shutdown_existing` | Kill `Simulator.app` and `simctl shutdown` the target | +| `boot_command` | `xcrun simctl boot ` | +| `wait_for_full_boot` | Poll every 20s (up to 11 min) until `simctl bootstatus` reports ready | +| `install_app` | `simctl install` the built `testing.app` **only after** bootstatus succeeds | + +During `wait_for_full_boot`, the script logs to the **GitHub Actions step log** with the `[boot-status]` prefix: + +- Whether `simctl list` shows the device as `Booted` +- **Data migration** snippets from `xcrun simctl bootstatus -d` (probed with a short timeout so the step keeps printing progress instead of looking hung) +- Elapsed time per poll + +We wait for **`simctl bootstatus`** (full boot completion), not merely the `Booted` line in `simctl list`. + +**Timeouts** (tuned for first-boot migration on latest iOS): + +| Setting | Value | Notes | +|---------|-------|--------| +| Pre-Boot retry step | 12 min × 3 attempts | was 5 min | +| Job `timeout-minutes` | 87 | +7 min vs previous 80 | +| Detox test step | 62 min | +7 min vs previous 55 | + +Simulator **caching** of device data is intentionally deferred — caching a bad migration state would require cache invalidation policy. + +### Simulator logging and video (troubleshooting) + +Artifacts are uploaded on every run (`if: always()`), even when tests fail. + +| Artifact | Source | Use when | +|----------|--------|----------| +| `simulator--_log` | `xcrun simctl spawn booted log stream` → `simulator.log` | In-simulator system/app logs during Detox | +| `simulator--_video` | `xcrun simctl io booted recordVideo` → `simulator.mp4` | Visual confirmation of UI state | +| `screenrecording--` | `screencapture` of the Mac desktop | Includes Simulator.app window | +| `screenrecording-setup--.mov` | Guidepup setup recording | Very early environment setup | +| `emulator-scripts-logs--` | `.github/workflows/scripts/*.log` | Script output if redirected | + +**When to use which log** + +- **Boot / migration / “simulator won’t start”** — read the **Pre-Boot Simulator** step log in GitHub Actions first. Look for `[boot-status]` lines and `bootstatus -d` migration output. That captures first-boot migration even though `simulator.log` starts only after pre-boot succeeds. +- **Detox / app / test failures** — download `simulator-*_log` and use [E2E test app orchestration](#e2e-test-app-orchestration-detox--jet) grep patterns (`[rnfb-lifecycle]`, `waitForActive`, SpringBoard foreground). Jet WS drops (1006/1001) appear in the **Detox step log** (`[jet-ws]`, `[rnfb-e2e]`). +- **UI regressions** — `simulator-*_video` or `screenrecording-*`. + +**Downloading artifacts** + +From the workflow run page: **Artifacts** section at the bottom, or: + +```bash +gh run download -n simulator-debug-0_log +``` + +**Analyzing `simulator.log`** + +The file is unified logging from the booted simulator (compact style). Useful patterns: + +```bash +rg -i "datamigrator|Telemetry: duration|systemShellWillBootstrap" simulator.log +rg -i "com\.invertase\.testing|installcoordination" simulator.log +rg -i "test daemon not ready|xctest" simulator.log +``` + +A long gap with only `com.apple.datamigrator` activity and no `com.invertase.testing` usually means the simulator was still in first-boot migration or pre-boot had not finished installing the app yet. + +### Detox configuration + +Device type is defined in `tests/.detoxrc.js` (`devices.simulator.device.type`). The boot script and Detox both use this name. CI does not hard-code a UDID. + +### E2E test app orchestration (Detox + Jet) + +After pre-boot succeeds, failures often move **inside** the test app process (`com.invertase.testing`, binary `testing`). Simulator boot and app install are fine; Detox `launchApp` stalls while the app stays alive. Three overlapping issues show up in CI logs. + +#### 1. Early `ready` race + +**Detox step symptom** + +``` +ws-server connection :50400<->:50415 +ws-server The app has dispatched "ready" action too early. +``` + +**Cause** — iOS `DetoxManager` sends proactive `ready` on `webSocketDidConnect` before the anonymous server handles `login`. Detox 20.x logs and drops that `ready`, leaving `device.launchApp()` stuck in `waitUntilReady`. + +**Mitigation** — `.yarn/patches/detox-npm-20.51.0-*.patch` buffers early `ready` and replays it after app `login` (`AnonymousConnectionHandler.js`). + +#### 2. Main-thread delay before WebSocket handshake + +**Symptom** — Long gap (~30–60s) between `device com.invertase.testing launched` (Detox step log) and the first `Connection 1: ready` / `handshake successful` lines in `simulator.log`. Firebase configure, RN bridge startup, and LLVM coverage instrumentation run on the main thread and can defer Detox’s `URLSessionWebSocketDelegate` callbacks. + +**Mitigations** + +| Change | Location | +|--------|----------| +| Wait for Jet (port 8090) before `launchApp` | `tests/e2e/firebase.test.js` | +| `detoxEnableSynchronization: 'NO'` at launch | `tests/e2e/firebase.test.js` | +| `detoxURLBlacklistRegex: '.*'` | `tests/e2e/firebase.test.js` (existing) | + +#### 3. `waitForActive` / scene never foreground-active + +**Symptom** — Detox reaches `isReady` and `waitForActive` but never logs `waitForActiveDone`. App process stays alive for the rest of the step timeout (~30+ min). In `simulator.log`, SpringBoard requests `foreground-interactive` while the app scene stays `UISceneActivationStateUnattached` and UIKit deactivation reasons (e.g. `3104`) never clear. + +**Instrumentation** — `tests/ios/testing/AppDelegate.mm` logs `[rnfb-lifecycle]` at launch, on UIApplication/UIScene lifecycle notifications, and at **+30s / +60s** one-shot probes if the app never becomes active. Confirms whether the stall is Detox-side or the app never reaching `UIApplicationStateActive` / `foregroundActive`. + +**Mitigations in this repo (summary)** + +| Change | Location | +|--------|----------| +| Buffer early `ready`, replay after app `login` | `.yarn/patches/detox-npm-20.51.0-*.patch` | +| Jet wait + Detox launch args | `tests/e2e/firebase.test.js` | +| Lifecycle logging for post-mortem | `tests/ios/testing/AppDelegate.mm` | +| Pre-boot + `bootstatus` before install | `boot-simulator.sh` (orthogonal; fixes boot/migration only) | + +#### Diagnosing from `simulator.log` + +Download the artifact (`gh run download -n simulator-debug-0_log`), unzip if needed, then search `simulator.log`. + +**Quick triage** — map Detox step timestamps to simulator log (`testing[]`): + +```bash +# Detox orchestration inside the app process +rg 'testing\[' simulator.log | rg -i 'waitForActive|waitForActiveDone|com\.wix\.Detox|ready action too early' + +# App lifecycle confirmation (AppDelegate instrumentation) +rg '\[rnfb-lifecycle\]' simulator.log + +# SpringBoard launch intent vs app scene state +rg 'com\.invertase\.testing' simulator.log | rg -i 'foreground-interactive|visibility.*Foreground|running-active' +rg 'testing\[' simulator.log | rg -i 'Deactivation reason|activationState|UISceneActivationState' + +# WebSocket timing (main-thread block before handshake) +rg 'testing\[' simulator.log | rg 'Connection 1: ready|handshake successful' + +# Heavy startup on main thread (often precedes WS delay) +rg 'testing\[' simulator.log | rg -i 'FIRApp|RNFB|RCTBridge|configure' +``` + +**Sentinel patterns** + +| Pattern | Meaning | +|---------|---------| +| `ready action too early` in Detox step only | Early-ready race (patch should fix; check patch applied in `yarn install`) | +| `waitForActive` without `waitForActiveDone` | Scene/active hang; check `[rnfb-lifecycle]` probes still `unattached` / not `active` | +| `probe+30s` / `probe+60s` with `UIApplication.state=inactive` or scene `unattached` | App never became foreground-active; compare with SpringBoard `foreground-interactive` lines | +| `handshake successful` 30–60s after `simctl launch` | Main-thread startup delay; not a boot failure | +| Only `com.apple.datamigrator` activity, no `testing[` | Pre-boot / migration issue — use Actions `[boot-status]` log, not Detox orchestration | + +**Example healthy sequence** (abbreviated): `didFinishLaunching+after` → `UIApplicationDidBecomeActiveNotification` / scene `foregroundActive` → Detox `loginSuccess` → `isReady` → `waitForActiveDone`. A gap between SpringBoard foreground request and `[rnfb-lifecycle]` `active` is the smoking gun for issue 3. + +#### 4. Jet WebSocket disconnect (1006 / 1001) + +**Symptom** (Detox Test Debug step, often debug build only): + +``` +[🟨] Jet client disconnected - for no particular reason (code = 1006). +[🟥] Exiting after an abnormal disconnect. +Coverage summary: Unknown% ( 0/0 ) +``` + +Release on the same run may pass; the app process (`testing[]`) often stays alive in `simulator.log` — the break is the **simulator → host** mocha-remote WebSocket on port **8090**, not a native crash. + +**Cause** — Transient abnormal WebSocket closure (1006 = no close frame; 1001 = going away). Common in **debug CI** (live Metro on 8081 + Istanbul `__coverage__` growth + port 8090 forwarding). Jet’s `exitOnError: true` used to exit immediately; mocha-remote-client auto-reconnects in ~1s but the server had already shut down. + +**Mitigations in this repo** + +| Change | Location | +|--------|----------| +| 15s reconnect grace for 1006/1001 before fatal exit | `.yarn/patches/jet-npm-0.9.0-dev.13-*.patch` → `cli.js` | +| `reconnectGraceMs: 15000` | `tests/.jetrc.js` | +| One e2e retry when grace expires (`RETRYABLE_DISCONNECT`) | `tests/e2e/firebase.test.js` | +| Structured WS logging | `[jet-ws]` / `[rnfb-e2e]` prefixes in Jet + e2e harness | + +**Diagnosing from CI logs** (Detox step; `simulator.log` rarely shows Jet WS): + +```bash +# Jet WS lifecycle (Actions log) +rg '\[jet-ws\]|\[rnfb-e2e\]|Jet client disconnected|RETRYABLE_DISCONNECT' detox-step.log + +# Grace window recovered (no e2e retry needed) +rg '\[jet-ws\] reconnect_recovered' detox-step.log + +# Fatal transient disconnect → e2e retry eligible +rg '\[jet-ws\] (transient_disconnect|fatal_disconnect|RETRYABLE_DISCONNECT)' detox-step.log + +# E2e harness retry +rg '\[rnfb-e2e\] Retrying after transient Jet WS' detox-step.log + +# Coverage lost to abrupt Jet exit +rg 'Coverage summary|jet-coverage' detox-step.log +``` + +**Sentinel patterns** + +| Pattern | Meaning | +|---------|---------| +| `transient_disconnect code=1006` then `reconnect_recovered` | Flaky WS; grace window saved the run | +| `fatal_disconnect code=1006 grace_expired_ms=` + `RETRYABLE_DISCONNECT` | Grace failed; e2e should retry once | +| `[rnfb-e2e] Retrying after transient Jet WS` | Second Jet attempt starting | +| `Coverage summary.*0/0` after disconnect | JS coverage never reached NYC (check `[jet-coverage]` on release for contrast) | +| Release passes, debug fails with 1006 | Points at Metro/debug+coverage, not test logic | + +### Operational notes + +- **Release vs debug** — matrix runs both; each has separate artifacts (`debug` / `release` in the artifact name). +- **Retry** — Pre-Boot retries up to 3 times with 60s between attempts (clean shutdown + boot each time). +- **Do not boot the simulator only inside Detox** — historical races where the testee never sent “ready” to the Detox proxy; pre-boot remains mandatory. + +### Pinned Homebrew utilities + +CI installs macOS build helpers from **vendored formulae** in `.github/homebrew-rnfb/Formula/` instead of live taps (`wix/brew`, `homebrew-core`). Each formula file is frozen in git with pinned `url`, source `sha256`, and bottle hashes — similar in spirit to SHA-pinned GitHub Actions. + +| Formula | Version | Upstream source | Used in | +|---------|---------|-----------------|---------| +| `applesimutils.rb` | 0.9.12 | [wix/homebrew-brew](https://github.com/wix/homebrew-brew) @ `8f636f84541e` | iOS e2e (`tests_e2e_ios.yml`) | +| `xcbeautify.rb` | 3.2.1 | [homebrew-core](https://github.com/Homebrew/homebrew-core) @ `f2e343d17882` | iOS e2e + macOS e2e (`tests_e2e_other.yml`) | + +**Workflow install** — both workflows call `.github/workflows/scripts/install-homebrew-rnfb.sh` (from repo root). Homebrew 6+ refuses bare `brew install --formula path/to.rb`; the script copies formulae into a local `invertase/rnfb` tap, trusts it once per job, then installs: + +```bash +# iOS e2e (tests_e2e_ios.yml) +bash .github/workflows/scripts/install-homebrew-rnfb.sh applesimutils xcbeautify + +# macOS e2e (tests_e2e_other.yml) +bash .github/workflows/scripts/install-homebrew-rnfb.sh xcbeautify +``` + +**Why** — Third-party taps can change formula definitions on every `brew update`. Vendoring avoids supply-chain drift and Brew 6 untrusted-tap warnings for live third-party taps. We still `brew trust invertase/rnfb` for the ephemeral local tap copy each job creates. The install script **uninstalls any existing `homebrew-core` (or other tap) install** of the same formula name first — GHA macOS images often preinstall `xcbeautify`, and Brew refuses same-name formulae from two taps. + +#### When to update a pinned formula + +- CI **Install brew utilities** fails after a macOS runner / Xcode image bump (common for `xcbeautify` Swift/`on_sequoia` conditionals). +- You need a newer **applesimutils** or **xcbeautify** feature or bugfix. +- A security advisory affects the pinned upstream release (bump `url` / version and checksums). + +#### How to update a pinned formula + +1. **Fetch the upstream formula** you want to vendor (usually `master`, or a specific commit if you need a known-good revision): + + ```bash + # applesimutils (wix tap) + curl -fsSL "https://raw.githubusercontent.com/wix/homebrew-brew/master/Formula/applesimutils.rb" \ + -o /tmp/applesimutils.rb + + # xcbeautify (homebrew-core) + curl -fsSL "https://raw.githubusercontent.com/Homebrew/homebrew-core/master/Formula/x/xcbeautify.rb" \ + -o /tmp/xcbeautify.rb + ``` + +2. **Record the upstream commit** (for the table above and the file header): + + ```bash + # applesimutils + gh api repos/wix/homebrew-brew/commits \ + -f path=Formula/applesimutils.rb -f per_page=1 \ + --jq '.[0].sha[:12]' + + # xcbeautify + gh api repos/Homebrew/homebrew-core/commits \ + -f path=Formula/x/xcbeautify.rb -f per_page=1 \ + --jq '.[0].sha[:12]' + ``` + +3. **Merge into `.github/homebrew-rnfb/Formula/.rb`** — keep the RNFB header at the top (replace version + commit), then paste the upstream `class` body. Remove `head "..."` lines if present (frozen vendored formulae should not track moving branches). Example header: + + ```ruby + # frozen_string_literal: true + # RNFB CI vendored formula — do not install from third-party taps in workflows. + # Upstream: wix/homebrew-brew @ <12-char-sha> — AppleSimulatorUtils + # Update: see okf-bundle/ci-workflows/ios.md#pinned-homebrew-utilities + ``` + +4. **Verify locally on macOS** (from repo root): + + ```bash + bash .github/workflows/scripts/install-homebrew-rnfb.sh + --version # xcbeautify + applesimutils --help # applesimutils (no --version) + ``` + + If upgrading a formula already in your Cellar, uninstall first: `brew uninstall `. + +5. **Update this doc** — bump the version and upstream-commit columns in the table above. + +6. **Open a PR** — CI will exercise the same install script as production workflows. Watch the **Install brew utilities** step timing (`applesimutils` often builds from source on `macos-26`). + +#### Local dev (optional) + +Match CI with the install script, or install a single formula file via the script: + +```bash +bash .github/workflows/scripts/install-homebrew-rnfb.sh applesimutils xcbeautify +``` + +See also `CONTRIBUTING.md` and `tests/README.md`. + +**`applesimutils` on modern runners** — upstream bottles target older macOS releases; GHA `macos-26` typically **builds from source** (needs Xcode). Expect a longer “Install brew utilities” step than `xcbeautify`, which usually installs from a matching bottle. diff --git a/okf-bundle/ci-workflows/other.md b/okf-bundle/ci-workflows/other.md new file mode 100644 index 0000000000..a5ebcd41ea --- /dev/null +++ b/okf-bundle/ci-workflows/other.md @@ -0,0 +1,3 @@ +# Other CI workflows + +TBD — macOS Detox (`tests_e2e_other.yml`), Windows, documentation workflows, and shared actions (caches, Codecov, etc.). diff --git a/okf-bundle/index.md b/okf-bundle/index.md new file mode 100644 index 0000000000..2ffbcdd6c4 --- /dev/null +++ b/okf-bundle/index.md @@ -0,0 +1,15 @@ +--- +okf_version: "0.1" +--- + +# React Native Firebase knowledge bundle + +Knowledge documents for react-native-firebase development, testing, and maintenance. + +# CI workflows + +* [CI workflows](/ci-workflows/index.md) — GitHub Actions reliability, logging, and troubleshooting (iOS simulator boot and Detox/Jet e2e orchestration) + +# Testing + +* [Coverage design](/testing/coverage-design.md) - unit and e2e coverage goals, pipelines, and Codecov uploads diff --git a/okf-bundle/testing/coverage-design.md b/okf-bundle/testing/coverage-design.md new file mode 100644 index 0000000000..53841b912e --- /dev/null +++ b/okf-bundle/testing/coverage-design.md @@ -0,0 +1,234 @@ +--- +type: Reference +title: Coverage design +description: Goals and implementation details for unit and e2e test coverage across platforms. +tags: [testing, coverage, codecov, e2e, jest] +timestamp: 2026-06-17T00:00:00Z +--- + +# Goals + +Coverage exists to show which **TypeScript library sources** (`packages/*/lib/**`) and **native module sources** (Java, Objective-C, Swift under `packages/*/android/**` and `packages/*/ios/**`) are exercised by tests. + +| Layer | What it proves | Primary consumers | +|-------|----------------|-------------------| +| **Unit (Jest)** | Package logic in isolation with mocks | Fast feedback on `packages/*/lib/**` | +| **E2e (Jet / Detox)** | Real app behaviour against Firebase emulators | Integration coverage for TS + native bridges | + +Codecov merges uploads from CI into a single project view. Small project-level percentage swings can be noise (non-deterministic indirect lines); **file-level** coverage on `packages/*/lib/modular/**` and native startup files is the meaningful signal. + +macOS e2e uses the **firebase-js-sdk** only — native RNFB coverage is not applicable there. + +# End-to-end overview + +```mermaid +flowchart LR + subgraph unit [Unit Jest] + J1[jest --coverage] --> J2[coverage/lcov.info] + end + subgraph ts_e2e [E2e TypeScript] + M[Metro + inline source maps] --> A[App bundle] + A --> J[Jet --coverage] + J --> N[NYC remap] + N --> T2[coverage/lcov.info] + end + subgraph android_native [E2e Android native] + D1[Detox e2e] --> EC[coverage.ec in app] + EC --> P1[pull-native-coverage.js] + P1 --> J1R[jacocoAndroidTestReport] + J1R --> AX[jacoco XML] + end + subgraph ios_native [E2e iOS native] + D2[Detox e2e] --> FL[RNFBTestingCoverage.flush] + FL --> PR[coverage.profraw in Documents] + PR --> P2[pull-native-coverage.js] + P2 --> LLVM[process-ios-native-coverage.js] + LLVM --> I2[coverage/ios-native.lcov.info] + end + J2 --> C[Codecov] + T2 --> C + AX --> C + I2 --> C +``` + +# Unit coverage (Jest) + +## Command + +```bash +yarn tests:jest-coverage +``` + +## Tooling + +- **Provider:** Jest with `coverageProvider: "babel"` (Istanbul via `babel-jest`), **not** NYC. +- **Scope:** `packages/**/__tests__/**` only (see root `jest.config.js`). +- **Output:** `coverage/lcov.info` at repo root (among other Istanbul artifacts). + +## Behaviour + +Jest instruments TypeScript/JavaScript directly under `packages/*/lib/**`. No source-map remapping is required because tests import library sources, not Metro bundles. + +# E2e TypeScript coverage (Jet + NYC) + +## Commands + +| Platform | Yarn script | Notes | +|----------|-------------|-------| +| macOS | `yarn tests:macos:test-cover` | Jet only | +| iOS CI | `yarn tests:ios:test-cover` | Detox → Jet `--coverage` | +| iOS local (reuse build) | `yarn tests:ios:test-cover-reuse` | Same; Jet self-wraps under NYC when `--coverage` is passed | +| Android | `yarn tests:android:test-cover` | Detox → Jet `--coverage` | + +Android/iOS run Detox, which spawns Jet with `--coverage`. macOS runs Jet directly. + +## Tooling + +- **Metro** bundles `packages/*/dist/module/**` with inline source maps (`tests/.babelrc`: `useInlineSourceMaps: true`). +- **NYC** (`tests/nyc.config.js`) collects coverage from instrumented bundles, remaps to `packages/*/lib/**` via source maps, and writes **`coverage/lcov.info`** (NYC `cwd: '..'`). +- **Jet self-wrap:** When `--coverage` is passed, `tests/node_modules/jet/jet.js` re-invokes itself under `tests/node_modules/.bin/nyc` (checks `NYC_CONFIG` to avoid double-wrap). Detox/macOS do **not** need an extra `nyc` prefix on the yarn script — only Jet needs to run from the `tests/` directory so it can find NYC. +- **Transfer:** Patched Jet / mocha-remote send coverage over the existing WebSocket (`coverage-data` event), replacing HTTP POST that failed on large (~4.5MB+) payloads. Patches live in `.yarn/patches/` (`jet`, `mocha-remote-client`, `mocha-remote-server`). + +## Key NYC settings + +```javascript +include: ['packages/*/lib/**/*.{js,ts,tsx}', 'packages/*/dist/**/*.js'], +sourceMap: true, +'exclude-after-remap': true, +instrument: false, +reporter: ['lcov', 'html', 'text-summary'], +``` + +## Verification + +After a Detox/macOS e2e run, expect log lines like `[jet-coverage] WS received N file(s)` and an NYC summary. `coverage/lcov.info` should contain `SF:packages/...` paths (not only `packages/*/dist/...`). + +# E2e Android native coverage (Jacoco) + +## Pipeline + +1. Gradle enables `testCoverageEnabled` on RNFB Android library modules (`tests/android/build.gradle`). +2. Detox e2e runs; the instrumented app writes `coverage.ec`. +3. When the Jet process exits successfully, `tests/scripts/pull-native-coverage.js` copies the `.ec` file to `tests/android/app/build/output/coverage/emulator_coverage.ec`. **A failed pull logs a warning and does not fail the Detox test** (intermittent on CI when `coverage.ec` is not flushed in time). Jacoco report may be empty for that run. +4. `yarn tests:android:test:jacoco-report` runs `jacocoAndroidTestReport`, producing XML at: + + `tests/android/app/build/reports/jacoco/jacocoAndroidTestReport/jacocoAndroidTestReport.xml` + +CI runs steps 3–4 in sequence inside the emulator job. + +## Jacoco configuration notes + +- Class directories must use the AGP 8.x path: `build/intermediates/javac/debug/compileDebugJavaWithJavac/classes` (not legacy `.../debug/classes`). +- Source directories must include `src/reactnative/java` where modules place React Native entry code (e.g. `ReactNativeFirebaseAppInitProvider`). +- Module list comes from `rootProject.ext.firebaseModulePaths` populated in `tests/android/build.gradle`. +- `tests/android/app/jacoco.gradle` defines three report tasks sharing the same AGP 8 class paths and `firebaseModulePaths` source dirs: + - **`jacocoAndroidTestReport`** — e2e only (`**/*.ec` from Detox pull) + - **`jacocoUnitTestReport`** — unit tests only (`**/*.exec`; for when module/app unit tests are added) + - **`jacocoTestReport`** — merged unit + e2e (`**/*.exec` and `**/*.ec`) + CI uses `jacocoAndroidTestReport` after Detox. + +# E2e iOS native coverage (LLVM) + +## Pipeline + +1. **Build-time instrumentation:** LLVM profile flags are set in **`tests/ios/Podfile` `post_install`** only (run `pod install` after checkout): + - **`testing` app target:** compile + link profile flags, plus Swift toolchain library search paths (needed when linking Firebase Swift static pods on CI) + - **`RNFB*` pod targets:** compile-time profile flags only — **not** `-fprofile-instr-generate` on `OTHER_LDFLAGS` for third-party / Firebase pods (breaks `swiftCompatibility56` linking on CI) +2. **Runtime flush:** At app launch, `RNFBTestingConfigureCoverageProfilePath()` sets the profile output to `Documents/coverage.profraw`. After all Mocha tests complete, the Jet `after` hook in `tests/app.js` calls `NativeModules.RNFBTestingCoverage.flush()` (native module exported via `RCT_EXPORT_MODULE(RNFBTestingCoverage)`). **Do not use a custom URL scheme** — iOS shows an “Open in 'testing'?” dialog that blocks Detox. +3. **Pull:** When the Jet process exits with code 0, `tests/scripts/pull-native-coverage.js` copies profraw from the simulator app container to `tests/ios/build/output/coverage/simulator_coverage.profraw`. **The Detox test fails if no profraw is found.** Pull happens on Jet `close` (not in `afterAll`) so it runs before Detox environment teardown. +4. **Post-test export:** `yarn tests:ios:test:process-coverage` runs `tests/scripts/process-ios-native-coverage.js`, which: + - exits **1** if no `.profraw` files are present (missing profraw after a successful e2e means flush or pull failed) + - merges `.profraw` from `tests/ios/build/output/coverage/` (Detox) and optionally `Build/ProfileData/` (`xcodebuild test` only — not used by Detox today) + - exports lcov with `xcrun llvm-cov export -format=lcov` against the main app binary (statically linked RNFB code), writing to a temp file (large reports exceed Node stdout buffer limits) + - streams and rewrites `SF:` paths to repo-relative `packages/**` paths + - writes **`coverage/ios-native.lcov.info`** + - **deletes the processed `.profraw` files** so a missing file on the next run is a clear signal that e2e did not produce fresh native coverage + +## Objective-C and Swift + +Both languages are covered by the same LLVM pipeline. Swift files (e.g. under `packages/firestore/ios/**`) appear in the exported lcov alongside `.m` / `.mm` files. + +Most entries in the raw llvm-cov export are Pods/SDK/system code; only paths under `packages/` matter for Codecov. A healthy full e2e run typically reports on the order of **~50–60 `packages/*/ios/**` files** among ~2000 total source entries. + +## CocoaPods → SPM + +The Podfile `post_install` coverage flags are temporary. When native dependencies move to SPM, enable the same build settings on SPM targets; the post-test script remains unchanged. + +# Codecov uploads (CI) + +CI uses [codecov-action](https://github.com/codecov/codecov-action) v6 with `verbose: true`. It discovers coverage files under the repo, including: + +| Workflow | Artifacts | +|----------|-----------| +| `tests_jest.yml` | `coverage/lcov.info`, `coverage-final.json`, `clover.xml` | +| `tests_e2e_android.yml` | `coverage/lcov.info` + Jacoco XML | +| `tests_e2e_other.yml` | `coverage/lcov.info` (macOS TS) | +| `tests_e2e_ios.yml` (debug) | `coverage/lcov.info` + `coverage/ios-native.lcov.info` | + +The iOS workflow runs `yarn tests:ios:test:process-coverage` after Detox (`if: always()`, `continue-on-error: true` for now). The process step exits 1 when profraw is missing. + +## File naming + +The repo standard for JavaScript lcov is `coverage/lcov.info`. iOS native uses **`coverage/ios-native.lcov.info`**. Codecov detects **lcov format by file content**, not only by the exact name `lcov.info`. + +Check the Codecov commit **Uploads** tab for **Processed** vs **Unusable** per upload — that is the authoritative signal, not small project percentage deltas. + +# Local iteration + +```bash +# Full iOS (build once, then reuse) +yarn tests:ios:build +yarn tests:ios:test-cover-reuse +yarn tests:ios:test:process-coverage + +# Or combined +yarn tests:ios:test-cover-and-process # clean Detox run + process (no --reuse) + +# Android (after e2e) +yarn tests:android:test-cover-reuse +yarn tests:android:test:jacoco-report + +# Codecov CLI (optional) +.codecov-venv/bin/codecovcli upload-process \ + -t "$CODECOV_TOKEN" -r invertase/react-native-firebase \ + --git-service github -C "$(git rev-parse HEAD)" -B "$(git branch --show-current)" \ + -f coverage/ios-native.lcov.info -n local-ios-native --disable-search +``` + +Metro must be running (`yarn tests:packager:jet`) for Detox e2e. + +# Critical invariants + +These must all be true for native iOS coverage to work. If any break, the e2e test should fail (not silently upload stale data). + +| Invariant | Where enforced | +|-----------|----------------| +| App built with LLVM profile flags | `Podfile` `post_install` (run `pod install`) | +| Profile path set at launch | `AppDelegate` → `RNFBTestingConfigureCoverageProfilePath()` | +| JS module name matches native export | `RCT_EXPORT_MODULE(RNFBTestingCoverage)` + `NativeModules.RNFBTestingCoverage` in `tests/app.js` | +| Flush runs after Mocha tests | Jet `after` hook in `tests/app.js` | +| Profraw pulled before Detox teardown | `pull-native-coverage.js` on Jet `close` in `firebase.test.js` | +| Fresh profraw processed after e2e | `process-ios-native-coverage.js` (deletes profraw after export) | + +# Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Simulator “Open in 'testing'?” dialog | Custom URL scheme handler | Use native module flush only; no `rnfb-testing://` | +| No profraw after e2e; test still passes (old behaviour) | Pull in `afterAll` after `detox.cleanup()`, or wrong module name | Pull on Jet `close`; verify `RNFBTestingCoverage` export name | +| Stale profraw uploaded | Re-processed old file without re-running e2e | Process step deletes profraw after export; missing file + exit 1 on next process | +| `process-ios-native-coverage` succeeds but no `packages/` hits | Wrong binary / not instrumented | Rebuild with `yarn tests:ios:build`; check Podfile flags | +| Empty Jacoco XML (~235 bytes) | AGP 8 class path, missing `src/reactnative/java`, or no `coverage.ec` pulled | See `jacocoAndroidTestReport`; check `[native-coverage] Android native coverage pull failed` warning | +| iOS link: `swiftCompatibility56` undefined | Profile link flags applied to all Pods | Restrict `OTHER_LDFLAGS` profile flags to app target; RNFB pods compile-only | +| No `[jet-coverage] WS received` lines | Patches not applied | `yarn install` from repo root; check `.yarn/patches/` | +| NYC summary missing / empty `lcov.info` | Jet not run from `tests/` cwd | Detox spawns `yarn jet` inside `tests/`; macOS uses `cd tests && npx jet` | +| Codecov upload **Unusable** | Wrong `SF:` paths | Confirm path rewrite in `process-ios-native-coverage.js`; check Uploads tab message | + +# Future cleanups (non-blocking) + +- **CI:** drop `continue-on-error: true` on the iOS process-coverage step once stable in CI. + +# Citations + +[1] [Open Knowledge Format (OKF) specification](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) +[2] [Codecov CLI documentation](https://docs.codecov.com/docs/the-codecov-cli) diff --git a/okf-bundle/testing/index.md b/okf-bundle/testing/index.md new file mode 100644 index 0000000000..7035983eb2 --- /dev/null +++ b/okf-bundle/testing/index.md @@ -0,0 +1,3 @@ +# Testing + +* [Coverage design](coverage-design.md) - how unit and e2e coverage is collected, transformed, and uploaded diff --git a/package.json b/package.json index e7c0b0aa2d..5ddb56e6a3 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,9 @@ "tests:ios:test:debug": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 yarn detox test --configuration ios.sim.debug --loglevel warn --inspect", "tests:ios:test-reuse": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 yarn detox test --configuration ios.sim.debug --reuse --loglevel warn", "tests:ios:test-cover": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 yarn detox test --configuration ios.sim.debug --loglevel verbose", - "tests:ios:test-cover-reuse": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 node_modules/.bin/nyc yarn detox test --configuration ios.sim.debug --reuse --loglevel warn", + "tests:ios:test-cover-reuse": "cd tests && SIMCTL_CHILD_GULGeneratedClassDisposeDisabled=1 yarn detox test --configuration ios.sim.debug --reuse --loglevel warn", + "tests:ios:test:process-coverage": "node tests/scripts/process-ios-native-coverage.js", + "tests:ios:test-cover-and-process": "yarn tests:ios:test-cover && yarn tests:ios:test:process-coverage", "tests:ios:pod:install": "cd tests && rm -f ios/Podfile.lock && rm -rf ios/ReactNativeFirebaseDemo.xcworkspace && cd ios && pod install", "tests:macos:build": "cd tests && yarn build:macos", "tests:macos:pod:install": "cd tests && rm -f macos/Podfile.lock && cd macos && pod install", @@ -116,7 +118,9 @@ "typescript-eslint": "^8.59.1" }, "resolutions": { - "@types/react": "~19.0.0" + "@types/react": "~19.0.0", + "mocha-remote-client@npm:^1.13.0": "patch:mocha-remote-client@npm%3A1.13.2#~/.yarn/patches/mocha-remote-client-npm-1.13.2-a2e7596aba.patch", + "mocha-remote-server@npm:^1.13.0": "patch:mocha-remote-server@npm%3A1.13.2#~/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch" }, "workspaces": { "packages": [ diff --git a/tests/.babelrc b/tests/.babelrc index 53e94b45c3..cef28c9b85 100644 --- a/tests/.babelrc +++ b/tests/.babelrc @@ -8,7 +8,7 @@ "instrument": true, "relativePath": true, "include": ["**/packages/**"], - "useInlineSourceMaps": false + "useInlineSourceMaps": true } ] ] diff --git a/tests/.detoxrc.js b/tests/.detoxrc.js index 0cb069abf1..1bbbbf02f2 100644 --- a/tests/.detoxrc.js +++ b/tests/.detoxrc.js @@ -48,7 +48,7 @@ module.exports = { simulator: { type: 'ios.simulator', device: { - type: 'iPhone 16', + type: 'iPhone 17', }, }, attached: { diff --git a/tests/.jetrc.js b/tests/.jetrc.js index 3b5f949b00..f8ef23f2fc 100644 --- a/tests/.jetrc.js +++ b/tests/.jetrc.js @@ -8,6 +8,8 @@ module.exports = { reporter: 'spec', timeout: 420000, // 7 minutes - fetchAndActivate takes 5+ sometimes exitOnError: true, + // Wait for mocha-remote client auto-reconnect before fatal exit (1006/1001). + reconnectGraceMs: 15000, coverage: true, }, targets: { diff --git a/tests/README.md b/tests/README.md index fe7e798a62..78483666ed 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,11 +13,10 @@ Our tests are powered by [Jet ✈️](https://github.com/invertase/jet). - [Apple Sim Utils](https://github.com/wix/AppleSimulatorUtils): ```bash - brew tap wix/brew - brew install wix/brew/applesimutils + bash .github/workflows/scripts/install-homebrew-rnfb.sh applesimutils ``` -> **Note**: If Homebrew complains about a conflict in the `wix/brew` tap, run `brew untap wix/brew && brew tap wix/brew` and try installing again + CI uses the same vendored formulae (see `okf-bundle/ci-workflows/ios.md#pinned-homebrew-utilities`). --- diff --git a/tests/android/app/jacoco.gradle b/tests/android/app/jacoco.gradle index 398ee89006..eac18cb7f8 100644 --- a/tests/android/app/jacoco.gradle +++ b/tests/android/app/jacoco.gradle @@ -58,88 +58,72 @@ tasks.withType(Test) { jacoco.excludes = ['jdk.internal.*'] } -// Our merge report task -task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest', 'connectedDebugAndroidTest']) { - def htmlOutDir = layout.buildDirectory.dir("reports/jacoco/$name/html").get().asFile - - doLast { - openReport htmlOutDir - } +def configureRnfbJacocoSourcesAndClasses(JacocoReport reportTask) { + def classFileFilter = [ + '**/R.class', + '**/R$*.class', + '**/BuildConfig.*', + '**/Manifest*.*', + '**/*Test*.*', + 'android/**/*.*', + ] + def classFiles = [] + def srcFiles = [] - reports { - xml.required = true - html.outputLocation = htmlOutDir + rootProject.ext.firebaseModulePaths.forEach { projectPath -> + classFiles << fileTree( + dir: "$projectPath/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes", + excludes: classFileFilter, + ) + srcFiles << "$projectPath/src/main/java" + def reactNativeSrc = file("$projectPath/src/reactnative/java") + if (reactNativeSrc.exists()) { + srcFiles << reactNativeSrc + } } - def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*'] - def debugTree = fileTree(dir: "$project.buildDir/intermediates/javac/debug/classes", excludes: fileFilter) - def debugTree2 = fileTree(dir: "$project.buildDir/../../node_modules/@react-native-firebase/android/build/intermediates/javac/debug/classes", excludes: fileFilter) - def mainSrc = "$project.projectDir/src/main/java" - def mainSrc2 = "$project.projectDir/../../node_modules/@react-native-firebase/android/src/main/java" - - sourceDirectories.from = files([mainSrc, mainSrc2]) - classDirectories.from = files([debugTree, debugTree2]) - executionData.from = fileTree(dir: project.buildDir, includes: [ - '**/*.exec', - '**/*.ec' - ]) + reportTask.sourceDirectories.from = files(srcFiles) + reportTask.classDirectories.from = files(classFiles) } -// A unit-test only report task -task jacocoUnitTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) { - def htmlOutDir = layout.buildDirectory.dir("reports/jacoco/$name/html").get().asFile - - // Runs normal test but with this added: - // -javaagent:build/tmp/expandedArchives/org.jacoco.agent-0.8.7.jar_3a83c50b4a016f281c4e9f3500d16b55/jacocoagent.jar=destfile=build/jacoco/testPlayDebugUnitTest.exec,append=true,excludes=jdk.internal.*,inclnolocationclasses=true,dumponexit=true,output=file,jmx=false - - doLast { - openReport htmlOutDir - } - - reports { - xml.required = true - html.outputLocation = htmlOutDir +def rnfbJacocoExecutionData(List includes) { + def executionTrees = [fileTree(dir: project.buildDir, includes: includes)] + rootProject.ext.firebaseModulePaths.forEach { projectPath -> + executionTrees << fileTree(dir: "$projectPath/build", includes: includes) } - - def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*'] - def debugTree = fileTree(dir: "$project.buildDir/intermediates/javac/debug/classes", excludes: fileFilter) - def mainSrc = "$project.projectDir/src/main/java" - - sourceDirectories.from = files([mainSrc]) - classDirectories.from = files([debugTree]) - executionData.from = fileTree(dir: project.buildDir, includes: [ - '**/*.exec' - - ]) + return files(executionTrees) } -// A connected android tests only report task -task jacocoAndroidTestReport(type: JacocoReport) { - def htmlOutDir = layout.buildDirectory.dir("reports/jacoco/$name/html").get().asFile +def configureJacocoReportTask(JacocoReport reportTask) { + def htmlOutDir = layout.buildDirectory.dir("reports/jacoco/${reportTask.name}/html").get().asFile - doLast { + reportTask.doLast { openReport htmlOutDir } - reports { + reportTask.reports { xml.required = true html.outputLocation = htmlOutDir } - def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*'] + configureRnfbJacocoSourcesAndClasses(reportTask) +} - // use our collected firebase module names to aggregate source / class files for reporting - def classFiles = [] - def srcFiles = [] - rootProject.ext.firebaseModulePaths.forEach { projectPath -> - classFiles << fileTree(dir: "$projectPath/build/intermediates/javac/debug/classes", excludes: fileFilter) - srcFiles << "$projectPath/src/main/java" - } +// Unit + e2e (Detox) merged report. E2e coverage is pulled to build/output/coverage/*.ec +// after Detox; unit tests produce *.exec under each module's build/ when added. +task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) { + configureJacocoReportTask(it) + executionData.from = rnfbJacocoExecutionData(['**/*.exec', '**/*.ec']) +} +// Unit-test only report (for when android unit tests are added to RNFB modules or the app). +task jacocoUnitTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) { + configureJacocoReportTask(it) + executionData.from = rnfbJacocoExecutionData(['**/*.exec']) +} - sourceDirectories.from = files(srcFiles) - classDirectories.from = files(classFiles) - executionData.from = fileTree(dir: project.buildDir, includes: [ - '**/*.ec' - ]) +// Detox e2e report (connected instrumentation is not used — coverage.ec is dumped from the app). +task jacocoAndroidTestReport(type: JacocoReport) { + configureJacocoReportTask(it) + executionData.from = rnfbJacocoExecutionData(['**/*.ec']) } diff --git a/tests/app.js b/tests/app.js index c2f81786c9..5dce9f1975 100644 --- a/tests/app.js +++ b/tests/app.js @@ -247,6 +247,21 @@ function loadTests(_) { const storageTests = require.context('../packages/storage/e2e', true, /\.e2e\.js$/); storageTests.keys().forEach(storageTests); } + + after(async function flushNativeCoverageProfile() { + if (Platform.OS === 'ios') { + const { NativeModules } = require('react-native'); + const coverageModule = NativeModules.RNFBTestingCoverage; + if (coverageModule?.flush) { + console.log('[ios-native-coverage] flushing LLVM profile from Jet after hook'); + await coverageModule.flush(); + } else { + console.warn( + '[ios-native-coverage] RNFBTestingCoverage native module not available; skipping flush', + ); + } + } + }); }); } diff --git a/tests/e2e/firebase.test.js b/tests/e2e/firebase.test.js index 62666c9fe5..056e0703db 100644 --- a/tests/e2e/firebase.test.js +++ b/tests/e2e/firebase.test.js @@ -15,83 +15,158 @@ * limitations under the License. * */ -const { execSync, spawn } = require('child_process'); +const { spawn } = require('child_process'); +const net = require('net'); +const path = require('path'); -describe('Jet Tests', function () { - jest.retryTimes(0, { logErrorsBeforeRetry: true }); +const { pullAndroidCoverage, pullIosCoverage } = require('../scripts/pull-native-coverage'); - it('runs all tests', async function () { - return new Promise(async (resolve, reject) => { - const platform = detox.device.getPlatform(); - const jetArgs = - process.platform === 'win32' - ? ['jet', `--target=${platform}`] // NYC / coverage does not work on windows. - : ['jet', `--target=${platform}`, '--coverage']; - const jetProcess = spawn('yarn', jetArgs, { - stdio: ['ignore', 'inherit', 'inherit'], - shell: true, - }); - jetProcess.on('error', err => { - console.error(`Jet tests had an error: ${err}`); - reject(new Error(`Jet tests failed!`)); +const JET_REMOTE_PORT = parseInt(process.env.JET_REMOTE_PORT || '8090', 10); +const JET_RETRYABLE_WS_RE = /\[jet-ws\] RETRYABLE_DISCONNECT code=(1006|1001)\b/; + +function waitForTcpPort(port, host = '127.0.0.1', timeoutMs = 120000) { + const start = Date.now(); + + return new Promise((resolve, reject) => { + const tryConnect = () => { + if (Date.now() - start > timeoutMs) { + reject(new Error(`Timed out waiting for ${host}:${port} after ${timeoutMs}ms`)); + return; + } + + const socket = net.connect(port, host); + socket.once('connect', () => { + socket.end(); + resolve(); }); - jetProcess.on('close', code => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`Jet tests failed!`)); - } + socket.once('error', () => { + socket.destroy(); + setTimeout(tryConnect, 250); }); + }; + + tryConnect(); + }); +} + +function isRetryableJetDisconnect(output) { + return JET_RETRYABLE_WS_RE.test(output); +} + +function runJetE2eAttempt(attempt) { + const platform = detox.device.getPlatform(); + const testsDir = path.resolve(__dirname, '..'); + const jetArgs = + process.platform === 'win32' + ? ['jet', `--target=${platform}`] + : ['jet', `--target=${platform}`, '--coverage']; + + let output = ''; + const jetProcess = spawn('yarn', jetArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + cwd: testsDir, + }); + + jetProcess.stdout.on('data', chunk => { + const text = chunk.toString(); + output += text; + process.stdout.write(text); + }); + jetProcess.stderr.on('data', chunk => { + const text = chunk.toString(); + output += text; + process.stderr.write(text); + }); + + return new Promise(async (resolve, reject) => { + jetProcess.on('error', err => { + err.jetOutput = output; + reject(err); + }); + jetProcess.on('close', code => { + if (code !== 0) { + const err = new Error('Jet tests failed!'); + err.jetOutput = output; + err.jetExitCode = code; + reject(err); + return; + } + resolve({ output }); + }); + + try { + console.log(`[rnfb-e2e] Jet attempt ${attempt}: waiting for port ${JET_REMOTE_PORT}`); + await waitForTcpPort(JET_REMOTE_PORT); + console.log(`[rnfb-e2e] Jet attempt ${attempt}: launching app`); await device.launchApp({ newInstance: true, delete: true, - launchArgs: { detoxURLBlacklistRegex: `.*` }, + launchArgs: { + detoxURLBlacklistRegex: `.*`, + // Avoid sync/idling blocking the main queue while Detox WS login is pending. + detoxEnableSynchronization: 'NO', + }, }); - }); + } catch (err) { + jetProcess.kill(); + err.jetOutput = output; + reject(err); + } }); -}); +} -beforeAll(async function () { - // Nothing to do here. -}); +describe('Jet Tests', function () { + jest.retryTimes(0, { logErrorsBeforeRetry: true }); -beforeEach(async function () { - // Nothing to do here. -}); + it('runs all tests', async function () { + const platform = detox.device.getPlatform(); + const deviceId = detox.device.id; + const testsDir = path.resolve(__dirname, '..'); + + for (let attempt = 1; attempt <= 2; attempt++) { + try { + if (attempt > 1) { + console.warn('[rnfb-e2e] Retrying after transient Jet WS disconnect (1006/1001)'); + try { + await device.terminateApp(); + } catch (_) { + // No-op + } + } -afterAll(async function () { - console.log(' ✨ Tests Complete ✨ '); - const isAndroid = detox.device.getPlatform() === 'android'; - const deviceId = detox.device.id; - - // emits 'cleanup' across socket, which goes native, terminates Detox test Looper - // This returns control to the java code in our instrumented test, and then Instrumentation lifecycle finishes cleanly - // await Utils.sleep(5000); // give async processes (like Firestore writes) time to complete - await detox.cleanup(); - // await Utils.sleep(5000); // give client app time to dump coverage report - - // Get the file off the device, into standard location for JaCoCo binary report - // It will still need processing via gradle jacocoAndroidTestReport task for codecov, but it's available now - if (isAndroid && process.platform !== 'win32') { - const pkg = 'com.invertase.testing'; - const emuOrig = `/data/user/0/${pkg}/files/coverage.ec`; - const emuDest = '/data/local/tmp/detox/coverage.ec'; - const localDestDir = './android/app/build/output/coverage/'; - const adb = process.env.ANDROID_HOME ? `${process.env.ANDROID_HOME}/platform-tools/adb` : 'adb'; + await runJetE2eAttempt(attempt); + break; + } catch (err) { + const jetOutput = err.jetOutput || ''; + const retryable = attempt === 1 && isRetryableJetDisconnect(jetOutput); + console.warn( + `[rnfb-e2e] Jet attempt ${attempt} failed (retryable=${retryable}, exit=${err.jetExitCode ?? 'n/a'})`, + ); + if (!retryable) { + throw err; + } + } + } try { - execSync(`${adb} -s ${deviceId} shell "run-as ${pkg} cat ${emuOrig} > ${emuDest}"`); - execSync(`mkdir -p ${localDestDir}`); - execSync(`${adb} -s ${deviceId} pull ${emuDest} ${localDestDir}/emulator_coverage.ec`); - console.log(`Coverage data downloaded to: ${localDestDir}/emulator_coverage.ec`); + if (platform === 'android' && process.platform !== 'win32') { + pullAndroidCoverage(deviceId, { testsDir, softFail: true }); + } + if (platform === 'ios' && process.platform === 'darwin') { + pullIosCoverage(deviceId, { testsDir }); + } } catch (e) { - console.log('Unable to download coverage data from device: ', JSON.stringify(e)); + throw new Error(`Failed to download native coverage data: ${e.message}`); } - } + }); - try { - await device.terminateApp(); - } catch (_) { - // No-op - } + afterAll(async function () { + console.log(' ✨ Tests Complete ✨ '); + try { + await device.terminateApp(); + } catch (_) { + // No-op + } + }); }); diff --git a/tests/ios/Podfile b/tests/ios/Podfile index 6d2b7392a5..99d75ebad1 100644 --- a/tests/ios/Podfile +++ b/tests/ios/Podfile @@ -71,12 +71,32 @@ target 'testing' do # :ccache_enabled => true ) + apply_ios_native_coverage = lambda do |build_settings, link_profile:| + build_settings['CLANG_ENABLE_CODE_COVERAGE'] = 'YES' + build_settings['OTHER_CFLAGS'] = '$(inherited) -fprofile-instr-generate -fcoverage-mapping' + build_settings['OTHER_SWIFT_FLAGS'] = '$(inherited) -profile-generate -profile-coverage-mapping' + if link_profile + # DT_TOOLCHAIN_DIR (not TOOLCHAIN_DIR) resolves in workspace OTHER_LDFLAGS on CI. + # Explicit -l flags satisfy Firebase static Swift pod autolink when clang/ccache links. + build_settings['OTHER_LDFLAGS'] = [ + '$(inherited)', + '-fprofile-instr-generate', + '-L$(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)', + '-L$(SDKROOT)/usr/lib/swift', + '-lswiftCompatibility56', + '-lswiftCompatibilityPacks', + ].join(' ') + end + end + installer.aggregate_targets.each do |aggregate_target| aggregate_target.user_project.native_targets.each do |target| target.build_configurations.each do |config| # Arch selection is needed to work across M1/Intel macs, became necessary when App Check was added config.build_settings['ONLY_ACTIVE_ARCH'] = 'YES' config.build_settings['EXCLUDED_ARCHS'] = 'i386' + # LLVM coverage for e2e native export (see okf-bundle/testing/coverage-design.md) + apply_ios_native_coverage.call(config.build_settings, link_profile: true) end end aggregate_target.user_project.save @@ -97,6 +117,11 @@ target 'testing' do target.build_configurations.each do |config| config.build_settings["GCC_WARN_INHIBIT_ALL_WARNINGS"] = "YES" config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported + # Instrument RNFB pods at compile time only. Do not add profile link flags to + # third-party / Firebase pods — that breaks Swift compatibility library linking on CI. + if target.name.include?('RNFB') + apply_ios_native_coverage.call(config.build_settings, link_profile: false) + end end end end diff --git a/tests/ios/Podfile.lock b/tests/ios/Podfile.lock index ce4289710f..65e4359774 100644 --- a/tests/ios/Podfile.lock +++ b/tests/ios/Podfile.lock @@ -202,7 +202,7 @@ PODS: - GTMSessionFetcher/Core (< 6.0, >= 3.4) - fmt (12.1.0) - glog (0.3.5) - - GoogleAdsOnDeviceConversion (3.5.0): + - GoogleAdsOnDeviceConversion (3.6.0): - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - GoogleUtilities/Network (~> 8.1) @@ -1804,7 +1804,7 @@ PODS: - Yoga - RNDeviceInfo (15.0.2): - React-Core - - RNFBAnalytics (24.0.0): + - RNFBAnalytics (24.1.1): - DoubleConversion - FirebaseAnalytics/Core (= 12.13.0) - FirebaseAnalytics/IdentitySupport (= 12.13.0) @@ -1829,7 +1829,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBApp (24.0.0): + - RNFBApp (24.1.1): - DoubleConversion - Firebase/CoreOnly (= 12.13.0) - glog @@ -1851,7 +1851,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNFBAppCheck (24.0.0): + - RNFBAppCheck (24.1.1): - DoubleConversion - Firebase/AppCheck (= 12.13.0) - glog @@ -1874,7 +1874,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBAppDistribution (24.0.0): + - RNFBAppDistribution (24.1.1): - DoubleConversion - Firebase/AppDistribution (= 12.13.0) - glog @@ -1897,7 +1897,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBAuth (24.0.0): + - RNFBAuth (24.1.1): - DoubleConversion - Firebase/Auth (= 12.13.0) - glog @@ -1920,7 +1920,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBCrashlytics (24.0.0): + - RNFBCrashlytics (24.1.1): - DoubleConversion - Firebase/Crashlytics (= 12.13.0) - FirebaseCoreExtension @@ -1944,7 +1944,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBDatabase (24.0.0): + - RNFBDatabase (24.1.1): - DoubleConversion - Firebase/Database (= 12.13.0) - glog @@ -1967,7 +1967,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBFirestore (24.0.0): + - RNFBFirestore (24.1.1): - DoubleConversion - Firebase/Firestore (= 12.13.0) - glog @@ -1990,7 +1990,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBFunctions (24.0.0): + - RNFBFunctions (24.1.1): - DoubleConversion - Firebase/Functions (= 12.13.0) - glog @@ -2013,7 +2013,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBInAppMessaging (24.0.0): + - RNFBInAppMessaging (24.1.1): - DoubleConversion - Firebase/InAppMessaging (= 12.13.0) - glog @@ -2036,7 +2036,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBInstallations (24.0.0): + - RNFBInstallations (24.1.1): - DoubleConversion - Firebase/Installations (= 12.13.0) - glog @@ -2059,7 +2059,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBMessaging (24.0.0): + - RNFBMessaging (24.1.1): - DoubleConversion - Firebase/Messaging (= 12.13.0) - FirebaseCoreExtension @@ -2083,7 +2083,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBML (24.0.0): + - RNFBML (24.1.1): - DoubleConversion - glog - hermes-engine @@ -2105,7 +2105,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBPerf (24.0.0): + - RNFBPerf (24.1.1): - DoubleConversion - Firebase/Performance (= 12.13.0) - glog @@ -2128,7 +2128,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBRemoteConfig (24.0.0): + - RNFBRemoteConfig (24.1.1): - DoubleConversion - Firebase/RemoteConfig (= 12.13.0) - glog @@ -2151,7 +2151,7 @@ PODS: - ReactCommon/turbomodule/core - RNFBApp - Yoga - - RNFBStorage (24.0.0): + - RNFBStorage (24.1.1): - DoubleConversion - Firebase/Storage (= 12.13.0) - glog @@ -2529,7 +2529,7 @@ SPEC CHECKSUMS: FirebaseStorage: a4709ed4e33f0b19dc6ac4889bb3c946fe8865ef fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8 - GoogleAdsOnDeviceConversion: 914d95386d0dd6815e8b1d70c465fe1d13312a1e + GoogleAdsOnDeviceConversion: 80ce443fa1b4b5750913d53a04ecda644ff57744 GoogleAppMeasurement: 01e991b4c0c025dd66558dbc5133b5d70ccdf88e GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 @@ -2600,25 +2600,25 @@ SPEC CHECKSUMS: RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba RNCAsyncStorage: 6a8127b6987dc9fbce778669b252b14c8355c7ce RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e - RNFBAnalytics: e5a484651375f055dd379c450af1ffb559fc58ba - RNFBApp: 638a7f6f2cf353c1f50822eafdc296a9250eb3ea - RNFBAppCheck: 280751b67cf26f750ddfee3558021452e06f4d46 - RNFBAppDistribution: aa586ad9e8dc30c9d88d0a9edd9c1c3e7b69329e - RNFBAuth: 6b1a9d0f6ba787ea0dd3a6b5e4467bc89aa9e90c - RNFBCrashlytics: 457c3fae67f0b055e6dc907adf0b47982b1baa6f - RNFBDatabase: 3cb09e2d2c9c77830bebe3e3e524c97a7b5d47a1 - RNFBFirestore: 208b7ebe8dbea11f0cc2b8b779c955784b59250b - RNFBFunctions: 0399d1973a1df7e05dbb3b3f6aeef8a994eb0e89 - RNFBInAppMessaging: ed7d92ad32bf00b25d13cd4e799caa3119738dc6 - RNFBInstallations: 86d1c1bedb690a2fb959ebebe6a2d40929ef21b7 - RNFBMessaging: b3d4d7027a51f6dd017d57a8319fc092f479f3f0 - RNFBML: c7d77b4bdc3ecfb786f5f986b994d4d932808f0b - RNFBPerf: 667bbf279aa09e9e976a1282bd0a9b8701f3a018 - RNFBRemoteConfig: b24c97868965c7e8892a8228f6e491ff95148134 - RNFBStorage: 81040df4fdc87da85fe84b46ee5ba6e10e08abe9 + RNFBAnalytics: 3c61eb209244f55e1f4249e82f673093d23026cb + RNFBApp: 309ef0f49afcd5f287b7ae16d60b9b0d72e112ab + RNFBAppCheck: 7c415580c65fa3f5d67d21ebccd6d808a0148614 + RNFBAppDistribution: 24ffa1be51be847ed7044a1cf904f3344fbebb79 + RNFBAuth: af0ae9222ab3dd9282c5709aeca44eec0feab015 + RNFBCrashlytics: d2269d090690c15b89662ea74e72843313c6b269 + RNFBDatabase: 35f486ab4ebe443d1ff34401f6fe718c313032ce + RNFBFirestore: d4c47b536c1e08a07904e607f3fd3b589e4febfc + RNFBFunctions: c068a60db7d2eb7e1a8619521e2a0c6ede4cdc42 + RNFBInAppMessaging: ba18b429b84038863a2cb61399f17e0c61f802b6 + RNFBInstallations: bce1bf295e31b2bd9da22254b5e1d83d2690e9e9 + RNFBMessaging: f2add52271cba073ff88946d4144d74987dec60d + RNFBML: 2aa57d48341bfab095e836b1cebebd3e9bf6081a + RNFBPerf: df8dbf56b0291edbfb2f72924f5db22f37624c36 + RNFBRemoteConfig: 6a65a1fde85cd52b5a7c28f0c9c90d7bf09f9ff7 + RNFBStorage: 9f7f959ecbed67b9c6b3898b10a3e518005d749a SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 3bb1ee33b5133befbd33872601fa46efdd48e841 -PODFILE CHECKSUM: dbd2dddd0ee0d9aea4622842cf7e57adc932db8e +PODFILE CHECKSUM: db302f01b6ea151c2fae5443e215e49c7061ee55 COCOAPODS: 1.16.2 diff --git a/tests/ios/testing.xcodeproj/project.pbxproj b/tests/ios/testing.xcodeproj/project.pbxproj index 1dfbc2743e..f992435edd 100644 --- a/tests/ios/testing.xcodeproj/project.pbxproj +++ b/tests/ios/testing.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ 27CE6A36224923D200222E16 /* remote_config_resource_test.plist in Resources */ = {isa = PBXBuildFile; fileRef = 27CE6A35224923D200222E16 /* remote_config_resource_test.plist */; }; 27DBE25D2594E43C00CEC69A /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 27DBE25C2594E43C00CEC69A /* LaunchScreen.storyboard */; }; 3323F06104C7189BEC46D8B5 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3323FFA47718EA67C36AD776 /* Images.xcassets */; }; + A1B2C3D41E00000100000001 /* RNFBTestingCoverageProfile.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41E00000100000002 /* RNFBTestingCoverageProfile.mm */; }; + A1B2C3D41E00000100000003 /* RNFBTestingCoverageModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41E00000100000004 /* RNFBTestingCoverageModule.m */; }; + B2C3D4E51F00000100000001 /* RNFBTestingSwiftRuntimeStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E51F00000100000002 /* RNFBTestingSwiftRuntimeStub.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -48,6 +51,11 @@ 327B0CEB26D00056AE09E0DA /* Pods_testing.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_testing.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3323FFA47718EA67C36AD776 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = testing/Images.xcassets; sourceTree = ""; }; 57FE9E835CE2D5A05E92A82C /* Pods-testing.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-testing.debug.xcconfig"; path = "Target Support Files/Pods-testing/Pods-testing.debug.xcconfig"; sourceTree = ""; }; + A1B2C3D41E00000100000002 /* RNFBTestingCoverageProfile.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = RNFBTestingCoverageProfile.mm; path = testing/RNFBTestingCoverageProfile.mm; sourceTree = ""; }; + A1B2C3D41E00000100000004 /* RNFBTestingCoverageModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RNFBTestingCoverageModule.m; path = testing/RNFBTestingCoverageModule.m; sourceTree = ""; }; + A1B2C3D41E00000100000005 /* RNFBTestingCoverageProfile.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNFBTestingCoverageProfile.h; path = testing/RNFBTestingCoverageProfile.h; sourceTree = ""; }; + B2C3D4E51F00000100000002 /* RNFBTestingSwiftRuntimeStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = RNFBTestingSwiftRuntimeStub.swift; path = testing/RNFBTestingSwiftRuntimeStub.swift; sourceTree = ""; }; + B2C3D4E51F00000100000003 /* testing-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "testing-Bridging-Header.h"; path = "testing/testing-Bridging-Header.h"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -87,6 +95,11 @@ 271CB184206AFCD300EBADF4 /* GoogleService-Info.plist */, 13B07FAF1A68108700A75B9A /* AppDelegate.h */, 13B07FB01A68108700A75B9A /* AppDelegate.mm */, + A1B2C3D41E00000100000002 /* RNFBTestingCoverageProfile.mm */, + A1B2C3D41E00000100000004 /* RNFBTestingCoverageModule.m */, + A1B2C3D41E00000100000005 /* RNFBTestingCoverageProfile.h */, + B2C3D4E51F00000100000002 /* RNFBTestingSwiftRuntimeStub.swift */, + B2C3D4E51F00000100000003 /* testing-Bridging-Header.h */, 13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB71A68108700A75B9A /* main.m */, 27CE6A35224923D200222E16 /* remote_config_resource_test.plist */, @@ -368,6 +381,9 @@ buildActionMask = 2147483647; files = ( 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, + A1B2C3D41E00000100000001 /* RNFBTestingCoverageProfile.mm in Sources */, + A1B2C3D41E00000100000003 /* RNFBTestingCoverageModule.m in Sources */, + B2C3D4E51F00000100000001 /* RNFBTestingSwiftRuntimeStub.swift in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -380,6 +396,7 @@ baseConfigurationReference = 57FE9E835CE2D5A05E92A82C /* Pods-testing.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_CODE_COVERAGE = YES; CODE_SIGN_ENTITLEMENTS = testing/testing.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -402,16 +419,29 @@ LIBRARY_SEARCH_PATHS = ( "$(SDKROOT)/usr/lib/swift", "$(inherited)", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", ); MARKETING_VERSION = 1.0; ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = ( + "$(inherited)", + "-fprofile-instr-generate", + "-fcoverage-mapping", + ); OTHER_LDFLAGS = ( "$(inherited)", - "-L$(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME)", + "-fprofile-instr-generate", + "-L$(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "-L$(SDKROOT)/usr/lib/swift", + "-lswiftCompatibility56", + "-lswiftCompatibilityPacks", ); + OTHER_SWIFT_FLAGS = "$(inherited) -profile-generate -profile-coverage-mapping"; PRODUCT_BUNDLE_IDENTIFIER = com.invertase.testing; PRODUCT_NAME = testing; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "testing/testing-Bridging-Header.h"; + SWIFT_VERSION = 5.0; WARNING_CFLAGS = "-Wno-nullability-completeness"; }; name = Debug; @@ -421,6 +451,7 @@ baseConfigurationReference = 2867162C3D0FAFF97CDACF63 /* Pods-testing.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_CODE_COVERAGE = YES; CODE_SIGN_ENTITLEMENTS = testing/testing.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -442,16 +473,29 @@ LIBRARY_SEARCH_PATHS = ( "$(SDKROOT)/usr/lib/swift", "$(inherited)", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", ); MARKETING_VERSION = 1.0; ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = ( + "$(inherited)", + "-fprofile-instr-generate", + "-fcoverage-mapping", + ); OTHER_LDFLAGS = ( "$(inherited)", - "-L$(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME)", + "-fprofile-instr-generate", + "-L$(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "-L$(SDKROOT)/usr/lib/swift", + "-lswiftCompatibility56", + "-lswiftCompatibilityPacks", ); + OTHER_SWIFT_FLAGS = "$(inherited) -profile-generate -profile-coverage-mapping"; PRODUCT_BUNDLE_IDENTIFIER = com.invertase.testing; PRODUCT_NAME = testing; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "testing/testing-Bridging-Header.h"; + SWIFT_VERSION = 5.0; WARNING_CFLAGS = "-Wno-nullability-completeness"; }; name = Release; diff --git a/tests/ios/testing/AppDelegate.mm b/tests/ios/testing/AppDelegate.mm index fd878417e1..4cad1670dd 100644 --- a/tests/ios/testing/AppDelegate.mm +++ b/tests/ios/testing/AppDelegate.mm @@ -19,13 +19,111 @@ #import "RNFBMessagingModule.h" #import "RNFBAppCheckModule.h" +#import "RNFBTestingCoverageProfile.h" #import #import +static NSString *RNFBTestingDescribeApplicationState(UIApplicationState state) +{ + switch (state) { + case UIApplicationStateActive: + return @"active"; + case UIApplicationStateInactive: + return @"inactive"; + case UIApplicationStateBackground: + return @"background"; + default: + return @"unknown"; + } +} + +static NSString *RNFBTestingDescribeSceneActivationState(UISceneActivationState state) +{ + switch (state) { + case UISceneActivationStateUnattached: + return @"unattached"; + case UISceneActivationStateForegroundInactive: + return @"foregroundInactive"; + case UISceneActivationStateForegroundActive: + return @"foregroundActive"; + case UISceneActivationStateBackground: + return @"background"; + default: + return @"unknown"; + } +} + +static void RNFBTestingLogLifecycle(NSString *event) +{ + UIApplication *app = UIApplication.sharedApplication; + NSMutableString *sceneSummary = [NSMutableString string]; + for (UIScene *scene in app.connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + UIWindowScene *windowScene = (UIWindowScene *)scene; + [sceneSummary appendFormat:@" %@=%@", + scene.session.persistentIdentifier, + RNFBTestingDescribeSceneActivationState(windowScene.activationState)]; + } + if (sceneSummary.length == 0) { + [sceneSummary appendString:@" (no UIWindowScene)"]; + } + NSLog(@"[rnfb-lifecycle] event=%@ UIApplication.state=%@ scenes:%@", + event, + RNFBTestingDescribeApplicationState(app.applicationState), + sceneSummary); +} + +static void RNFBTestingScheduleLifecycleProbes(void) +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + RNFBTestingLogLifecycle(@"probe+30s"); + }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(60 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + RNFBTestingLogLifecycle(@"probe+60s"); + }); + }); +} + +static void RNFBTestingRegisterLifecycleObservers(id observer) +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + NSArray *names = @[ + UIApplicationDidBecomeActiveNotification, + UIApplicationWillResignActiveNotification, + UIApplicationDidEnterBackgroundNotification, + UIApplicationWillEnterForegroundNotification, + UISceneDidActivateNotification, + UISceneWillDeactivateNotification, + UISceneDidEnterBackgroundNotification, + UISceneWillEnterForegroundNotification, + ]; + for (NSString *name in names) { + [center addObserver:observer selector:@selector(rnfb_applicationLifecycleNotification:) name:name object:nil]; + } + }); +} + @implementation AppDelegate +- (void)rnfb_applicationLifecycleNotification:(NSNotification *)notification +{ + RNFBTestingLogLifecycle(notification.name); +} + - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + RNFBTestingRegisterLifecycleObservers(self); + RNFBTestingScheduleLifecycleProbes(); + RNFBTestingLogLifecycle(@"didFinishLaunching+before"); + + RNFBTestingConfigureCoverageProfilePath(); + // Initialize RNFBAppCheckModule, it sets the custom RNFBAppCheckProviderFactory // which lets us configure any of the available native platform providers, // and reconfigure if needed, dynamically after `[FIRApp configure]` just like the other platforms. @@ -47,20 +145,9 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( // You can add your custom initial props in the dictionary below. // They will be passed down to the ViewController used by React Native. self.initialProps = [RNFBMessagingModule addCustomPropsToUserProps:nil withLaunchOptions:@{}]; - return [super application:application didFinishLaunchingWithOptions:launchOptions]; -} - -- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options { - return NO; -} - -- (BOOL)application:(nonnull UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler: -#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= 12000) /* __IPHONE_12_0 */ - (nonnull void (^)(NSArray> *_Nullable))restorationHandler { -#else - (nonnull void (^)(NSArray *_Nullable))restorationHandler { -#endif - return NO; + BOOL didFinish = [super application:application didFinishLaunchingWithOptions:launchOptions]; + RNFBTestingLogLifecycle(@"didFinishLaunching+after"); + return didFinish; } - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSString *)fcmToken { diff --git a/tests/ios/testing/Info.plist b/tests/ios/testing/Info.plist index e77f4886bd..b545f687c5 100644 --- a/tests/ios/testing/Info.plist +++ b/tests/ios/testing/Info.plist @@ -39,7 +39,6 @@ reactnativefirebase.page.link - CFBundleVersion 200 diff --git a/tests/ios/testing/RNFBTestingCoverageModule.m b/tests/ios/testing/RNFBTestingCoverageModule.m new file mode 100644 index 0000000000..1cb9718df5 --- /dev/null +++ b/tests/ios/testing/RNFBTestingCoverageModule.m @@ -0,0 +1,23 @@ +#import +#import + +#import "RNFBTestingCoverageProfile.h" + +@interface RNFBTestingCoverageModule : NSObject +@end + +@implementation RNFBTestingCoverageModule + +RCT_EXPORT_MODULE(RNFBTestingCoverage); + +RCT_EXPORT_METHOD(flush : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) +{ + int status = RNFBTestingFlushCoverageProfile(); + if (status == 0) { + resolve(@(YES)); + } else { + reject(@"coverage_flush_failed", @"Failed to write LLVM profile data", nil); + } +} + +@end diff --git a/tests/ios/testing/RNFBTestingCoverageProfile.h b/tests/ios/testing/RNFBTestingCoverageProfile.h new file mode 100644 index 0000000000..592148747a --- /dev/null +++ b/tests/ios/testing/RNFBTestingCoverageProfile.h @@ -0,0 +1,12 @@ +#import + +#ifdef __cplusplus +extern "C" { +#endif + +void RNFBTestingConfigureCoverageProfilePath(void); +int RNFBTestingFlushCoverageProfile(void); + +#ifdef __cplusplus +} +#endif diff --git a/tests/ios/testing/RNFBTestingCoverageProfile.mm b/tests/ios/testing/RNFBTestingCoverageProfile.mm new file mode 100644 index 0000000000..86a92da6f7 --- /dev/null +++ b/tests/ios/testing/RNFBTestingCoverageProfile.mm @@ -0,0 +1,30 @@ +#import "RNFBTestingCoverageProfile.h" + +extern "C" { +int __llvm_profile_write_file(void); +void __llvm_profile_set_filename(const char *Name); +const char *__llvm_profile_get_filename(void); +} + +static NSString *RNFBTestingCoverageProfilePath(void) +{ + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + return [[paths firstObject] stringByAppendingPathComponent:@"coverage.profraw"]; +} + +extern "C" void RNFBTestingConfigureCoverageProfilePath(void) +{ + __llvm_profile_set_filename(RNFBTestingCoverageProfilePath().UTF8String); +} + +extern "C" int RNFBTestingFlushCoverageProfile(void) +{ + RNFBTestingConfigureCoverageProfilePath(); + int status = __llvm_profile_write_file(); + NSLog( + @"[ios-native-coverage] flush status=%d path=%@ runtimePath=%s", + status, + RNFBTestingCoverageProfilePath(), + __llvm_profile_get_filename() ?: "(null)"); + return status; +} diff --git a/tests/ios/testing/RNFBTestingSwiftRuntimeStub.swift b/tests/ios/testing/RNFBTestingSwiftRuntimeStub.swift new file mode 100644 index 0000000000..ade62269cb --- /dev/null +++ b/tests/ios/testing/RNFBTestingSwiftRuntimeStub.swift @@ -0,0 +1,2 @@ +// Ensures Xcode links Swift compatibility libraries for static Firebase Swift pods. +enum RNFBTestingSwiftRuntimeStub {} diff --git a/tests/ios/testing/main.m b/tests/ios/testing/main.m index b3bfd27624..83e8c25327 100644 --- a/tests/ios/testing/main.m +++ b/tests/ios/testing/main.m @@ -1,10 +1,18 @@ /** - * Copyright (c) 2015-present, Facebook, Inc. - * All rights reserved. + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. */ #import @@ -13,7 +21,7 @@ int main(int argc, char *argv[]) { - @autoreleasepool { + @autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); } } diff --git a/tests/ios/testing/testing-Bridging-Header.h b/tests/ios/testing/testing-Bridging-Header.h new file mode 100644 index 0000000000..a3e0bd3aea --- /dev/null +++ b/tests/ios/testing/testing-Bridging-Header.h @@ -0,0 +1 @@ +// Intentionally empty — required when adding Swift sources to this Obj-C target. diff --git a/tests/nyc.config.js b/tests/nyc.config.js index 72e59bf6a4..9000d7da2b 100644 --- a/tests/nyc.config.js +++ b/tests/nyc.config.js @@ -4,7 +4,10 @@ module.exports = { statements: 95, functions: 95, branches: 95, - include: ['packages/*/lib/**/*.js'], + include: [ + 'packages/*/lib/**/*.{js,ts,tsx}', + 'packages/*/dist/**/*.js', + ], exclude: [ '**/common/lib/**', '**/lib/handlers.js', @@ -12,7 +15,8 @@ module.exports = { 'packages/database/lib/DatabaseSyncTree.js', ], cwd: '..', - sourceMap: false, + sourceMap: true, + 'exclude-after-remap': true, instrument: false, reporter: ['lcov', 'html', 'text-summary'], }; diff --git a/tests/package.json b/tests/package.json index c832fe7382..1851c3d855 100644 --- a/tests/package.json +++ b/tests/package.json @@ -51,7 +51,7 @@ "firebase-tools": "^15.16.0", "jest-circus": "^30.3.0", "jest-environment-node": "^30.3.0", - "jet": "0.9.0-dev.13", + "jet": "patch:jet@npm%3A0.9.0-dev.13#~/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch", "mocha": "^11.7.5", "nyc": "^18.0.0", "patch-package": "^8.0.1", diff --git a/tests/scripts/process-ios-native-coverage.js b/tests/scripts/process-ios-native-coverage.js new file mode 100644 index 0000000000..17a9a779e8 --- /dev/null +++ b/tests/scripts/process-ios-native-coverage.js @@ -0,0 +1,228 @@ +#!/usr/bin/env node +/* + * Merge LLVM profraw from Detox iOS e2e runs and export an lcov report for Codecov. + * + * See okf-bundle/testing/coverage-design.md for the full pipeline description. + */ +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +const repoRoot = path.resolve(__dirname, '../..'); +const testsDir = path.join(repoRoot, 'tests'); + +function parseArgs(argv) { + const options = { + derivedData: path.join(testsDir, 'ios/build'), + configuration: 'Debug', + appName: 'testing', + output: path.join(repoRoot, 'coverage/ios-native.lcov.info'), + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--derived-data') { + options.derivedData = path.resolve(argv[i + 1]); + i += 1; + } else if (arg === '--configuration') { + options.configuration = argv[i + 1]; + i += 1; + } else if (arg === '--app-name') { + options.appName = argv[i + 1]; + i += 1; + } else if (arg === '--output') { + options.output = path.resolve(argv[i + 1]); + i += 1; + } else if (arg === '--help' || arg === '-h') { + // eslint-disable-next-line no-console + console.log(`Usage: node tests/scripts/process-ios-native-coverage.js [options] + +Options: + --derived-data Detox/Xcode derived data (default: tests/ios/build) + --configuration Xcode configuration (default: Debug) + --app-name App product name (default: testing) + --output lcov output path (default: coverage/ios-native.lcov.info) +`); + process.exit(0); + } + } + + return options; +} + +function walkFiles(dir, matcher, results = []) { + if (!fs.existsSync(dir)) { + return results; + } + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkFiles(fullPath, matcher, results); + } else if (matcher(fullPath)) { + results.push(fullPath); + } + } + + return results; +} + +function normalizeSourcePath(sourcePath) { + const normalized = sourcePath.replace(/\\/g, '/'); + + const packagesIdx = normalized.indexOf('/packages/'); + if (packagesIdx >= 0) { + return normalized.slice(packagesIdx + 1); + } + + const rnfbMatch = normalized.match(/@react-native-firebase\/([^/]+)\/(.+)$/); + if (rnfbMatch) { + return `packages/${rnfbMatch[1]}/${rnfbMatch[2]}`; + } + + const testsIdx = normalized.indexOf('/tests/'); + if (testsIdx >= 0) { + return normalized.slice(testsIdx + 1); + } + + return normalized.replace(/^\.\//, ''); +} + +function runOrThrow(command, args) { + try { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const stderr = error.stderr ? error.stderr.toString() : ''; + const stdout = error.stdout ? error.stdout.toString() : ''; + throw new Error( + `${command} ${args.join(' ')} failed:\n${stderr || stdout || error.message}`, + ); + } +} + +function runToFileOrThrow(command, args, outputPath) { + let stderr = ''; + try { + execFileSync(command, args, { + stdio: ['ignore', fs.openSync(outputPath, 'w'), 'pipe'], + }); + } catch (error) { + stderr = error.stderr ? error.stderr.toString() : ''; + throw new Error( + `${command} ${args.join(' ')} failed:\n${stderr || error.message}`, + ); + } +} + +async function rewriteLcovFile(inputPath, outputPath) { + const input = fs.createReadStream(inputPath, { encoding: 'utf8' }); + const output = fs.createWriteStream(outputPath, { encoding: 'utf8' }); + const lines = readline.createInterface({ input, crlfDelay: Infinity }); + + let sourceFileCount = 0; + let packagesHits = 0; + + for await (const line of lines) { + if (line.startsWith('SF:')) { + sourceFileCount += 1; + const normalizedPath = normalizeSourcePath(line.slice(3)); + if (normalizedPath.startsWith('packages/')) { + packagesHits += 1; + } + output.write(`SF:${normalizedPath}\n`); + } else { + output.write(`${line}\n`); + } + } + + await new Promise((resolve, reject) => { + output.end(() => resolve()); + output.on('error', reject); + }); + + return { sourceFileCount, packagesHits }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const productsDir = path.join( + options.derivedData, + 'Build/Products', + `${options.configuration}-iphonesimulator`, + ); + const appBinary = path.join(productsDir, `${options.appName}.app`, options.appName); + const profileDataDir = path.join(options.derivedData, 'Build/ProfileData'); + const simulatorCoverageDir = path.join(options.derivedData, 'output/coverage'); + + const profrawFiles = [ + ...walkFiles(simulatorCoverageDir, filePath => filePath.endsWith('.profraw')), + ...walkFiles(profileDataDir, filePath => filePath.endsWith('.profraw')), + ]; + + if (profrawFiles.length === 0) { + // eslint-disable-next-line no-console + console.error( + `[ios-native-coverage] No .profraw files under ${simulatorCoverageDir} or ${profileDataDir}.`, + ); + process.exit(1); + } + + // eslint-disable-next-line no-console + console.log( + `[ios-native-coverage] Found ${profrawFiles.length} profraw file(s): ${profrawFiles.join(', ')}`, + ); + + if (!fs.existsSync(appBinary)) { + throw new Error(`App binary not found at ${appBinary}`); + } + + fs.mkdirSync(path.dirname(options.output), { recursive: true }); + + const profdataPath = path.join(path.dirname(options.output), 'ios-native.profdata'); + runOrThrow('xcrun', [ + 'llvm-profdata', + 'merge', + '-sparse', + ...profrawFiles, + '-o', + profdataPath, + ]); + + const rawLcovPath = path.join(path.dirname(options.output), 'ios-native.lcov.raw'); + try { + runToFileOrThrow('xcrun', [ + 'llvm-cov', + 'export', + '-instr-profile', + profdataPath, + '-object', + appBinary, + '-format=lcov', + ], rawLcovPath); + + const { sourceFileCount, packagesHits } = await rewriteLcovFile(rawLcovPath, options.output); + + // eslint-disable-next-line no-console + console.log( + `[ios-native-coverage] Wrote ${options.output} (${sourceFileCount} source file(s), ${packagesHits} under packages/)`, + ); + + profrawFiles.forEach(profrawPath => { + fs.rmSync(profrawPath, { force: true }); + // eslint-disable-next-line no-console + console.log(`[ios-native-coverage] Removed processed profraw: ${profrawPath}`); + }); + } finally { + fs.rmSync(rawLcovPath, { force: true }); + } +} + +main().catch(error => { + // eslint-disable-next-line no-console + console.error(`[ios-native-coverage] ${error.message}`); + process.exit(1); +}); diff --git a/tests/scripts/pull-native-coverage.js b/tests/scripts/pull-native-coverage.js new file mode 100644 index 0000000000..d1c9bb1027 --- /dev/null +++ b/tests/scripts/pull-native-coverage.js @@ -0,0 +1,69 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const TEST_APP_PACKAGE = 'com.invertase.testing'; + +function pullAndroidCoverage(deviceId, options = {}) { + const { softFail = false, testsDir = path.resolve(__dirname, '..') } = options; + const emuOrig = `/data/user/0/${TEST_APP_PACKAGE}/files/coverage.ec`; + const emuDest = '/data/local/tmp/detox/coverage.ec'; + const localDestDir = path.join(testsDir, 'android/app/build/output/coverage'); + const localDestFile = path.join(localDestDir, 'emulator_coverage.ec'); + const adb = process.env.ANDROID_HOME + ? `${process.env.ANDROID_HOME}/platform-tools/adb` + : 'adb'; + + try { + execSync(`${adb} -s ${deviceId} shell "run-as ${TEST_APP_PACKAGE} cat ${emuOrig} > ${emuDest}"`); + fs.mkdirSync(localDestDir, { recursive: true }); + execSync(`${adb} -s ${deviceId} pull ${emuDest} ${localDestFile}`); + console.log(`Coverage data downloaded to: ${localDestFile}`); + return localDestFile; + } catch (error) { + const message = `Android native coverage pull failed: ${error.message}`; + if (softFail) { + console.warn(`[native-coverage] ${message}`); + return null; + } + throw new Error(message); + } +} + +function pullIosCoverage(deviceId, options = {}) { + const testsDir = options.testsDir || path.resolve(__dirname, '..'); + const localDestDir = path.join(testsDir, 'ios/build/output/coverage'); + const container = execSync(`xcrun simctl get_app_container ${deviceId} ${TEST_APP_PACKAGE} data`, { + encoding: 'utf8', + }).trim(); + fs.mkdirSync(localDestDir, { recursive: true }); + + const profrawList = execSync( + `find "${container}" \\( -path "*/Documents/coverage.profraw" -o -path "*/tmp/coverage.profraw" -o -name '*.profraw' \\)`, + { encoding: 'utf8' }, + ) + .trim() + .split('\n') + .filter(Boolean); + + if (profrawList.length === 0) { + throw new Error(`No iOS coverage profraw files found under ${container}`); + } + + const destPaths = profrawList.map((src, index) => { + const suffix = profrawList.length > 1 ? `_${index}` : ''; + const dest = path.join(localDestDir, `simulator_coverage${suffix}.profraw`); + execSync(`cp "${src}" "${dest}"`); + return dest; + }); + + console.log( + `Coverage data downloaded to: ${localDestDir} (${profrawList.length} profraw file(s))`, + ); + return destPaths; +} + +module.exports = { + pullAndroidCoverage, + pullIosCoverage, +}; diff --git a/yarn.lock b/yarn.lock index 5f754650cf..d7205a714f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11144,7 +11144,7 @@ __metadata: "detox@patch:detox@npm%3A20.51.0#~/.yarn/patches/detox-npm-20.51.0-3e13b6e309.patch": version: 20.51.0 - resolution: "detox@patch:detox@npm%3A20.51.0#~/.yarn/patches/detox-npm-20.51.0-3e13b6e309.patch::version=20.51.0&hash=9a9476" + resolution: "detox@patch:detox@npm%3A20.51.0#~/.yarn/patches/detox-npm-20.51.0-3e13b6e309.patch::version=20.51.0&hash=274e53" dependencies: "@wix-pilot/core": "npm:^3.4.2" "@wix-pilot/detox": "npm:^1.0.13" @@ -11190,7 +11190,7 @@ __metadata: optional: true bin: detox: local-cli/cli.js - checksum: 10/cdf7cb647640107a3435e0d281db4c5eea421f0ebb8e9f5ea0e5e0016afcdf90a50fda63d2483e9552b878066b88b7ad1ec2f682f696a072f9865a7742a2feaa + checksum: 10/8654a18d87c44d699b2a1f79d35dc26faebde02a9149f65e9a1ac819abb82dd6d83bb1e9338858ff12cdb2ed2dab51fe187291ac1006ea9b4f3825ae3b523cc2 languageName: node linkType: hard @@ -16337,6 +16337,29 @@ __metadata: languageName: node linkType: hard +"jet@patch:jet@npm%3A0.9.0-dev.13#~/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch": + version: 0.9.0-dev.13 + resolution: "jet@patch:jet@npm%3A0.9.0-dev.13#~/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch::version=0.9.0-dev.13&hash=735c11" + dependencies: + "@types/mocha": "npm:^10.0.10" + babel-plugin-istanbul: "npm:^7.0.0" + cosmiconfig: "npm:^9.0.0" + istanbul-lib-coverage: "npm:^3.2.2" + mocha: "npm:^11.1.0" + mocha-remote-client: "npm:^1.13.0" + mocha-remote-server: "npm:^1.13.0" + nyc: "npm:^17.1.0" + yargs: "npm:^17.7.2" + zod: "npm:^3.24.1" + peerDependencies: + react: "*" + react-native: "*" + bin: + jet: jet.js + checksum: 10/86aedebdce97b4e1e5c312a4c521a80e482414ee868cc4a915ad6e83696b7403924b3f06bb6fe8b6d86055a441c1412986d7631ea272104710ed707433df64d0 + languageName: node + linkType: hard + "jimp-compact@npm:0.16.1": version: 0.16.1 resolution: "jimp-compact@npm:0.16.1" @@ -19483,7 +19506,7 @@ __metadata: languageName: node linkType: hard -"mocha-remote-client@npm:^1.13.0": +"mocha-remote-client@npm:1.13.2": version: 1.13.2 resolution: "mocha-remote-client@npm:1.13.2" dependencies: @@ -19495,6 +19518,18 @@ __metadata: languageName: node linkType: hard +"mocha-remote-client@patch:mocha-remote-client@npm%3A1.13.2#~/.yarn/patches/mocha-remote-client-npm-1.13.2-a2e7596aba.patch": + version: 1.13.2 + resolution: "mocha-remote-client@patch:mocha-remote-client@npm%3A1.13.2#~/.yarn/patches/mocha-remote-client-npm-1.13.2-a2e7596aba.patch::version=1.13.2&hash=fd012f" + dependencies: + debug: "npm:^4.3.4" + fast-equals: "npm:^5.0.1" + mocha-remote-common: "npm:1.13.2" + ws: "npm:^8.17.1" + checksum: 10/57f45130c7116b41a347ea516d932b890e8355ab1c8dd880f59957cb35b8f6eaf9bc94f62a2d21e1f1ef99d9883f01e6336554fae0a9fd565b18ec1553771aa1 + languageName: node + linkType: hard + "mocha-remote-common@npm:1.13.2": version: 1.13.2 resolution: "mocha-remote-common@npm:1.13.2" @@ -19504,7 +19539,7 @@ __metadata: languageName: node linkType: hard -"mocha-remote-server@npm:^1.13.0": +"mocha-remote-server@npm:1.13.2": version: 1.13.2 resolution: "mocha-remote-server@npm:1.13.2" dependencies: @@ -19516,6 +19551,18 @@ __metadata: languageName: node linkType: hard +"mocha-remote-server@patch:mocha-remote-server@npm%3A1.13.2#~/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch": + version: 1.13.2 + resolution: "mocha-remote-server@patch:mocha-remote-server@npm%3A1.13.2#~/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch::version=1.13.2&hash=5b470d" + dependencies: + debug: "npm:^4.3.4" + flatted: "npm:^3.3.1" + mocha-remote-common: "npm:1.13.2" + ws: "npm:^8.17.1" + checksum: 10/c5861226362636fac484237e3653e6442c88a7b0b12b240ab33482961b09b5c2a1a54190c03237b2c2274616be6fe7e5c8484b355a00bb28ee401cb7b81351b6 + languageName: node + linkType: hard + "mocha@npm:^11.1.0, mocha@npm:^11.7.5": version: 11.7.5 resolution: "mocha@npm:11.7.5" @@ -22276,7 +22323,7 @@ __metadata: firebase-tools: "npm:^15.16.0" jest-circus: "npm:^30.3.0" jest-environment-node: "npm:^30.3.0" - jet: "npm:0.9.0-dev.13" + jet: "patch:jet@npm%3A0.9.0-dev.13#~/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch" mocha: "npm:^11.7.5" nyc: "npm:^18.0.0" patch-package: "npm:^8.0.1"