From f7d5176b6ffb316351e56f0d2115f78a2c92b3f0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 7 Oct 2026 13:50:31 +0500 Subject: [PATCH 1/5] Packaging and README release blockers (BLK-17, API-49, B-3..B-5, R-1..R-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ship native-versions.json in both tarballs: prepack stages the repo-root file at each package root (scripts/pack-native-versions.ts), postpack removes it; the podspecs read that copy first and the Gradle walk-up finds it. Without it pod install and Gradle configuration failed in every app installed from npm (BLK-17). - files: `plugin/build/**` instead of `plugin/build`. Yarn 4 drops the files under a nested directory entry, so `yarn pack` shipped app.plugin.js without the compiled plugin it requires (B-3). - Exclude __tests__, __mocks__, testSupport and android/src/test from the tarballs (B-5). ios/Support/Tests stays: SwiftPM refuses the Support manifest without its declared test target's sources. - podspec prepare_command: download into a mktemp directory under TMPDIR, unpack into a staging directory beside the destination and rename into place, instead of a fixed /tmp path that concurrent pod installs shared (B-4). - Codegen types from the react-native root (`CodegenTypes.*`) instead of the deep Libraries/Types/CodegenTypes path, which 0.87 types only behind an opt-in condition; widen the RN version type for 0.87's numeric prerelease. Consumer tsc on 0.87 failed (API-49). Codegen schemas are byte-identical on 0.81 and 0.87. The library tsconfig drops the deep-import condition so a regression fails our typecheck. - README: static BugseeLaunchOptions.serialize; Release symbolication steps for bare apps (Bugsee Gradle plugin with NDK symbols, iOS bundle phase through bugsee-xcode.sh); pnpm 10+/11 allowBuilds for @bugsee/cli; fmt C++17 troubleshooting for RN 0.81/0.82 + Xcode 26.4. - CI: scripts/check-pack-install.sh installs the packed tarballs into a fresh `react-native init` app: consumer tsc (template tsconfig) on 0.81-0.87, Gradle configuration on 0.81/0.87, pod install on 0.81/0.87. rn compat matrix widened to every minor. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .github/workflows/ci.yml | 50 ++++- .gitignore | 4 + .../BugseeReactNativeFeedback.podspec | 33 +++- packages/react-native-feedback/package.json | 7 +- .../src/NativeBugseeFeedback.ts | 10 +- .../react-native/BugseeReactNative.podspec | 27 ++- packages/react-native/README.md | 145 +++++++++++++- packages/react-native/package.json | 11 +- packages/react-native/src/NativeBugsee.ts | 49 ++--- packages/react-native/src/wrapper/collect.ts | 7 +- .../__tests__/event-emitter-wiring.test.ts | 3 +- .../__tests__/pack-native-versions.test.ts | 74 +++++++ scripts/__tests__/package-metadata.test.ts | 34 +++- scripts/check-pack-install.sh | 131 ++++++++++++ scripts/cli-pack-native-versions.ts | 18 ++ scripts/pack-native-versions.ts | 50 +++++ scripts/pack/__tests__/pack-contents.test.ts | 149 ++++++++++++++ .../pack/__tests__/podspec-prepare.test.ts | 186 ++++++++++++++++++ tsconfig.base.json | 10 +- tsconfig.example.json | 4 + 20 files changed, 942 insertions(+), 60 deletions(-) create mode 100644 scripts/__tests__/pack-native-versions.test.ts create mode 100755 scripts/check-pack-install.sh create mode 100644 scripts/cli-pack-native-versions.ts create mode 100644 scripts/pack-native-versions.ts create mode 100644 scripts/pack/__tests__/pack-contents.test.ts create mode 100644 scripts/pack/__tests__/podspec-prepare.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 161ecb2f..4fd1f599 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,7 +184,7 @@ jobs: strategy: fail-fast: false matrix: - react-native: ['0.81', '0.83', '0.86', '0.87'] + react-native: ['0.81', '0.82', '0.83', '0.84', '0.85', '0.86', '0.87'] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -196,6 +196,54 @@ jobs: # and swapping the npm dependency does not swap the project. - run: ./scripts/check-rn-compat.sh ${{ matrix.react-native }} + # The packed tarballs installed into a fresh `react-native init` app, as a + # customer gets them from npm (scripts/check-pack-install.sh). Inside the + # workspace every lookup finds the repo root and the repo's own tsconfig, + # so none of the jobs above can see what this does: native-versions.json + # missing from the tarball broke Gradle and pod install for every npm + # install (BLK-17), and the app's own `tsc` failed on 0.87 (API-49). + # Typecheck on every supported minor; Gradle configuration on the floor + # and the latest. + pack-install: + name: pack install (${{ matrix.react-native }}${{ matrix.android && ', android' || '' }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + react-native: ['0.81', '0.82', '0.83', '0.84', '0.85', '0.86', '0.87'] + include: + - react-native: '0.81' + android: true + - react-native: '0.87' + android: true + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.18' + - uses: actions/setup-java@v4 + if: matrix.android + with: + distribution: temurin + java-version: 17 + - run: ./scripts/check-pack-install.sh ${{ matrix.react-native }} --typecheck ${{ matrix.android && '--android' || '' }} + + # The iOS half: `pod install` evaluates both podspecs from the tarballs and + # vendors the SDK (BLK-17). + pack-install-ios: + name: pack install (${{ matrix.react-native }}, ios) + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + react-native: ['0.81', '0.87'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.18' + - run: ./scripts/check-pack-install.sh ${{ matrix.react-native }} --ios + android: name: android runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 25b37bac..7a499efa 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ packages/react-native-feedback/BugseeFeedbackSources/ reports/ packages/react-native/.bugsee-xcframework-version packages/react-native-feedback/.bugsee-feedback-sources-version + +# Staged into each package by prepack (scripts/pack-native-versions.ts) and +# removed by postpack; the repo-root file is the only source. +packages/*/native-versions.json diff --git a/packages/react-native-feedback/BugseeReactNativeFeedback.podspec b/packages/react-native-feedback/BugseeReactNativeFeedback.podspec index c9990ac8..72ea3fd4 100644 --- a/packages/react-native-feedback/BugseeReactNativeFeedback.podspec +++ b/packages/react-native-feedback/BugseeReactNativeFeedback.podspec @@ -1,7 +1,16 @@ require 'json' package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) -native = JSON.parse(File.read(File.join(__dir__, '..', '..', 'native-versions.json'))) +# The one source of every native pin. In this repo it is the root file. In an +# app the package sits in node_modules, where the root file is not above it, so +# prepack ships a copy at the package root (scripts/pack-native-versions.ts); +# that copy is read first. +native_versions_file = [ + File.join(__dir__, 'native-versions.json'), + File.join(__dir__, '..', '..', 'native-versions.json'), +].find { |path| File.file?(path) } +raise "native-versions.json not found in #{__dir__} or the repo root above it" unless native_versions_file +native = JSON.parse(File.read(native_versions_file)) Pod::Spec.new do |s| s.name = 'BugseeReactNativeFeedback' @@ -49,16 +58,20 @@ Pod::Spec.new do |s| VERSION="#{native['ios']['sdk']}" STAMP=".bugsee-feedback-sources-version" if [ ! -d "BugseeFeedbackSources" ] || [ "$(cat "${STAMP}" 2>/dev/null)" != "${VERSION}" ]; then - rm -rf "BugseeFeedbackSources" - ARCHIVE="/tmp/feedback-spm-${VERSION}.tar.gz" - curl -sSfL -o "${ARCHIVE}" \ + # A download directory of this run's own, not a fixed /tmp path that + # concurrent pod installs share; the sources are staged beside their + # destination and renamed into place, so a failure leaves nothing. + WORK="$(mktemp -d "${TMPDIR:-/tmp}/bugsee-feedback.XXXXXX")" + STAGE="$(mktemp -d "./.bugsee-feedback.XXXXXX")" + trap 'rm -rf "${WORK}" "${STAGE}"' EXIT + curl -sSfL -o "${WORK}/feedback-spm.tar.gz" \ "https://codeload.github.com/bugsee/feedback-spm/tar.gz/refs/tags/${VERSION}" - STAGE="$(mktemp -d)" - tar -xzf "${ARCHIVE}" -C "${STAGE}" - SRC="$(echo "${STAGE}"/feedback-spm-*/Sources/BugseeFeedback)" - mkdir -p "BugseeFeedbackSources" - cp -R "${SRC}/." "BugseeFeedbackSources/" - rm -rf "${STAGE}" "${ARCHIVE}" + tar -xzf "${WORK}/feedback-spm.tar.gz" -C "${WORK}" + SRC="$(echo "${WORK}"/feedback-spm-*/Sources/BugseeFeedback)" + mkdir "${STAGE}/BugseeFeedbackSources" + cp -R "${SRC}/." "${STAGE}/BugseeFeedbackSources/" + rm -rf "BugseeFeedbackSources" + mv "${STAGE}/BugseeFeedbackSources" "BugseeFeedbackSources" printf '%s' "${VERSION}" > "${STAMP}" fi CMD diff --git a/packages/react-native-feedback/package.json b/packages/react-native-feedback/package.json index 4af232f6..135671a8 100644 --- a/packages/react-native-feedback/package.json +++ b/packages/react-native-feedback/package.json @@ -7,10 +7,14 @@ "main": "src/index.ts", "files": [ "src", + "!src/**/__tests__", + "!src/testSupport", "android", + "!android/src/test", "ios", "react-native.config.js", "*.podspec", + "native-versions.json", "LICENSE", "README.md" ], @@ -23,7 +27,8 @@ "react-native": "0.87.1" }, "scripts": { - "prepack": "node ../../scripts/cli-check-releasable-pins.ts" + "prepack": "node ../../scripts/cli-check-releasable-pins.ts && node ../../scripts/cli-pack-native-versions.ts stage", + "postpack": "node ../../scripts/cli-pack-native-versions.ts unstage" }, "codegenConfig": { "name": "RNBugseeFeedbackSpec", diff --git a/packages/react-native-feedback/src/NativeBugseeFeedback.ts b/packages/react-native-feedback/src/NativeBugseeFeedback.ts index f128f4ea..d5752ad9 100644 --- a/packages/react-native-feedback/src/NativeBugseeFeedback.ts +++ b/packages/react-native-feedback/src/NativeBugseeFeedback.ts @@ -1,6 +1,8 @@ -import type { TurboModule } from 'react-native'; +// CodegenTypes from the package root: 0.87 declares no types for the deep +// `react-native/Libraries/Types/CodegenTypes` path unless the consumer opts in, +// and this file is compiled by the consumer's tsc (API-49). +import type { CodegenTypes, TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; -import type { EventEmitter } from 'react-native/Libraries/Types/CodegenTypes'; /** * The native surface. Codegen turns this into the Java and ObjC++ specs. @@ -29,8 +31,8 @@ export interface Spec extends TurboModule { */ setAppearanceColor(name: string, r: number, g: number, b: number, a: number): void; - readonly onNewMessagesReceived: EventEmitter<{ messagesJson: string }>; - readonly onNewMessageSent: EventEmitter<{ message: string }>; + readonly onNewMessagesReceived: CodegenTypes.EventEmitter<{ messagesJson: string }>; + readonly onNewMessageSent: CodegenTypes.EventEmitter<{ message: string }>; } export default TurboModuleRegistry.getEnforcing('BugseeFeedbackModule'); diff --git a/packages/react-native/BugseeReactNative.podspec b/packages/react-native/BugseeReactNative.podspec index 085dddbd..eb2d7b6f 100644 --- a/packages/react-native/BugseeReactNative.podspec +++ b/packages/react-native/BugseeReactNative.podspec @@ -1,7 +1,16 @@ require 'json' package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) -native = JSON.parse(File.read(File.join(__dir__, '..', '..', 'native-versions.json'))) +# The one source of every native pin. In this repo it is the root file. In an +# app the package sits in node_modules, where the root file is not above it, so +# prepack ships a copy at the package root (scripts/pack-native-versions.ts); +# that copy is read first. +native_versions_file = [ + File.join(__dir__, 'native-versions.json'), + File.join(__dir__, '..', '..', 'native-versions.json'), +].find { |path| File.file?(path) } +raise "native-versions.json not found in #{__dir__} or the repo root above it" unless native_versions_file +native = JSON.parse(File.read(native_versions_file)) Pod::Spec.new do |s| s.name = 'BugseeReactNative' @@ -51,16 +60,24 @@ Pod::Spec.new do |s| VERSION="#{native['ios']['sdk']}" STAMP=".bugsee-xcframework-version" if [ ! -d "Bugsee.xcframework" ] || [ "$(cat "${STAMP}" 2>/dev/null)" != "${VERSION}" ]; then - rm -rf "Bugsee.xcframework" - curl -sSfL -o /tmp/Bugsee-${VERSION}.zip \ + # A download directory of this run's own. A fixed /tmp path is shared by + # every pod install on the machine: two at once overwrote and deleted + # each other's archive mid-extract ("bad CRC"). + WORK="$(mktemp -d "${TMPDIR:-/tmp}/bugsee-xcframework.XXXXXX")" + # Unpacked beside its destination, so the final mv is a rename on one + # filesystem: a failed download or extract leaves nothing half-written. + STAGE="$(mktemp -d "./.bugsee-xcframework.XXXXXX")" + trap 'rm -rf "${WORK}" "${STAGE}"' EXIT + curl -sSfL -o "${WORK}/Bugsee.zip" \ "https://download.bugsee.com/sdk/ios/spm/Bugsee-${VERSION}.zip" # Only the framework. The archive also carries the iOS SDK's own # README.md and LICENSE at its root, and extracting everything drops # them into this package -- where npm publishes README.md and LICENSE # whatever `files` says. That is how this package came to ship the SDK's # CocoaPods instructions and PLCrashReporter's licence. - unzip -q -o /tmp/Bugsee-${VERSION}.zip 'Bugsee.xcframework/*' -d . - rm -f /tmp/Bugsee-${VERSION}.zip + unzip -q -o "${WORK}/Bugsee.zip" 'Bugsee.xcframework/*' -d "${STAGE}" + rm -rf "Bugsee.xcframework" + mv "${STAGE}/Bugsee.xcframework" "Bugsee.xcframework" printf '%s' "${VERSION}" > "${STAMP}" fi CMD diff --git a/packages/react-native/README.md b/packages/react-native/README.md index bf9fb763..d67100f3 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -29,17 +29,33 @@ Manager. Earlier versions use CocoaPods, where the podspec vendors the same XCFramework โ€” run `pod install` as usual. Nothing is published to CocoaPods trunk, so no `pod repo update` is needed. -**Android.** Autolinking picks the module up; no Gradle changes are required. +**Android.** Autolinking picks the module up, and the app builds and +launches with no Gradle changes. Release symbolication needs the Bugsee Gradle +plugin and the source-map hooks: see [Release symbolication](#release-symbolication). + +**pnpm 10 and later** run no dependency's install script unless the project +decides, and pnpm 11 fails the install until it does +(`ERR_PNPM_IGNORED_BUILDS`): `@bugsee/cli`, which uploads symbols and source +maps, has a `postinstall`. Its binary comes from a platform-specific optional +dependency, so the script is only a download fallback for registry mirrors +that lack those packages. Decide in `pnpm-workspace.yaml`: + +```yaml +allowBuilds: + '@bugsee/cli': false # true only if your registry lacks @bugsee/cli- +``` + +React Native itself also needs `nodeLinker: hoisted` under pnpm. ## Use ```ts -import Bugsee, { createDefaultLaunchOptions } from '@bugsee/react-native'; +import Bugsee, { BugseeLaunchOptions, createDefaultLaunchOptions } from '@bugsee/react-native'; const options = createDefaultLaunchOptions(); options.captureLogs = true; -await Bugsee.launch('', options.serialize()); +await Bugsee.launch('', BugseeLaunchOptions.serialize(options)); ``` `launch` resolves to whether the SDK started. It can decline โ€” already @@ -108,6 +124,16 @@ so when the root file already has one the pin goes into it (its braces must stand alone on their lines, or the prebuild refuses); a new block is written only when there is none. +## Release symbolication + +A Release build has to send Bugsee what turns its crash frames back into +source: the JS source map (with a debug id that ties it to the shipped +bundle), the R8 mapping and the native (NDK) symbols on Android, and the +dSYMs on iOS. **An Expo app gets all of it from the config plugin.** A bare +app needs the three sections below โ€” the Android source-map hooks, the Bugsee +Gradle plugin, and the iOS bundle phase. Without them the app still builds +and runs, but JS and native frames in Release reports stay unsymbolicated. + ## Android source maps Release builds give the Hermes bundle a debug id and upload its source map, so @@ -149,6 +175,119 @@ Hermes off, the plain JS bundle and Metro's map get the id instead. builds on a Windows host are not supported yet. The hook has not yet been verified with Gradle's configuration cache. +## Android: Bugsee Gradle plugin + +Uploads the R8/ProGuard mapping, the native (NDK) symbols and the build info +of each Release build. It is a Gradle plugin, so autolinking cannot add it; +the config plugin writes these same edits for an Expo app. + +`android/settings.gradle` โ€” the plugin is published to Maven Central, which +Gradle does not search for plugins unless told to: + +```groovy +pluginManagement { + includeBuild("../node_modules/@react-native/gradle-plugin") + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +``` + +`android/build.gradle` โ€” declare it at the version this package is built +against (`android.gradlePlugin` in +`node_modules/@bugsee/react-native/native-versions.json`), above +`apply plugin: "com.facebook.react.rootproject"`: + +```groovy +plugins { + id 'com.bugsee.android.gradle' version '4.0.7' apply false +} +``` + +`android/app/build.gradle` โ€” apply it, and for native crash symbols add the +NDK module at the SDK version the package pins, with symbol extraction on +(AGP extracts none by default, so there would be nothing to upload): + +```groovy +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" +apply plugin: "com.bugsee.android.gradle" + +android { + buildTypes { + release { + ndk { + debugSymbolLevel 'SYMBOL_TABLE' + } + } + } +} + +// bugseeDir as in "Android source maps" above. +def bugseeVersions = new groovy.json.JsonSlurper().parse(new File(bugseeDir, "native-versions.json")) + +dependencies { + implementation "com.bugsee:bugsee-android-ndk:${bugseeVersions.android.sdk}" +} +``` + +`android/bugsee.properties` (next to `android/build.gradle`; keep it out of +version control if the token is private): + +```properties +app_token= +plugin.ndk.enabled=true +``` + +## iOS: bundle phase + +The JS half of iOS symbolication. In Xcode, the app target's **Bundle React +Native code and images** build phase runs React Native's +`react-native-xcode.sh`. Point it at this package's `bugsee-xcode.sh` +instead, which runs React Native's script, then composes the Hermes source +map, gives the bundle and the map a debug id, and uploads the map: + +```sh +set -e + +WITH_ENVIRONMENT="$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh" +REACT_NATIVE_XCODE="${SRCROOT}/../node_modules/@bugsee/react-native/scripts/bugsee-xcode.sh" + +/bin/sh -c "\"$WITH_ENVIRONMENT\" \"$REACT_NATIVE_XCODE\"" +``` + +The upload needs a real token, from `BUGSEE_APP_TOKEN` or `BUGSEE_TOKEN_IOS` +in the build environment (for example `export BUGSEE_APP_TOKEN=โ€ฆ` in +`ios/.xcode.env.local`). Without one the phase bundles as usual and says it +skipped the upload; `BUGSEE_UPLOAD_SOURCEMAPS=false` turns the upload off, +and a failed upload warns without failing the build. Debug builds skip it. + +Native iOS frames come from the dSYMs. The config plugin adds an Archive +post-action that uploads them with `bugsee-cli xcode post-action`; a bare app +adds the same post-action to its scheme's Archive action. + +## Troubleshooting + +**`fmt`: "call to consteval function โ€ฆ is not a constant expression" (iOS, +React Native 0.81 and 0.82, Xcode 26.4 or later).** Not Bugsee: React Native +0.81 and 0.82 build `fmt` 11.0.2 from source, which newer Apple clang rejects +as C++20 ([fmtlib/fmt#4740](https://github.com/fmtlib/fmt/issues/4740)); 0.83 +and later ship `fmt` 12 and are not affected. Build that one pod as C++17 from +the `post_install` in `ios/Podfile`, after `react_native_post_install(...)`, +then run `pod install` again: + +```ruby + installer.pods_project.targets.each do |target| + next unless target.name == 'fmt' + target.build_configurations.each do |config| + config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17' + end + end +``` + ## Licence Commercial. See [LICENSE](./LICENSE) and https://www.bugsee.com/terms. diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 254a6dff..3013026e 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -7,17 +7,21 @@ "main": "src/index.ts", "files": [ "src", + "!src/**/__tests__", + "!src/**/__mocks__", "android", + "!android/src/test", "ios", "app.plugin.js", - "plugin/build", + "plugin/build/**", "scripts/hermes-sourcemaps.js", "scripts/compose-then-inject.js", "scripts/bugsee-xcode.sh", "scripts/hermesc-preserve-js.sh", "scripts/bugsee-sourcemaps.gradle", "react-native.config.js", - "*.podspec" + "*.podspec", + "native-versions.json" ], "dependencies": { "@bugsee/cli": "0.7.11", @@ -32,7 +36,8 @@ }, "scripts": { "build:plugin": "node ../../node_modules/typescript/lib/tsc.js -p plugin/tsconfig.json && node plugin/bake-native-versions.js", - "prepack": "node ../../scripts/cli-check-releasable-pins.ts" + "prepack": "node ../../scripts/cli-check-releasable-pins.ts && node ../../scripts/cli-pack-native-versions.ts stage", + "postpack": "node ../../scripts/cli-pack-native-versions.ts unstage" }, "codegenConfig": { "name": "RNBugseeSpec", diff --git a/packages/react-native/src/NativeBugsee.ts b/packages/react-native/src/NativeBugsee.ts index 14f1b5af..e777f92d 100644 --- a/packages/react-native/src/NativeBugsee.ts +++ b/packages/react-native/src/NativeBugsee.ts @@ -1,9 +1,10 @@ -import type { TurboModule } from 'react-native'; +// CodegenTypes from the package root, not the deep +// `react-native/Libraries/Types/CodegenTypes` path: 0.87 declares no types for +// deep imports unless the consumer opts in, and this file is compiled by the +// consumer's tsc (API-49). Root export and codegen's qualified-name support +// both exist from 0.80, below the 0.81 floor. +import type { CodegenTypes, TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; -import type { - EventEmitter, - UnsafeObject, -} from 'react-native/Libraries/Types/CodegenTypes'; /** * The native surface. Codegen turns this into the C++/Java/ObjC++ spec both @@ -34,7 +35,7 @@ export interface Spec extends TurboModule { * Deriving keeps one source, one mapping, and tests that cover both * platforms at once. */ - readonly onLifecycleEvent: EventEmitter<{ name: string; reportId?: string }>; + readonly onLifecycleEvent: CodegenTypes.EventEmitter<{ name: string; reportId?: string }>; /** @@ -46,7 +47,7 @@ export interface Spec extends TurboModule { * configuration are JS-side facts. Call before `launch`: the SDK reads the * wrapper while building a report's environment. */ - setWrapperInfo(identity: UnsafeObject): void; + setWrapperInfo(identity: CodegenTypes.UnsafeObject): void; /** * Publishes the regions the SDK must not record, for one display, as a flat * list of four-number rectangles: `[left, top, right, bottom, ...]`, in the @@ -118,7 +119,7 @@ export interface Spec extends TurboModule { * 6.5`/`Task 6.6` are what actually emit it, from the same per-module event * bus every other emitter here goes through. */ - readonly onDataRequest: EventEmitter<{ + readonly onDataRequest: CodegenTypes.EventEmitter<{ requestId: string; type: string; originX: number; @@ -142,12 +143,12 @@ export interface Spec extends TurboModule { */ setViewTreeEnabled(enabled: boolean): void; - launch(token: string, options: UnsafeObject): Promise; - relaunch(options: UnsafeObject): Promise; + launch(token: string, options: CodegenTypes.UnsafeObject): Promise; + relaunch(options: CodegenTypes.UnsafeObject): Promise; stop(): Promise; getStatus(): Promise; /** The options the SDK reports as being in effect. */ - getLaunchOptions(): Promise; + getLaunchOptions(): Promise; testCrash(): void; /** @@ -245,7 +246,7 @@ export interface Spec extends TurboModule { * dropped by the SDK; this event is not a second timeout that would pass * the original through. */ - readonly onNetworkFilterRequest: EventEmitter<{ requestId: string; eventJson: string }>; + readonly onNetworkFilterRequest: CodegenTypes.EventEmitter<{ requestId: string; eventJson: string }>; /** * Installs or removes the native network filter (`setNetworkEventFilter` * on both SDKs). `true` registers the bridge; `false` passes `null`, which @@ -281,7 +282,7 @@ export interface Spec extends TurboModule { * A request that never gets an answer is dropped by the SDK's own timeout; * this event is not a second one. */ - readonly onLogFilterRequest: EventEmitter<{ requestId: string; line: string }>; + readonly onLogFilterRequest: CodegenTypes.EventEmitter<{ requestId: string; line: string }>; /** * Installs or removes the native log filter (`setLogEventFilter` on both * SDKs). `true` registers the bridge; `false` passes `null`, which is how @@ -337,7 +338,7 @@ export interface Spec extends TurboModule { * passed that id. It sits beside `crumbJson`; it is not a key inside the * crumb the callback sees. An SDK crumb omits it. */ - readonly onBreadcrumbFilterRequest: EventEmitter<{ + readonly onBreadcrumbFilterRequest: CodegenTypes.EventEmitter<{ requestId: string; crumbJson: string; addId?: string; @@ -394,14 +395,14 @@ export interface Spec extends TurboModule { * because `UnsafeObject` has no way to say "absent" other than omitting a * member. */ - getAttribute(name: string): Promise; + getAttribute(name: string): Promise; /** * Every attribute, from the persisted copy the report is built from -- * where a fractional value is a 32-bit float, already widened back to a * `Double` the way the SDK's own JSON writer would (design doc Phase 5). * `{}` when none are set. */ - getAllAttributes(): Promise; + getAllAttributes(): Promise; clearAttribute(name: string): Promise; clearAllAttributes(): Promise; /** @@ -413,7 +414,7 @@ export interface Spec extends TurboModule { */ setUserIdentifier(identifier: string): void; /** `{}` when absent (including a native empty string), else `{ value }`. */ - getUserIdentifier(): Promise; + getUserIdentifier(): Promise; clearUserIdentifier(): void; /** @@ -427,7 +428,7 @@ export interface Spec extends TurboModule { * the native deadline for THIS handle โ€” JS marks the handle dead locally * once it passes, whether or not the app's callback has settled. */ - readonly onReportHandlerRequest: EventEmitter<{ + readonly onReportHandlerRequest: CodegenTypes.EventEmitter<{ handleId: string; phase: string; reportId: string; @@ -449,7 +450,7 @@ export interface Spec extends TurboModule { */ completeReportHandler(handleId: string): void; /** The live state of the report behind `handleId`, as a plain snapshot. */ - reportRead(handleId: string): Promise; + reportRead(handleId: string): Promise; /** * Applies a validated patch to the report behind `handleId`. All-or-nothing * on both sides of the bridge: JS validates before crossing, and native @@ -483,7 +484,7 @@ export interface Spec extends TurboModule { */ createReport(): Promise; /** The live state of the created report behind `handleId`, the `BugseeReportSnapshot` wire shape. */ - createdReportRead(handleId: string): Promise; + createdReportRead(handleId: string): Promise; /** * Applies a validated patch, as JSON text, the same transport as * {@link reportUpdate}. @@ -534,15 +535,15 @@ export interface Spec extends TurboModule { name: string, operation: string, attributesJson: string | null, - ): UnsafeObject; + ): CodegenTypes.UnsafeObject; /** Starts a span under the active span on this thread and retains it. */ - startSpan(operation: string, description: string | null): UnsafeObject; + startSpan(operation: string, description: string | null): CodegenTypes.UnsafeObject; /** * The active span on this thread, or a snapshot whose `handle` is `''` * when there is none. A span this bridge already holds comes back with * the same handle. */ - getActiveSpan(): UnsafeObject; + getActiveSpan(): CodegenTypes.UnsafeObject; /** * `setName`, which sets the operation on both SDKs. * @@ -560,7 +561,7 @@ export interface Spec extends TurboModule { handle: string, operation: string, description: string | null, - ): UnsafeObject; + ): CodegenTypes.UnsafeObject; /** * Finishes the span and releases every retained span that is now * finished, including children a parent finish cancelled. `statusSet` diff --git a/packages/react-native/src/wrapper/collect.ts b/packages/react-native/src/wrapper/collect.ts index d5b2ba52..f1faf883 100644 --- a/packages/react-native/src/wrapper/collect.ts +++ b/packages/react-native/src/wrapper/collect.ts @@ -8,11 +8,16 @@ import { Platform } from 'react-native'; import type { WrapperFacts } from './identity'; +/** + * Wide enough for every supported React Native's own declaration: 0.87 types + * `prerelease` as a number on some platforms (API-49), and a consumer's tsc + * checks this file against whichever version the app is on. + */ interface ReactNativeVersion { major: number; minor: number; patch: number; - prerelease?: string | null; + prerelease?: string | number | null; } /** `0.87.1`, or `0.88.0-rc.1`, or `unknown`. */ diff --git a/scripts/__tests__/event-emitter-wiring.test.ts b/scripts/__tests__/event-emitter-wiring.test.ts index 502ddece..9d911612 100644 --- a/scripts/__tests__/event-emitter-wiring.test.ts +++ b/scripts/__tests__/event-emitter-wiring.test.ts @@ -5,7 +5,8 @@ const pkg = (...p: string[]) => readFileSync(join(__dirname, '..', '..', 'packages', 'react-native', ...p), 'utf8'); const spec = pkg('src', 'NativeBugsee.ts'); -const declaresAnEmitter = /:\s*EventEmitter { + repo = mkdtempSync(join(tmpdir(), 'bugsee-stage-')); + pkg = join(repo, 'packages', 'react-native'); + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(repo, 'native-versions.json'), PINS); + writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: '@bugsee/react-native' })); +}); + +afterEach(() => rmSync(repo, { recursive: true, force: true })); + +describe('stageNativeVersions', () => { + it('copies the root pins into the package byte for byte and returns the path', () => { + const staged = stageNativeVersions(repo, pkg); + + expect(staged).toBe(join(pkg, 'native-versions.json')); + expect(readFileSync(staged, 'utf8')).toBe(PINS); + }); + + it('replaces a stale copy left by an earlier pack', () => { + writeFileSync(join(pkg, 'native-versions.json'), '{"stale":true}'); + + stageNativeVersions(repo, pkg); + + expect(readFileSync(join(pkg, 'native-versions.json'), 'utf8')).toBe(PINS); + }); + + it('refuses root pins that are not JSON, and writes nothing', () => { + writeFileSync(join(repo, 'native-versions.json'), '{ not json'); + + expect(() => stageNativeVersions(repo, pkg)).toThrow(/native-versions\.json is not valid JSON/); + expect(existsSync(join(pkg, 'native-versions.json'))).toBe(false); + }); + + it('refuses a directory that is not a @bugsee package', () => { + expect(() => stageNativeVersions(repo, repo)).toThrow(/not a @bugsee package/); + writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: 'other' })); + expect(() => stageNativeVersions(repo, pkg)).toThrow(/not a @bugsee package/); + expect(existsSync(join(pkg, 'native-versions.json'))).toBe(false); + }); +}); + +describe('unstageNativeVersions', () => { + it('removes the staged copy and says so', () => { + stageNativeVersions(repo, pkg); + + expect(unstageNativeVersions(pkg)).toBe(true); + expect(existsSync(join(pkg, 'native-versions.json'))).toBe(false); + }); + + it('is a no-op when nothing is staged', () => { + expect(unstageNativeVersions(pkg)).toBe(false); + }); + + // Run from the wrong directory it must never take the source of truth with it. + it('never deletes the repo-root pins', () => { + expect(() => unstageNativeVersions(repo)).toThrow(/not a @bugsee package/); + expect(readFileSync(join(repo, 'native-versions.json'), 'utf8')).toBe(PINS); + }); +}); diff --git a/scripts/__tests__/package-metadata.test.ts b/scripts/__tests__/package-metadata.test.ts index 851b04c2..5657bc9f 100644 --- a/scripts/__tests__/package-metadata.test.ts +++ b/scripts/__tests__/package-metadata.test.ts @@ -64,13 +64,43 @@ describe('the release guard', () => { JSON.parse(read('package.json')) as { scripts?: Record } ).scripts ?? {}; - it('runs on prepack', () => { + // First, and chained with &&: a refused pin stops the pack before anything + // is staged (prepack also stages native-versions.json, BLK-17). + it('runs on prepack, before anything else', () => { expect(scripts.prepack).toBe( - 'node ../../scripts/cli-check-releasable-pins.ts', + 'node ../../scripts/cli-check-releasable-pins.ts && node ../../scripts/cli-pack-native-versions.ts stage', ); }); + it('removes the staged native-versions.json on postpack', () => { + expect(scripts.postpack).toBe('node ../../scripts/cli-pack-native-versions.ts unstage'); + }); + it('is not left on a hook yarn 4 never runs', () => { expect(scripts.prepublishOnly ?? '').not.toMatch(/releasable-pins/); }); }); + +describe('the README integration steps', () => { + const readme = read('README.md'); + const versions = JSON.parse( + readFileSync(join(__dirname, '..', '..', 'native-versions.json'), 'utf8'), + ) as { android: { gradlePlugin: string } }; + + // A `plugins {}` block cannot read a file, so the README states the pin as + // a literal; it must be the one the package is built against. + it('pins the Bugsee Gradle plugin at native-versions.json android.gradlePlugin', () => { + const pins = [...readme.matchAll(/id 'com\.bugsee\.android\.gradle' version '([^']+)'/g)].map((m) => m[1]); + expect(pins).toEqual([versions.android.gradlePlugin]); + }); + + // R-1: `serialize` is static; `options.serialize()` was a TypeError. + it('serializes launch options with the static form', () => { + expect(readme).toMatch(/BugseeLaunchOptions\.serialize\(options\)/); + expect(readme).not.toMatch(/options\.serialize\(\)/); + }); + + it('documents the iOS bundle phase through bugsee-xcode.sh', () => { + expect(readme).toMatch(/node_modules\/@bugsee\/react-native\/scripts\/bugsee-xcode\.sh/); + }); +}); diff --git a/scripts/check-pack-install.sh b/scripts/check-pack-install.sh new file mode 100755 index 00000000..2bb6f157 --- /dev/null +++ b/scripts/check-pack-install.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# +# Installs the PACKED packages into a fresh React Native app, the way a +# customer gets them from npm, and checks what only a real install shows: +# +# --typecheck the app's own `tsc` (the template's TypeScript and tsconfig) +# over a file importing both packages and the README "Use" +# snippets, verbatim from the packed READMEs (API-49, R-1). +# --android Gradle configures the app with both modules autolinked +# (BLK-17: the wrapper's build.gradle reads native-versions.json). +# --ios `pod install` evaluates both podspecs and vendors the SDK +# (BLK-17: the podspecs read native-versions.json). +# +# The repo's own checks cannot see these: inside the workspace every lookup +# finds the repo root, and the repo's tsconfig is not a consumer's. +# +# Usage: check-pack-install.sh [--typecheck] [--android] [--ios] +# PACK_INSTALL_DIR keep the app here instead of a temporary directory +# RN_CLI_VERSION @react-native-community/cli used for init (default 20.2.0) +set -euo pipefail + +RN="${1:?usage: check-pack-install.sh [--typecheck] [--android] [--ios]}" +shift +TYPECHECK=0 +ANDROID=0 +IOS=0 +for arg in "$@"; do + case "$arg" in + --typecheck) TYPECHECK=1 ;; + --android) ANDROID=1 ;; + --ios) IOS=1 ;; + *) echo "unknown argument $arg" >&2; exit 2 ;; + esac +done +[[ "$RN" =~ ^0\.[0-9]+$ ]] || { echo "minor must look like 0.81" >&2; exit 2; } + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [[ -n "${PACK_INSTALL_DIR:-}" ]]; then + WORK="$PACK_INSTALL_DIR" + rm -rf "$WORK" + mkdir -p "$WORK" +else + WORK="$(mktemp -d)" + trap 'rm -rf "$WORK"' EXIT +fi + +echo "--- packing (npm pack, prepack/postpack as on publish)" +mkdir -p "$WORK/tarballs" +for pkg in react-native react-native-feedback; do + (cd "$REPO/packages/$pkg" && npm pack --silent --pack-destination "$WORK/tarballs" >/dev/null) +done +CORE_TGZ="$(ls "$WORK"/tarballs/bugsee-react-native-[0-9]*.tgz)" +FEEDBACK_TGZ="$(ls "$WORK"/tarballs/bugsee-react-native-feedback-[0-9]*.tgz)" +echo " $(basename "$CORE_TGZ") $(basename "$FEEDBACK_TGZ")" + +VERSION="$(npm view "react-native@~${RN}.0" version --json \ + | node -e 'const v = JSON.parse(require("fs").readFileSync(0, "utf8")); console.log(Array.isArray(v) ? v[v.length - 1] : v)')" +echo "--- react-native ${VERSION}: template app" +(cd "$WORK" && npx --yes "@react-native-community/cli@${RN_CLI_VERSION:-20.2.0}" init PackProbe \ + --version "$VERSION" --directory "$WORK/app" \ + --skip-install --skip-git-init --install-pods false >/dev/null) +cd "$WORK/app" +npm install --no-audit --no-fund --loglevel=error >/dev/null +echo "--- installing the tarballs" +npm install --no-audit --no-fund --loglevel=error "$CORE_TGZ" "$FEEDBACK_TGZ" >/dev/null + +if [[ "$TYPECHECK" -eq 1 ]]; then + echo "--- consumer typecheck (tsc $(node -p "require('typescript/package.json').version"), the template's tsconfig)" + # Every public export of both packages, through the package entry points a + # consumer imports, so the compiler checks all the shipped modules. + cat > bugsee-consumer.ts <<'TS' +import Bugsee, * as core from '@bugsee/react-native'; +import * as feedback from '@bugsee/react-native-feedback'; + +export const surface = [Bugsee, core, feedback] as const; +TS + # The README "Use" snippets, verbatim from the packed READMEs: what a reader + # pastes must compile. + node -e ' + const fs = require("fs"); + for (const [pkg, out] of [ + ["@bugsee/react-native", "readme-use-core.ts"], + ["@bugsee/react-native-feedback", "readme-use-feedback.ts"], + ]) { + const readme = fs.readFileSync(require.resolve(pkg + "/README.md"), "utf8"); + const use = readme.slice(readme.indexOf("\n## Use\n")); + const block = /```ts\n([\s\S]*?)```/.exec(use); + if (readme.indexOf("\n## Use\n") < 0 || !block) { + console.error("FAIL: no ts block under \"## Use\" in " + pkg + "/README.md"); + process.exit(1); + } + fs.writeFileSync(out, block[1] + "\nexport {};\n"); + } + ' + npx tsc --noEmit + echo " typecheck clean" +fi + +if [[ "$ANDROID" -eq 1 ]]; then + echo "--- Gradle configuration" + # `projects` configures every project (no configure-on-demand in the + # template), so each autolinked module's build.gradle is evaluated. + (cd android && ./gradlew projects --no-daemon --console=plain) > "$WORK/gradle-projects.log" 2>&1 \ + || { tail -40 "$WORK/gradle-projects.log"; echo "FAIL: Gradle configuration"; exit 1; } + for module in ':bugsee_react-native' ':bugsee_react-native-feedback'; do + grep -q "Project '${module}'" "$WORK/gradle-projects.log" \ + || { cat "$WORK/gradle-projects.log"; echo "FAIL: ${module} is not an autolinked project"; exit 1; } + done + echo " configured with :bugsee_react-native and :bugsee_react-native-feedback" +fi + +if [[ "$IOS" -eq 1 ]]; then + echo "--- pod install" + (cd ios && pod install) > "$WORK/pod-install.log" 2>&1 \ + || { tail -40 "$WORK/pod-install.log"; echo "FAIL: pod install"; exit 1; } + for pod in BugseeReactNative BugseeReactNativeFeedback; do + grep -q "^ - ${pod} (" ios/Podfile.lock \ + || { echo "FAIL: ${pod} is not in Podfile.lock"; exit 1; } + done + PKG_DIR="node_modules/@bugsee/react-native" + IOS_SDK="$(node -p "require('./${PKG_DIR}/native-versions.json').ios.sdk")" + [[ -d "$PKG_DIR/Bugsee.xcframework" ]] \ + || { echo "FAIL: the podspec did not vendor Bugsee.xcframework"; exit 1; } + [[ "$(cat "$PKG_DIR/.bugsee-xcframework-version")" == "$IOS_SDK" ]] \ + || { echo "FAIL: vendored xcframework is not ${IOS_SDK}"; exit 1; } + [[ -d "node_modules/@bugsee/react-native-feedback/BugseeFeedbackSources" ]] \ + || { echo "FAIL: the feedback podspec did not fetch its sources"; exit 1; } + echo " both pods installed; Bugsee.xcframework ${IOS_SDK} vendored" +fi + +echo "PASS pack-install react-native ${VERSION}" diff --git a/scripts/cli-pack-native-versions.ts b/scripts/cli-pack-native-versions.ts new file mode 100644 index 00000000..8cbf4c7c --- /dev/null +++ b/scripts/cli-pack-native-versions.ts @@ -0,0 +1,18 @@ +/** + * argv/exit shim for pack-native-versions.ts, run by each package's prepack + * (`stage`) and postpack (`unstage`) with the package directory as cwd. + */ +import { join } from 'node:path'; +import { stageNativeVersions, unstageNativeVersions } from './pack-native-versions.ts'; + +const repoRoot = join(import.meta.dirname, '..'); +const command = process.argv[2]; + +if (command === 'stage') { + console.log(` staged ${stageNativeVersions(repoRoot, process.cwd())}`); +} else if (command === 'unstage') { + unstageNativeVersions(process.cwd()); +} else { + console.error('usage: cli-pack-native-versions.ts stage|unstage'); + process.exit(2); +} diff --git a/scripts/pack-native-versions.ts b/scripts/pack-native-versions.ts new file mode 100644 index 00000000..6857bf8d --- /dev/null +++ b/scripts/pack-native-versions.ts @@ -0,0 +1,50 @@ +/** + * native-versions.json is the one source of every native pin, and it lives at + * the repo root. In the repo the podspecs read `../../native-versions.json` and + * the Android builds walk up for it. Inside an app the package sits in + * node_modules, where neither lookup finds the repo root, so `pod install` and + * Gradle configuration failed in every app that installed from npm (BLK-17). + * + * prepack stages a copy at the package root, which `files` ships and which + * both lookups find first; postpack removes it again so the repo copy stays + * the only one on disk. The staged copy is gitignored. + */ +import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +const FILE = 'native-versions.json'; + +function requireBugseePackage(packageDir: string): void { + let name: unknown; + try { + name = (JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { name?: unknown }).name; + } catch { + name = undefined; + } + if (typeof name !== 'string' || !name.startsWith('@bugsee/')) { + throw new Error(`${packageDir} is not a @bugsee package; run this from a package directory (npm/yarn pack do)`); + } +} + +/** Copies `/native-versions.json` into `packageDir`, byte for byte. Returns the copy's path. */ +export function stageNativeVersions(repoRoot: string, packageDir: string): string { + requireBugseePackage(packageDir); + const source = join(repoRoot, FILE); + try { + JSON.parse(readFileSync(source, 'utf8')); + } catch (error) { + throw new Error(`${source}: native-versions.json is not valid JSON`, { cause: error }); + } + const target = join(packageDir, FILE); + copyFileSync(source, target); + return target; +} + +/** Removes the staged copy from `packageDir`. Returns whether there was one. */ +export function unstageNativeVersions(packageDir: string): boolean { + requireBugseePackage(packageDir); + const target = join(packageDir, FILE); + const staged = existsSync(target); + rmSync(target, { force: true }); + return staged; +} diff --git a/scripts/pack/__tests__/pack-contents.test.ts b/scripts/pack/__tests__/pack-contents.test.ts new file mode 100644 index 00000000..7143abdd --- /dev/null +++ b/scripts/pack/__tests__/pack-contents.test.ts @@ -0,0 +1,149 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** + * What the published tarballs hold, packed the two ways a release can be cut: + * `npm pack` and `yarn pack` (Yarn 4, the repo's own). Both run the packages' + * prepack/postpack, as `npm publish` and `yarn npm publish` do. + * + * Found by the campaign's build lane (BLK-17, B-3, B-5): native-versions.json + * was in neither tarball, so `pod install` and Gradle configuration failed in + * every app that installed the package from npm; `yarn pack` dropped the + * compiled Expo config plugin that app.plugin.js requires; and both shipped + * the Jest tests. + * + * Not in the scripts mutation gate's testMatch on purpose: it packs the real + * packages, which a Stryker sandbox has no install state for. + */ +const repo = join(__dirname, '..', '..', '..'); +const rootVersions = JSON.parse(readFileSync(join(repo, 'native-versions.json'), 'utf8')) as unknown; + +type Tool = 'npm' | 'yarn'; +type Pkg = 'react-native' | 'react-native-feedback'; + +const work = mkdtempSync(join(tmpdir(), 'bugsee-pack-')); +afterAll(() => rmSync(work, { recursive: true, force: true })); + +function pack(tool: Tool, pkg: Pkg): string { + const cwd = join(repo, 'packages', pkg); + const out = join(work, tool, pkg); + execFileSync('mkdir', ['-p', out]); + if (tool === 'npm') { + execFileSync('npm', ['pack', '--silent', '--pack-destination', out], { cwd, stdio: 'pipe' }); + } else { + execFileSync('yarn', ['pack', '--out', join(out, 'package.tgz')], { cwd, stdio: 'pipe' }); + } + const [tgz] = readdirSync(out).filter((name) => name.endsWith('.tgz')); + if (!tgz) throw new Error(`${tool} pack wrote no tarball for ${pkg}`); + return join(out, tgz); +} + +const listing = (tgz: string): string[] => + execFileSync('tar', ['-tzf', tgz], { encoding: 'utf8' }) + .split('\n') + .filter((line) => line.length > 0 && !line.endsWith('/')) + .map((line) => line.replace(/^package\//, '')) + .sort(); + +const entry = (tgz: string, path: string): string => + execFileSync('tar', ['-xzOf', tgz, `package/${path}`], { encoding: 'utf8' }); + +const tarballs = new Map(); +beforeAll(() => { + for (const tool of ['npm', 'yarn'] as const) { + for (const pkg of ['react-native', 'react-native-feedback'] as const) { + tarballs.set(`${tool} ${pkg}`, pack(tool, pkg)); + } + } +}, 240_000); + +const TEST_ONLY = [ + /(^|\/)__tests__\//, + /(^|\/)__mocks__\//, + /^src\/testSupport\//, + /^android\/src\/test\//, +]; + +describe.each(['npm', 'yarn'] as const)('%s pack', (tool) => { + describe.each(['react-native', 'react-native-feedback'] as const)('@bugsee/%s', (pkg) => { + const tgz = () => tarballs.get(`${tool} ${pkg}`)!; + + it('ships native-versions.json, identical to the repo root pins', () => { + expect(listing(tgz())).toContain('native-versions.json'); + expect(JSON.parse(entry(tgz(), 'native-versions.json'))).toEqual(rootVersions); + }); + + it('ships no tests, mocks or test fixtures', () => { + const shipped = listing(tgz()).filter((path) => TEST_ONLY.some((re) => re.test(path))); + expect(shipped).toEqual([]); + }); + + it('leaves no staged native-versions.json behind in the package', () => { + expect(existsSync(join(repo, 'packages', pkg, 'native-versions.json'))).toBe(false); + }); + }); + + it('ships what the core package needs at build and run time', () => { + const files = listing(tarballs.get(`${tool} react-native`)!); + expect(files).toEqual( + expect.arrayContaining([ + 'package.json', + 'README.md', + 'LICENSE', + 'BugseeReactNative.podspec', + 'react-native.config.js', + 'app.plugin.js', + // app.plugin.js requires the compiled plugin, which reads its baked pins. + 'plugin/build/index.js', + 'plugin/build/native-versions.js', + 'plugin/build/native-versions.baked.json', + 'src/index.ts', + 'src/NativeBugsee.ts', + 'src/options/option-keys.json', + 'android/build.gradle', + 'android/src/main/AndroidManifest.xml', + 'ios/BugseeModule.mm', + 'ios/Package.swift', + 'ios/Support/Package.swift', + 'scripts/bugsee-xcode.sh', + 'scripts/bugsee-sourcemaps.gradle', + 'scripts/compose-then-inject.js', + 'scripts/hermes-sourcemaps.js', + 'scripts/hermesc-preserve-js.sh', + ]), + ); + }); + + // ios/Support/Package.swift declares a test target. SwiftPM refuses to load + // a manifest whose test target has no sources ("overlapping sources"), and + // the 0.87+ SPM delivery loads that manifest, so these sources must ship. + it('keeps the SPM test target sources the Support manifest declares', () => { + const files = listing(tarballs.get(`${tool} react-native`)!); + expect(files.some((path) => path.startsWith('ios/Support/Tests/BugseeRNSupportTests/'))).toBe(true); + }); + + it('ships what the feedback package needs at build and run time', () => { + const files = listing(tarballs.get(`${tool} react-native-feedback`)!); + expect(files).toEqual( + expect.arrayContaining([ + 'package.json', + 'README.md', + 'LICENSE', + 'BugseeReactNativeFeedback.podspec', + 'react-native.config.js', + 'src/index.ts', + 'src/NativeBugseeFeedback.ts', + 'android/build.gradle', + 'ios/Package.swift', + ]), + ); + }); +}); + +describe('npm and yarn pack the same files', () => { + it.each(['react-native', 'react-native-feedback'] as const)('@bugsee/%s', (pkg) => { + expect(listing(tarballs.get(`yarn ${pkg}`)!)).toEqual(listing(tarballs.get(`npm ${pkg}`)!)); + }); +}); diff --git a/scripts/pack/__tests__/podspec-prepare.test.ts b/scripts/pack/__tests__/podspec-prepare.test.ts new file mode 100644 index 00000000..8049b696 --- /dev/null +++ b/scripts/pack/__tests__/podspec-prepare.test.ts @@ -0,0 +1,186 @@ +import { spawn, execFileSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Runs each podspec's real prepare_command with a stand-in `curl`, twice at + * once, in two package directories: two `pod install`s on one machine (B-4). + * The download used to go to a fixed /tmp/Bugsee-.zip (and + * /tmp/feedback-spm-.tar.gz), so concurrent installs overwrote and + * deleted each other's archive mid-extract ("bad CRC" in the build lane). + * + * The stand-in curl refuses to write over a file that already exists and + * holds the download open for a moment, so two runs that share one path + * collide every time instead of now and then. + * + * Not in the scripts mutation gate's testMatch: it exercises the podspecs, + * not scripts/*.ts. + */ +const repo = join(__dirname, '..', '..', '..'); +const VERSION = '9.9.9-packtest'; + +function prepareCommand(podspec: string): string { + const text = readFileSync(join(repo, 'packages', podspec), 'utf8'); + const match = /s\.prepare_command = <<-CMD\n([\s\S]*?)\n\s*CMD\n/.exec(text); + if (!match) throw new Error(`${podspec}: no prepare_command heredoc`); + return match[1]!.replace("#{native['ios']['sdk']}", VERSION); +} + +const work = mkdtempSync(join(tmpdir(), 'bugsee-prepare-')); +afterAll(() => rmSync(work, { recursive: true, force: true })); + +const bin = join(work, 'bin'); +const fixtures = join(work, 'fixtures'); + +beforeAll(() => { + mkdirSync(bin, { recursive: true }); + mkdirSync(fixtures, { recursive: true }); + + // The core archive: the framework plus the SDK's own README and LICENSE at + // the root, which must not be extracted. + const zipRoot = join(work, 'zip-root'); + mkdirSync(join(zipRoot, 'Bugsee.xcframework'), { recursive: true }); + writeFileSync(join(zipRoot, 'Bugsee.xcframework', 'Info.plist'), 'framework\n'); + writeFileSync(join(zipRoot, 'README.md'), 'sdk readme\n'); + writeFileSync(join(zipRoot, 'LICENSE'), 'sdk licence\n'); + execFileSync('zip', ['-qr', join(fixtures, 'core.zip'), '.'], { cwd: zipRoot }); + + // The feedback tag archive, laid out as codeload serves it. + const tarRoot = join(work, 'tar-root'); + const sources = join(tarRoot, `feedback-spm-${VERSION}`, 'Sources', 'BugseeFeedback'); + mkdirSync(sources, { recursive: true }); + writeFileSync(join(sources, 'Feedback.swift'), '// feedback\n'); + execFileSync('tar', ['-czf', join(fixtures, 'feedback.tar.gz'), '-C', tarRoot, '.']); + + const curl = join(bin, 'curl'); + writeFileSync( + curl, + [ + '#!/bin/bash', + 'out=""; url=""', + 'while [ $# -gt 0 ]; do', + ' case "$1" in -o) out="$2"; shift 2 ;; -*) shift ;; *) url="$1"; shift ;; esac', + 'done', + '[ -n "$out" ] || { echo "stub curl: no -o" >&2; exit 2; }', + '[ -n "${STUB_CURL_FAIL:-}" ] && { echo "stub curl: failing on request" >&2; exit 22; }', + // Another run is writing (or has left) this very file: a shared path. + // noclobber makes the create exclusive (O_EXCL), so the check and the + // create cannot interleave with the other run's. + 'if ! ( set -o noclobber; : > "$out" ) 2>/dev/null; then', + ' echo "stub curl: $out already exists" >&2; exit 23', + 'fi', + 'echo "$out" >> "$STUB_CURL_LOG"', + 'sleep 1', + 'case "$url" in', + ` *codeload.github.com/bugsee/feedback-spm/*) cat "${join(fixtures, 'feedback.tar.gz')}" > "$out" ;;`, + ` *download.bugsee.com/*) cat "${join(fixtures, 'core.zip')}" > "$out" ;;`, + ' *) echo "stub curl: unexpected url $url" >&2; exit 3 ;;', + 'esac', + '', + ].join('\n'), + ); + chmodSync(curl, 0o755); +}); + +interface Run { + readonly code: number | null; + readonly stderr: string; +} + +function run(script: string, cwd: string, env: Record = {}): Promise { + mkdirSync(cwd, { recursive: true }); + return new Promise((resolve) => { + const child = spawn('/bin/bash', ['-c', script], { + cwd, + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ''}`, + TMPDIR: join(work, 'tmp'), + STUB_CURL_LOG: join(work, 'curl.log'), + ...env, + }, + }); + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString())); + child.on('close', (code) => resolve({ code, stderr })); + }); +} + +beforeEach(() => { + rmSync(join(work, 'tmp'), { recursive: true, force: true }); + mkdirSync(join(work, 'tmp'), { recursive: true }); + rmSync(join(work, 'curl.log'), { force: true }); + rmSync(join(work, 'pkg'), { recursive: true, force: true }); +}); + +const cases = [ + { + name: 'BugseeReactNative', + podspec: join('react-native', 'BugseeReactNative.podspec'), + product: join('Bugsee.xcframework', 'Info.plist'), + stamp: '.bugsee-xcframework-version', + }, + { + name: 'BugseeReactNativeFeedback', + podspec: join('react-native-feedback', 'BugseeReactNativeFeedback.podspec'), + product: join('BugseeFeedbackSources', 'Feedback.swift'), + stamp: '.bugsee-feedback-sources-version', + }, +] as const; + +describe.each(cases)('$name prepare_command', ({ podspec, product, stamp }) => { + it('two concurrent installs each get their own download', async () => { + const script = prepareCommand(podspec); + const a = join(work, 'pkg', 'a'); + const b = join(work, 'pkg', 'b'); + const [first, second] = await Promise.all([run(script, a), run(script, b)]); + + expect(first).toEqual({ code: 0, stderr: '' }); + expect(second).toEqual({ code: 0, stderr: '' }); + for (const dir of [a, b]) { + expect(existsSync(join(dir, product))).toBe(true); + expect(readFileSync(join(dir, stamp), 'utf8')).toBe(VERSION); + } + const paths = readFileSync(join(work, 'curl.log'), 'utf8').trim().split('\n'); + expect(paths).toHaveLength(2); + expect(new Set(paths).size).toBe(2); + }, 30_000); + + it('downloads under TMPDIR, not a fixed /tmp path, and cleans up after itself', async () => { + const result = await run(prepareCommand(podspec), join(work, 'pkg', 'one')); + + expect(result.code).toBe(0); + const [path] = readFileSync(join(work, 'curl.log'), 'utf8').trim().split('\n'); + expect(path!.startsWith(join(work, 'tmp') + '/')).toBe(true); + expect(readdirSync(join(work, 'tmp'))).toEqual([]); + }, 30_000); + + it('leaves no partial product, stamp or staging directory when the download fails', async () => { + const dir = join(work, 'pkg', 'fail'); + const result = await run(prepareCommand(podspec), dir, { STUB_CURL_FAIL: '1' }); + + expect(result.code).not.toBe(0); + expect(readdirSync(dir)).toEqual([]); + expect(readdirSync(join(work, 'tmp'))).toEqual([]); + }, 30_000); +}); + +describe('BugseeReactNative prepare_command', () => { + it('extracts only the framework, not the archive README or LICENSE', async () => { + const dir = join(work, 'pkg', 'only'); + const result = await run(prepareCommand(join('react-native', 'BugseeReactNative.podspec')), dir); + + expect(result.code).toBe(0); + expect(readdirSync(dir).sort()).toEqual(['.bugsee-xcframework-version', 'Bugsee.xcframework']); + }, 30_000); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 2105d001..820dba88 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,11 +4,11 @@ "lib": ["ES2022"], "module": "ESNext", "moduleResolution": "bundler", - // RN 0.87 sets exports["./Libraries/*"].types to null, so deep imports - // into react-native resolve to .js with no declarations. This condition is - // RN's own sanctioned opt-in to the typed deep-import surface, which is - // where codegen types such as UnsafeObject live. - "customConditions": ["react-native-legacy-deep-imports"], + // No "react-native-legacy-deep-imports" condition: RN 0.87 declares no + // types for deep imports without it, and a consumer's tsc compiles this + // package's source with the consumer's tsconfig, which does not set it + // (API-49). Leaving it out here makes a deep import fail our own + // typecheck first. Codegen types come from the root `CodegenTypes`. // is the library's first .tsx; the automatic runtime is // what React Native's Babel preset compiles it with. "jsx": "react-jsx", diff --git a/tsconfig.example.json b/tsconfig.example.json index d4cc9a02..99d8c66b 100644 --- a/tsconfig.example.json +++ b/tsconfig.example.json @@ -8,6 +8,10 @@ "compilerOptions": { "jsx": "react-jsx", "types": ["node", "jest"], + // The example is an app, and it uses RN 0.87's typed deep-import surface + // (react-native/Libraries/...) the way an app may opt in. The library's + // own config deliberately does not (tsconfig.base.json). + "customConditions": ["react-native-legacy-deep-imports"], "noEmit": true, "allowImportingTsExtensions": true }, From 01739d378344031970a3dc17fbb94b9d039717cd Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 7 Oct 2026 13:51:53 +0500 Subject: [PATCH 2/5] pack-native-versions: kill two mutants (existsSync guard, error cause asserted) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stryker (scripts config, --mutate scripts/pack-native-versions.ts): 88.46 -> 96.00. The one survivor, 'utf8' -> '' on the read used only to validate the JSON, is equivalent: JSON.parse accepts the Buffer. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- scripts/__tests__/pack-native-versions.test.ts | 7 +++++++ scripts/pack-native-versions.ts | 10 ++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/__tests__/pack-native-versions.test.ts b/scripts/__tests__/pack-native-versions.test.ts index 370ce1c0..c2c4b1a2 100644 --- a/scripts/__tests__/pack-native-versions.test.ts +++ b/scripts/__tests__/pack-native-versions.test.ts @@ -43,6 +43,13 @@ describe('stageNativeVersions', () => { writeFileSync(join(repo, 'native-versions.json'), '{ not json'); expect(() => stageNativeVersions(repo, pkg)).toThrow(/native-versions\.json is not valid JSON/); + // The parser's own message (where it choked) stays reachable. + try { + stageNativeVersions(repo, pkg); + } catch (error) { + expect((error as Error).cause).toBeInstanceOf(SyntaxError); + } + expect.assertions(3); expect(existsSync(join(pkg, 'native-versions.json'))).toBe(false); }); diff --git a/scripts/pack-native-versions.ts b/scripts/pack-native-versions.ts index 6857bf8d..31dbb439 100644 --- a/scripts/pack-native-versions.ts +++ b/scripts/pack-native-versions.ts @@ -15,12 +15,10 @@ import { join } from 'node:path'; const FILE = 'native-versions.json'; function requireBugseePackage(packageDir: string): void { - let name: unknown; - try { - name = (JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { name?: unknown }).name; - } catch { - name = undefined; - } + const manifest = join(packageDir, 'package.json'); + const name = existsSync(manifest) + ? (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name + : undefined; if (typeof name !== 'string' || !name.startsWith('@bugsee/')) { throw new Error(`${packageDir} is not a @bugsee package; run this from a package directory (npm/yarn pack do)`); } From d1b9ab0dc2ec437a3871a8b069877a5d097e178d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 7 Oct 2026 14:11:58 +0500 Subject: [PATCH 3/5] rn compat: match the measureInWindow SyncCallback alias across lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view-tree guard grepped NativeDOM.h one line at a time. React Native 0.82 formats the alias over two lines (`using MeasureInWindowOnSuccessCallback =` / `SyncCallback<...>`), so the guard read it as a changed type and failed. 0.82 was not in the compat matrix until this PR widened it to every minor. The check now runs over the whole file; a header declaring AsyncCallback still fails it. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- scripts/check-rn-compat.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/check-rn-compat.sh b/scripts/check-rn-compat.sh index 56adc3f2..072d55a3 100755 --- a/scripts/check-rn-compat.sh +++ b/scripts/check-rn-compat.sh @@ -160,7 +160,13 @@ echo " NativeDOM::measureInWindow calls its callback synchronously with the r NATIVE_DOM_H="$RN_SRC/ReactCommon/react/nativemodule/dom/NativeDOM.h" if [ -f "$NATIVE_DOM_H" ] && grep -q 'MeasureInWindowOnSuccessCallback' "$NATIVE_DOM_H"; then - grep -qE 'MeasureInWindowOnSuccessCallback[[:space:]]*=[[:space:]]*SyncCallback' "$NATIVE_DOM_H" \ + # Over the whole file, not line by line: 0.82 formats the alias over two + # lines (`using MeasureInWindowOnSuccessCallback =` then `SyncCallback<โ€ฆ>`), + # which a one-line grep reads as a changed type. + node -e ' + const src = require("fs").readFileSync(process.argv[1], "utf8"); + if (!/using\s+MeasureInWindowOnSuccessCallback\s*=\s*SyncCallback\s* Date: Wed, 7 Oct 2026 14:12:56 +0500 Subject: [PATCH 4/5] platform-floors: declare the widened compat matrix (every minor 0.81-0.87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit single-source.test.ts asserts REACT_NATIVE_SUPPORT.matrix equals the workflow's rn compat matrix, which this PR widened; the declaration had not followed, failing the js job and the scripts mutation gate's dry run. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- scripts/platform-floors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/platform-floors.ts b/scripts/platform-floors.ts index 42c9803c..8e036745 100644 --- a/scripts/platform-floors.ts +++ b/scripts/platform-floors.ts @@ -66,8 +66,8 @@ export const IOS_FLOOR = { */ export const REACT_NATIVE_SUPPORT = { floor: '0.81.0', - /** What CI builds. 0.81-0.86 are CocoaPods-only; SPM arrived in 0.87. */ - matrix: ['0.81', '0.83', '0.86', '0.87'], + /** What CI builds: every minor. 0.81-0.86 are CocoaPods-only; SPM arrived in 0.87. */ + matrix: ['0.81', '0.82', '0.83', '0.84', '0.85', '0.86', '0.87'], } as const; /** From 9ae85ce7d3f03295665aa32eef3c3017646ea0a8 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 7 Oct 2026 14:52:49 +0500 Subject: [PATCH 5/5] Docs: Hermes required, JavaScriptCore unsupported (user decision) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both package READMEs state "Hermes is required; JavaScriptCore (Hermes off) is not supported" with the reason: React Native removed JSC from core in 0.81 and @react-native-community/javascriptcore does not work with current React Native (Android JSC cannot parse RN's bundle; iOS fails on 0.86/0.87). The core README no longer describes the Hermes-off source-map path as an option. The design doc records the decision in ยง4.5 and notes that the wrapper still reports js-engine "jsc" when Hermes is off (harmless reporting, left as is). ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/2026-09-15-sdk-design.md | 4 +++- packages/react-native-feedback/README.md | 5 ++++- packages/react-native/README.md | 10 ++++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/design/2026-09-15-sdk-design.md b/docs/design/2026-09-15-sdk-design.md index e0ef49f7..0306325c 100644 --- a/docs/design/2026-09-15-sdk-design.md +++ b/docs/design/2026-09-15-sdk-design.md @@ -56,6 +56,8 @@ The floor is a reach decision, and reach is not linear in the version number. A 0.81 still permits opting out of the New Architecture, so support there is **conditional on it being enabled** โ€” the default since 0.76. That is a documented requirement, not a legacy fallback: still one native source set per platform, a TurboModule spec with no legacy path, and no interop shims. +**Hermes is required; JavaScriptCore (Hermes off) is not supported** (decided 2026-10-07 for the first beta). React Native removed JSC from core in 0.81; the replacement, `@react-native-community/javascriptcore` (0.2.0, March 2025), does not work with current React Native: on Android its 2019 JSC cannot parse React Native's own bundle (`??`/`?.`), and on iOS it fails to link on 0.87 and to `pod install` on 0.86 (campaign build lane, MX-HERMES-OFF). The wrapper still reports `js-engine: jsc` and the source-map hook still handles a plain bundle when Hermes is off; neither is a supported configuration. + 0.80 is the hard technical floor. `codegenConfig.ios.modulesProvider`, which is how the TurboModule avoids colliding with the SDK's own `Bugsee` class (ยง6.4), does not exist before 0.80. 0.81 clears it with a version to spare. --- @@ -113,7 +115,7 @@ It is not merely an identity struct. It extends `ReportHandler` *and* `DataReque |---|---| | `getWrapperType()` / `wrapperType` | `"react_native"` | | `getWrapperVersion()` / `getWrapperBuild()` | package version and build | -| `getContext()` / `context` | RN version, JS engine (Hermes/JSC), build configuration | +| `getContext()` / `context` | RN version, JS engine (`hermes`; `jsc` is still reported if Hermes is off, though that is unsupported, ยง4.5), build configuration | | `onLifecycleEvent(name, data)` | lifecycle delivery; fires **before** the app's own listener | | `getSecureRectangles(display)` / `secureRectsForDisplay:` | pull-based secure rectangles | | `ReportHandler` callbacks | attachments and report mutation; fire **before** the app's `setReportHandler` | diff --git a/packages/react-native-feedback/README.md b/packages/react-native-feedback/README.md index e881dab9..1f7582cd 100644 --- a/packages/react-native-feedback/README.md +++ b/packages/react-native-feedback/README.md @@ -6,7 +6,10 @@ do not have to depend on this package. ## Requirements The same as `@bugsee/react-native`: React Native 0.81 or later with the New -Architecture, iOS 15.0 or later, Android API 21 or later. +Architecture, Hermes, iOS 15.0 or later, Android API 21 or later. Hermes is +required; JavaScriptCore (Hermes off) is not supported: React Native removed +JSC from core in 0.81, and the community package does not work with current +React Native. ## Install diff --git a/packages/react-native/README.md b/packages/react-native/README.md index d67100f3..5fe39595 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -14,10 +14,17 @@ added. | React Native | 0.81 or later, New Architecture enabled | | iOS | 15.0 or later | | Android | API 21 or later | +| JavaScript engine | Hermes (React Native's default) | The New Architecture is the default from React Native 0.76. On 0.81 it can still be turned off; this package requires it on. +Hermes is required; JavaScriptCore (Hermes off) is not supported. React +Native removed JSC from core in 0.81, and the community package that replaces +it, `@react-native-community/javascriptcore`, does not work with current React +Native: its Android JSC cannot parse React Native's own bundle, and its iOS +build fails on 0.86 and 0.87. + ## Install ```sh @@ -157,8 +164,7 @@ apply from: new File(new File(bugseeDir, "scripts"), "bugsee-sourcemaps.gradle") ``` If Hermes compiles a bundle without going through `hermesc-preserve-js.sh`, -the bundle task fails rather than ship a release without a debug id. With -Hermes off, the plain JS bundle and Metro's map get the id instead. +the bundle task fails rather than ship a release without a debug id. - **The upload needs a real token.** Set `app_token` in `android/bugsee.properties`, or `BUGSEE_APP_TOKEN`. Without a token, or with