diff --git a/docs/design/plans/2026-09-16-implementation-plan.md b/docs/design/plans/2026-09-16-implementation-plan.md index 5ac659dc..eaf170de 100644 --- a/docs/design/plans/2026-09-16-implementation-plan.md +++ b/docs/design/plans/2026-09-16-implementation-plan.md @@ -34,6 +34,8 @@ Each task: **red → green → mutate → commit.** The mutation step deliberate Commits are small and single-purpose. A phase ends with a reviewer subagent; its findings are addressed and it re-reviews until satisfied. +When a task's review passes, check its boxes here in the same session. If a device run changes a prediction, write the ruling under that task. Do not leave the boxes stale. + **Test layers.** Unit tests (Jest, mocked bridge) for JS logic. Native unit tests (JUnit / XCTest) for marshalling and coercion. **Integration tests on real hardware** for anything crossing the bridge — an iPhone XS and a WOD_LX1 Android device are attached, plus six AVDs and iOS simulators. Anything with a filter callback, an event, or a native→JS round trip needs a device test; unit tests mock precisely the seam where the spike found every real failure. **CI gate:** lint, typecheck, unit, mutation threshold, both platforms building the example app, **and an assertion that `Bugsee.framework` is embedded in the built `.app`**. A build that succeeds without it crashes at launch — the false pass the spike caught. @@ -62,6 +64,10 @@ Ordered so each phase is independently shippable and so the riskiest work lands Phases 4 and 5 are deliberately small — they settle how values cross the bridge, the pattern 7–11 reuse, so the hard parts arrive after the pattern is proven. Phase 6 carries the first native→JS round trip on the capture path (the `vh` data request), so it is the largest of the three. +### Progress + +Updated 2026-10-01. Phases 1–6 are on `main` (`7b40eb6`), except the Phase 6 Modal follow-up, which is not merged (iOS blackout still sees each `console.log` twice). Phase 7 is complete through its review gate on `feat/phase-7-5b` at `fb9449b`. Task 8.1 is complete and reviewed on `feat/phase-8-1` at `c437703`. Task 8.2a is complete and reviewed at `cd79431` on `feat/phase-8-2a`. Task 8.2b is complete and re-reviewed at `0542501` on `feat/phase-8-2b`. Task 8.2c is complete and re-reviewed at `a05df5a` on `feat/phase-8-2c`. Task 8.3a stopped at Step 0: the send control has no stable `resource-id`. The two bridges are still on separate branches. Task 8.3b has not started. Older unchecked boxes in Phases 1–6 were not backfilled in this pass. + --- ## Phase 1 — Foundation & distribution @@ -2879,7 +2885,7 @@ logUnhandledException(error: unknown): Promise; **Stubs:** Android and iOS `logException` are no-ops, and `logUnhandledException` resolves at once. Each is commented `Task 7.1c` or `Task 7.1d`. -- [ ] **Red** +- [x] **Red** - `options.test.ts`: - `undefined and {} encode to null`; - `domain, labels and includeVideo encode in that order`; @@ -2906,15 +2912,15 @@ logUnhandledException(error: unknown): Promise; - `logUnhandledException returns reportUnhandled's promise`; - `ExceptionOptions is exported`. - Run → FAIL. -- [ ] **Green** +- [x] **Green** - The modules, the facade, the mock (`logException` and `logUnhandledException` `jest.fn`s; `logUnhandledException` resolves by default) and the stubs. - `ios-spec-coverage`, `check-rn-compat.sh 0.81` and both example builds are green. -- [ ] **Mutate** +- [x] **Mutate** - (1) Drop `markReported` from `reportHandled`. `an object is reported once…` must fail. - (2) Race against a 5000 ms timer instead. `…after 1500 ms when native never does` must fail. - (3) Keep the facade's first frame. `a non-Error logged from a named function…` must fail. - Revert and record. -- [ ] **Commit** — `feat(exceptions): logException and logUnhandledException, bridge stubbed`. +- [x] **Commit** — `feat(exceptions): logException and logUnhandledException, bridge stubbed`. --- @@ -2963,7 +2969,7 @@ final class ExceptionBridge { - `logException(payloadJson, optionsJson)` → `ExceptionBridge.logHandled(PROD_SDK, …)`, then logs `exception handled sent bytes=`. It catches `RuntimeException`, like every void method. - `logUnhandledException(payloadJson, promise)` → `ExceptionBridge.logUnhandled(PROD_SDK, …)`, which is synchronous in the SDK. It logs `exception unhandled sent bytes=` and then `exception unhandled completed`, and resolves `null` in a `finally`, so it settles exactly once even if the SDK throws. -- [ ] **Red** +- [x] **Red** - `ExceptionBridgeTest`: - `theClassNameIsTheBackendContract` (`ReactNativeWebException.class.getName()` equals `"com.bugsee.reactnative.ReactNativeWebException"`, and the simple name contains `"ReactNativeWebException"`); - `theReasonIsThePayloadVerbatim` (`getMessage()` equals the input, byte for byte, including a non-BMP character); @@ -2980,16 +2986,16 @@ final class ExceptionBridge { - `the library's build.gradle declares consumer-rules.pro`; - `ReactNativeWebException is a top-level class in com.bugsee.reactnative` (not nested; the file and the package line are read). - Run → FAIL. -- [ ] **Green** +- [x] **Green** - As specified. - `./gradlew :bugsee-android-bridge:testDebugUnitTest` and the example `assembleDebug` are green. - `assembleRelease -PbugseeE2eMinify=true` builds, and `apkanalyzer dex packages` (or `unzip -p … classes*.dex | strings`) of the release APK shows `com/bugsee/reactnative/ReactNativeWebException`. Record the command and its output. -- [ ] **Mutate** +- [x] **Mutate** - (1) Delete the keep line. The keep-rules test must fail. By hand, the minified APK must no longer contain the name. Record it. - (2) Pass `getLocalizedMessage` through a wrapper that trims the payload. `theReasonIsThePayloadVerbatim` must fail. - (3) Drop the `finally` resolve and let a throwing SDK escape. `aThrowingSdkDoesNotEscape` must fail. - Revert and record. -- [ ] **Commit** — `feat(android): report JS exceptions as ReactNativeWebException, kept through R8`. +- [x] **Commit** — `feat(android): report JS exceptions as ReactNativeWebException, kept through R8`. --- @@ -3028,7 +3034,7 @@ FOUNDATION_EXPORT dispatch_block_t BGSRNSettleOnce(int64_t deadlineMs, dispatch_ - `done = BGSRNSettleOnce(BGSRNUnhandledCompletionDeadlineMs, main, ^{ log "exception unhandled completed"; resolve(nil); })`. - A comment states why the deadline exists: on the simulator the SDK never calls this completion (verified facts), and a promise must still settle. -- [ ] **Red** — `BGSRNSettleOnceTests`: `testSettlesOnceWhenCalled`; `testSettlesAtTheDeadlineWhenNeverCalled`; `testACallAfterTheDeadlineDoesNothing`; `testTwoConcurrentCallsSettleOnce` (two threads). `BGSRNExceptionsTests`: +- [x] **Red** — `BGSRNSettleOnceTests`: `testSettlesOnceWhenCalled`; `testSettlesAtTheDeadlineWhenNeverCalled`; `testACallAfterTheDeadlineDoesNothing`; `testTwoConcurrentCallsSettleOnce` (two threads). `BGSRNExceptionsTests`: - `testTheNameIsTheBackendContract`; - `testDomainBecomesExceptionDomain`; - `testIncludeVideoDefaultsToYes`; @@ -3039,13 +3045,13 @@ FOUNDATION_EXPORT dispatch_block_t BGSRNSettleOnce(int64_t deadlineMs, dispatch_ - `testANonStringLabelIsAnError`; - `testUnknownKeysAreIgnored`. - Run → FAIL. -- [ ] **Green** — as specified. The iOS example builds on both delivery paths. -- [ ] **Mutate** +- [x] **Green** — as specified. The iOS example builds on both delivery paths. +- [x] **Mutate** - (1) Remove the `includeVideo = YES` default. `testIncludeVideoDefaultsToYes` must fail. - (2) Change the name constant's value. `testTheNameIsTheBackendContract` must fail. - (3) Make the once-flag a plain `BOOL`. `testTwoConcurrentCallsSettleOnce` must fail, or be flaky; if it is only flaky, the test is wrong and gets a barrier that makes the race deterministic. - Revert and record. -- [ ] **Commit** — `feat(ios): report JS exceptions as ReactNativeWebException`. +- [x] **Commit** — `feat(ios): report JS exceptions as ReactNativeWebException`. --- @@ -3073,7 +3079,7 @@ In `buildExceptionPayload`: - the root gets `debug_ids` as a plain object of the **whole** map, entries in insertion order, when it is non-empty; - no key when it is empty. -- [ ] **Red** +- [x] **Red** - `debugIds.test.ts`: - `an absent or non-object registration is an empty map`; - `a Hermes registration stack keys its id by the top frame's file`; @@ -3090,13 +3096,13 @@ In `buildExceptionPayload`: - `no debug_ids key when nothing is registered`; - `the join key is the same for a registration stack and a crash stack from one bundle` (both Hermes `address at` lines, one with `file://`). - Run → FAIL. -- [ ] **Green** — as specified. `yarn test` and `yarn mutate:src` are green. -- [ ] **Mutate** +- [x] **Green** — as specified. `yarn test` and `yarn mutate:src` are green. +- [x] **Mutate** - (1) Key by the whole stack string instead of the top frame's file. `a Hermes registration stack keys…` must fail. - (2) Send `debug_ids` as an array of ids. `debug_ids is the whole map, as an object` must fail. - (3) Join on `cleanSource` instead of `fileKey`. `the join key is the same…` must fail, because the iOS `.app` case diverges. - Revert and record. -- [ ] **Commit** — `feat(exceptions): attach source-map debug IDs to JS frames`. +- [x] **Commit** — `feat(exceptions): attach source-map debug IDs to JS frames`. --- @@ -3151,7 +3157,7 @@ export function setExceptionCaptureEnabled(enabled: boolean): void; - `react-native/package.json` depends on `promise`; - `src/private/renderer/errorhandling/ErrorHandlers.js` has `onUncaughtError` calling `handleException(error, true)`. This is R8's premise: if it stops being true, Task 7.4's root reporter must be revisited. -- [ ] **Red** — `handlers.test.ts` (fake `ErrorUtils`, a fake Hermes tracker, fake timers): +- [x] **Red** — `handlers.test.ts` (fake `ErrorUtils`, a fake Hermes tracker, fake timers): - `installs once however often launch runs`; - `a fatal error is reported as unhandled, then the previous handler runs with the same arguments`; - `the previous handler waits for the report, at most 1500 ms`; @@ -3167,17 +3173,17 @@ export function setExceptionCaptureEnabled(enabled: boolean): void; - `onHandled reports nothing`; - `without ErrorUtils, only the rejection tracker is installed, with one warning`. - Run → FAIL. -- [ ] **Green** +- [x] **Green** - As specified. - The guard passes for 0.81 and 0.87 (`./scripts/check-rn-compat.sh 0.81`, `… 0.87`). - `yarn test` and `yarn mutate:src` are green. -- [ ] **Mutate** +- [x] **Mutate** - (1) Call `previous` before the report settles. `the previous handler waits for the report…` must fail. - (2) Report a non-fatal error as unhandled. `a non-fatal error is reported as handled…` must fail. - (3) Skip the dev chain. `in dev, RN's own rejection handlers still run` must fail. - (4) Point the guard at a copy of 0.87 whose `ErrorHandlers.js` calls `handleException(error, false)`. The guard must fail (run by hand; record it). - Revert and record. -- [ ] **Commit** — `feat(exceptions): report uncaught JS errors and unhandled rejections`. +- [x] **Commit** — `feat(exceptions): report uncaught JS errors and unhandled rejections`. --- @@ -3223,7 +3229,7 @@ export class RootErrorReporter extends React.Component<{ children?: React.ReactN - In the `rethrow` phase, `render()` throws `state.error`. React finds no boundary above, so its `onUncaughtError` runs RN's fatal path. - Document in the file header the soft-then-fatal sequence R8 records. -- [ ] **Red** +- [x] **Red** - `ErrorBoundary.test.tsx` (react-test-renderer; native mock): - `a render error below it is reported as handled, with its component stack`; - `renders the fallback element`; @@ -3243,13 +3249,13 @@ export class RootErrorReporter extends React.Component<{ children?: React.ReactN - `an error already reported is rethrown without a second report`. - `walk.test.ts`: `the root reporter is not emitted, and its children are`. - Run → FAIL. -- [ ] **Green** — as specified. `yarn test` and `yarn mutate:src` (with `.tsx`) are green. -- [ ] **Mutate** +- [x] **Green** — as specified. `yarn test` and `yarn mutate:src` (with `.tsx`) are green. +- [x] **Mutate** - (1) Report from `RootErrorReporter` without rethrowing. `…is reported as unhandled, then rethrown` must fail. - (2) Assign `error.cause = …` in `ErrorBoundary`. `the caught error is not mutated` must fail. - (3) Drop `RootErrorReporter` from `isWrapper`. The walk test must fail. - Revert and record. -- [ ] **Commit** — `feat(exceptions): ErrorBoundary, and a root reporter for uncaught render errors`. +- [x] **Commit** — `feat(exceptions): ErrorBoundary, and a root reporter for uncaught render errors`. --- @@ -3344,9 +3350,9 @@ export function fileExists(path: string): Promise; 3. `the NDK relaunch recovers on the bounded path, not JS` — the relaunch log has `report handler - completed by=recovery` (NDK early recovery on `bugsee-report-handler-bounded`; Task 3.4d's Java recovery was the live `BugseeReportHandlerThread`, this one is not), and `BUGSEE_E2E native after type=crash` is absent for that run. 4. `crashNative('abort') is SIGABRT` — cases 1–2 again with `kind=abort`: `Fatal signal 6 (SIGABRT)`, and `signal.name === 'SIGABRT'`. -- [ ] **Red/Green** — all four cases pass on the WOD_LX1. -- [ ] **Mutate** — make `crashNative` throw a Java `RuntimeException` instead. Case 2 must fail on `ndkCrash`. Revert and record. -- [ ] **Commit** — `test(e2e): a native crash from JS reaches an Android report`. The body records the banner, both signals, and each bundle's `signal` object. +- [x] **Red/Green** — all four cases pass on the WOD_LX1. +- [x] **Mutate** — make `crashNative` throw a Java `RuntimeException` instead. Case 2 must fail on `ndkCrash`. Revert and record. +- [x] **Commit** — `test(e2e): a native crash from JS reaches an Android report`. The body records the banner, both signals, and each bundle's `signal` object. Task 3.4d's external `adb shell run-as … kill -11 ` stays in place. This task gives the NDK path its own coverage and leaves 3.4d alone. @@ -3429,20 +3435,22 @@ Task 3.4d's external `adb shell run-as … kill -11 ` stays in place. This - R2 `RN's own crash is the second report` — exactly one crash bundle whose `exception.name` contains `JavascriptException` and whose raw reason contains `E2E fatal `. This pins R13; - R3 = case 10's `it.failing`, on this build too. -- [ ] **Red** — write the test and the scenarios. Run once before wiring `App.tsx` → FAIL at the first marker. -- [ ] **Green** +- [x] **Red** — write the test and the scenarios. Run once before wiring `App.tsx` → FAIL at the first marker. +- [x] **Green** - Cases 1–13 pass on the WOD_LX1, with the two documented `it.failing` cases failing for the right reason (record the labels array and the extra error bundle). - The gated R1–R3 pass on the release build. -- [ ] **Mutate** +- [x] **Mutate** - (1) Delete the keep rule and rebuild the release APK. R1 must fail on `exception.name`. - (2) Skip `previous` in the global handler. Case 9 must fail: there is no `app-handler` marker. - (3) Send `debug_ids` as a list. Case 3 must fail. - (4) Make `RootErrorReporter` swallow instead of rethrow. Case 12's red-box line must be missing. - Revert and record. -- [ ] **Commit** — `test(e2e): JS exceptions in Android bundles, handled, unhandled and from render`. The body records the banner, one payload verbatim (its nonce values are synthetic), and the full list of bundles R1–R3 produced. +- [x] **Commit** — `test(e2e): JS exceptions in Android bundles, handled, unhandled and from render`. The body records the banner, one payload verbatim (its nonce values are synthetic), and the full list of bundles R1–R3 produced. **If a prediction is wrong**, stop and report rather than loosening the assertion. The predictions are: case 12's red box, and R2's `JavascriptException` bundle. The controller rules on the change. +**Ruling (2026-10-01, WOD_LX1, Android SDK 7.3.0 `beb390dc0`).** Case 12's red box was present. R2's second Bugsee crash was not: the release run retains one `com.bugsee.reactnative.ReactNativeWebException` bundle, logcat still shows `JavascriptException`, and the process dies. R2 and R3 are plain assertions of that single bundle. Debug case 10 stays `it.failing`. Do not raise a double-report. Mutation 1 (delete the keep rule) did not rename the class: this example's R8 renames nothing. The keep rule stays. The keep-rules unit test is the lock. + --- ### Task 7.5b — Device verification, iOS: the simulator's documented behaviour, and the iPhone @@ -3467,9 +3475,11 @@ Task 3.4d's external `adb shell run-as … kill -11 ` stays in place. This **Gated `E2E_RELEASE=1` on the iPhone** (`IOS_CONFIGURATION=Release`): `exc-fatal` kills the process (the console stream ends). Relaunch `exc-observe`. **Exactly one** crash bundle for the incident, named `ReactNativeWebException`: the overriding report replaces `RCTFatalException`'s. If a second, `RCTFatalException` bundle arrives, stop and report. -- [ ] **Red/Green** — the simulator case passes on the iOS 26.5 simulator. Cases 1–13 pass on the iPhone XS. -- [ ] **Mutate** — make `BGSRNReactNativeExceptionName` `@"ReactNativeException"`. Case 1 must fail on the iPhone. Revert and record. -- [ ] **Commit** — `test(e2e): JS exceptions in iOS bundles, on the iPhone`. +- [x] **Red/Green** — the simulator case passes on the iOS 26.5 simulator. Cases 1–13 pass on the iPhone XS. +- [x] **Mutate** — make `BGSRNReactNativeExceptionName` `@"ReactNativeException"`. Case 1 must fail on the iPhone. Revert and record. +- [x] **Commit** — `test(e2e): JS exceptions in iOS bundles, on the iPhone` (`5667fa5`), then `test(e2e): pin iOS unhandled recovery as failing on beta3` (`fb9449b`). + +**Ruling (2026-10-01, KRSFT, iOS SDK 7.0.0-beta3 `0d9c9d0a-9`).** The live list is empty before terminate. `terminateIosApp()` then `exc-observe` recovers zero bundles. Release dies and also recovers zero bundles, with no `RCTFatalException`. beta3 claims `live_report.plcrash` and does not claim `override_report.plcrash`. The harness must not copy one onto the other. The one-crash assertions for cases 9 and 12 and the Release gate are `it.failing`. Case 1's name mutation failed as required and was reverted. **Hardware pass: add to Task 3.H:** - Task 7.5b whole on the iPhone, including the gated Release case; @@ -3494,6 +3504,8 @@ Record, for the controller: - R13 (the second Android crash report per fatal JS error); - the four SDK gaps this phase adds to "To raise". +- [x] **Gate (2026-10-01, `fb9449b`).** Passed. `yarn test` 1609, mutation 95.15, JVM 207, XCTest 183 on simulator `6FA9B3E8`, compat 0.81.6 and 0.87.1. WOD_LX1 `AMRJCP4718402860`: 7.5a debug and release, 7.6b 4/4. Simulator 7.5b: handled-sent line, then zero bundles. R13 stays the ruling above. `includeVideo` is documented and not pinned by a test. + --- ## Phase 8 — Reporting @@ -3656,7 +3668,7 @@ Both methods run inside `BGSRNRunOnMain`. - `upload:description:severity:labels:` → `[Bugsee uploadWithSummary:… description:… severity:(BugseeSeverityLevel)BGSRNUploadSeverity(severity, [Bugsee getLaunchOptions]) labels:BGSRNStringArray(labels)]`. - `showReportDialog:…` → `[Bugsee showReportDialog]` when every argument is absent (`nil`, `nil`, `0`, `nil`). Otherwise → `showReportDialogWithSummary:(summary ?: @"") description:(description ?: @"") severity:(BugseeSeverityLevel)severity labels:BGSRNStringArray(labels)`. -- [ ] **Red** +- [x] **Red** - `upload.test.ts`: - `the two-argument form crosses severity 0 and null labels`; - `severity crosses by value`; @@ -3686,15 +3698,15 @@ Both methods run inside `BGSRNRunOnMain`. - `testStringArrayKeepsStrings`; - `testStringArrayOfNilIsNil`. - Run → FAIL. -- [ ] **Green** +- [x] **Green** - As specified. - `ios-spec-coverage`, `check-rn-compat.sh 0.81` (its java-signatures step sees the changed `upload`), both example builds and every existing `upload` call site (scenarios) are green. The existing call sites still compile unchanged. -- [ ] **Mutate** +- [x] **Mutate** - (1) Map the Android severity with `IssueSeverity.values()[n]`. `severityIsByValueNotOrdinal` must fail. - (2) Drop the `arguments.length` guard. `a fifth argument throws…` must fail. - (3) Make `BGSRNUploadSeverity` return `requested` unconditionally. `testZeroTakesTheLaunchOption` must fail. - Revert and record. -- [ ] **Commit** — `feat(report): upload with severity and labels, and showReportDialog`. +- [x] **Commit** — `feat(report): upload with severity and labels, and showReportDialog` (`c437703`). Reviewed: no findings. No device e2e in this task. --- @@ -3744,7 +3756,7 @@ createReport(): Promise; **Stubs:** `createReport` resolves `null`, and the five `createdReport*` methods reject `E_REPORT_HANDLE_DEAD`, which is truthful since no stub mints a handle. Each is commented `Task 8.2b` or `Task 8.2c`. -- [ ] **Red** — `created-report.test.ts` (native mock): +- [x] **Red** — `created-report.test.ts` (native mock): - `createReport resolves null when native made none`; - `createReport wraps a handle`; - `E_REPORT_CREATE_BUSY surfaces as BugseeReportError with its code`; @@ -3758,12 +3770,12 @@ createReport(): Promise; - `read normalises the snapshot as the handler proxy does`. `validate.test.ts`: `ReportErrorCode values are exactly the four stable strings`. Run → FAIL. -- [ ] **Green** — the module, the mock (the six `jest.fn`s; `createReport` resolves `null` by default) and the stubs. Every gate is green. -- [ ] **Mutate** +- [x] **Green** — the module, the mock (the six `jest.fn`s; `createReport` resolves `null` by default) and the stubs. Every gate is green. +- [x] **Mutate** - (1) Mark dead after the native upload resolves. `two concurrent upload calls cross once` must fail. - (2) Pass `move: true` for files. `addFileAttachment … sends no move` must fail. - Revert and record. -- [ ] **Commit** — `feat(report): createReport, a report the app fills and uploads, bridge stubbed`. +- [x] **Commit** — `feat(report): createReport, a report the app fills and uploads, bridge stubbed` (`cd79431`). Reviewed: no findings. Stubs only. No device e2e. --- @@ -3800,7 +3812,7 @@ final class CreatedReports { - `take(h)`; `null` → `E_REPORT_HANDLE_DEAD`; - otherwise `Bugsee.upload(report, ok -> promise.resolve(ok))`, logging `uploaded ok=`. -- [ ] **Red** — `CreatedReportsTest`: +- [x] **Red** — `CreatedReportsTest`: - `oneOutstandingAtATime`; - `aNullReportFreesTheSlot`; - `takeFreesTheSlot`; @@ -3809,9 +3821,9 @@ final class CreatedReports { - `clearFreesEverything`; - `concurrentReservationsAdmitOne` (eight threads through a start barrier). - Run → FAIL. -- [ ] **Green** — as specified. The example builds. -- [ ] **Mutate** — make `reserve()` return `true` always. `oneOutstandingAtATime` must fail. Revert and record. -- [ ] **Commit** — `feat(android): createReport through a one-slot registry`. +- [x] **Green** — as specified. The example builds. +- [x] **Mutate** — make `reserve()` return `true` always. `oneOutstandingAtATime` must fail. Revert and record. +- [x] **Commit** — `feat(android): createReport through a one-slot registry` (`6179794`), then `fix(android): ignore a created-report callback after its reservation ended` (`0542501`). A late listener carries the reservation stamp and cannot fill the next runtime's slot. `invalidate` still clears the registry. Not a device pass. --- @@ -3857,7 +3869,7 @@ FOUNDATION_EXPORT const NSUInteger BGSRNCreatedReportAttachmentMaxBytes; // 31 - `createReport`: `reserve` else reject busy. Then `[Bugsee createReportWithCompletion:^(r){ resolve([registry fulfil:r]); }]`. The completion is called on every path (`nil` when not launched). - `createdReportUpload`: `take`, else dead. Then `[Bugsee uploadReport:r completion:^{ resolve(@YES); }]`. -- [ ] **Red** +- [x] **Red** - `BGSRNCreatedReportsTests`: mirror 8.2b's seven, by name, with a `test` prefix. - `BGSRNCreatedReportOpsTests` (a real `BugseeExtendedReport`): - `testReadsTheFields`; @@ -3875,12 +3887,12 @@ FOUNDATION_EXPORT const NSUInteger BGSRNCreatedReportAttachmentMaxBytes; // 31 - `testTheSdkSharesAttributesAcrossExtendedReports`. This pins the SDK bug behind P5: create A, set an attribute, create B, and A's attribute is gone. When this test fails, the SDK is fixed and P5 can be lifted; the test's message says so. - `BGSRNReportOpsTests` stay green unchanged. - Run → FAIL. -- [ ] **Green** — as specified. The iOS example builds on both delivery paths. -- [ ] **Mutate** +- [x] **Green** — as specified. The iOS example builds on both delivery paths. +- [x] **Mutate** - (1) Drop the count check. `testAFourthAttachmentIsRejected` must fail. - (2) Read the file lazily, keeping the path. `testAFileIsCapturedWhenAdded` must fail. - Revert and record. -- [ ] **Commit** — `feat(ios): createReport, with the SDK's attachment limits enforced up front`. +- [x] **Commit** — `feat(ios): createReport, with the SDK's attachment limits enforced up front` (`38bfadc`), then `fix(ios): ignore a created-report callback after its reservation ended` (`a05df5a`). A late listener carries a generation token. Simulator XCTests only, not an iPhone pass. --- @@ -3953,7 +3965,7 @@ FOUNDATION_EXPORT const NSUInteger BGSRNCreatedReportAttachmentMaxBytes; // 31 10. `created-report attachment limits` — **Android:** `extra1 ok` and `extra2 ok`, and case 7's bundle has 4 attachments. 11. `a handler attaches a file by copy and by move` — `exists copy=true move=false`. Exactly one bundle with summary `att-`, whose `attachmentsOf` has both names with contents `copy ` and `move `, and `mimeType === 'text/plain'` on both. -- [ ] **Step 0** — as above. Record the result. +- [ ] **Step 0** — as above. Record the result. *2026-10-01, WOD_LX1 `AMRJCP4718402860`, nonce `cae20c890381`: the dialog was on screen (`BeforeReportShown`, fields pre-filled). The submit control is a clickable `TextView`, text `Отправить` (`bugsee_send`), `resource-id=""`, bounds `[464,51][656,163]`. The only ids in the dump are `android:id/content` and `android:id/statusBarBackground`. Android SDK 7.3.0 builds that button with `ResourceIdentifiers.POSITIVE_BUTTON = View.generateViewId()`, so uiautomator has no stable resource name to pin. Stopped. Nothing was tapped. Cases 1–11 were not run. No commit.* - [ ] **Red** — write the tests and scenarios. Run once before wiring `App.tsx` → FAIL at the first marker. - [ ] **Green** — cases 1–11 pass on the WOD_LX1. - [ ] **Mutate** diff --git a/examples/bare/App.tsx b/examples/bare/App.tsx index a3fb3c54..ae1bda63 100644 --- a/examples/bare/App.tsx +++ b/examples/bare/App.tsx @@ -13,8 +13,9 @@ * `channel` is scenarios/channel.ts, `data` is scenarios/data.ts, `secure` * is scenarios/secure.tsx, `attributes`/`attributes-persist` are * scenarios/attributes.ts, `e2e-native-smoke` / `native-crash-*` are - * scenarios/native.ts, and `blackout`, `blackout-prelaunch`, - * `secure-component` and `view-tree` are scenarios/privacy.tsx. + * scenarios/native.ts, `blackout`, `blackout-prelaunch`, + * `secure-component` and `view-tree` are scenarios/privacy.tsx, and + * `exc-*` are scenarios/exceptions.tsx. */ import { useEffect, useState } from 'react'; import { @@ -70,6 +71,14 @@ import { preLaunchPrivacyProbe, runPrivacyScenario, } from './scenarios/privacy'; +import { + ExceptionStage, + installExceptionAppHandler, + isExceptionRenderScenario, + isExceptionScenario, + preLaunchExceptionProbe, + runExceptionScenario, +} from './scenarios/exceptions'; const STATUS_NAMES: Record = { [Status.Stopped]: 'Stopped', @@ -204,6 +213,13 @@ export default function App() { const [privacy, setPrivacy] = useState< { scenario: PrivacyScenario; nonce: string; launched: boolean } | undefined >(); + /** + * Set once Launched for `exc-boundary` / `exc-root`: mounts the thrower + * (with or without an app ErrorBoundary). + */ + const [exceptionRender, setExceptionRender] = useState< + { scenario: 'exc-boundary' | 'exc-root'; nonce: string } | undefined + >(); useEffect(() => { let cancelled = false; @@ -308,6 +324,14 @@ export default function App() { if (isNativeScenario(choice.scenario)) { installNativeHandler(choice.scenario); } + // Before launch(): app ErrorUtils handler (so Bugsee chains to it), and + // the pre-launch exception probe (must not reach a bundle). + if (isExceptionScenario(choice.scenario)) { + installExceptionAppHandler(); + if (choice.scenario === 'exc-prelaunch') { + preLaunchExceptionProbe(choice.nonce); + } + } console.log(`BUGSEE_E2E launching on ${Platform.OS}`); // Polling starts before launch() is awaited, not after. The SDK brings // capture up off the main thread, so by the time the promise resolves the @@ -366,6 +390,20 @@ export default function App() { return; } + if (isExceptionScenario(choice.scenario)) { + if (isExceptionRenderScenario(choice.scenario)) { + if (!cancelled) { + setExceptionRender({ + scenario: choice.scenario, + nonce: choice.nonce, + }); + } + return; + } + runExceptionScenario(choice.scenario, choice.nonce); + return; + } + // Task 2.6: the option must have reached the SDK, not merely been // accepted by launch(). Reading it back through getLaunchOptions is // the only thing that shows the difference. @@ -468,6 +506,12 @@ export default function App() { launched={privacy.launched} /> )} + {exceptionRender !== undefined && ( + + )} ); } diff --git a/examples/bare/android/app/build.gradle b/examples/bare/android/app/build.gradle index 48cc50ae..3e50aa73 100644 --- a/examples/bare/android/app/build.gradle +++ b/examples/bare/android/app/build.gradle @@ -60,8 +60,9 @@ react { /** * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + * Task 7.5a (and 7.1c's keep-rule check) turn it on with -PbugseeE2eMinify=true. */ -def enableProguardInReleaseBuilds = false +def enableProguardInReleaseBuilds = findProperty('bugseeE2eMinify') == 'true' /** * The preferred build flavor of JavaScriptCore (JSC) diff --git a/examples/bare/e2e/bundles.ts b/examples/bare/e2e/bundles.ts index f6419948..6bed0ef5 100644 --- a/examples/bare/e2e/bundles.ts +++ b/examples/bare/e2e/bundles.ts @@ -211,6 +211,19 @@ export function parseBundle(file: string, dir: string): PulledBundle { return { file, dir, request, manifest, log, captures, binaries }; } +/** + * The parsed `crash` capture (`crash.json`), or `undefined` when the + * manifest has none. Task 7.5a: exception payloads live in + * `exception.reason` as JSON text with no prefix. + */ +export function crashOf(bundle: PulledBundle): Record | undefined { + const text = bundle.captures.get('crash'); + if (text === undefined) { + return undefined; + } + return JSON.parse(text) as Record; +} + /** * The `events` array of the capture file of manifest `type` (`log`, * `events.user`, `traces.user`), or `[]` when the bundle has none. diff --git a/examples/bare/e2e/exceptions.test.ts b/examples/bare/e2e/exceptions.test.ts new file mode 100644 index 00000000..a47b3f19 --- /dev/null +++ b/examples/bare/e2e/exceptions.test.ts @@ -0,0 +1,1360 @@ +/** + * Task 7.5a: JS exceptions end to end on Android — handled, unhandled, + * rejection, ErrorBoundary and the root reporter — into retained bundles. + * + * Task 7.5b: the same file on iOS. On the simulator (`E2E_IOS_TARGET=simulator`) + * the SDK compiles `logException` / `logUnhandledException` out (verified + * facts), so one case replaces 1–13: the `BugseeRN exception handled sent` + * line proves the call reached the SDK, and 15 s later there are zero + * bundles. On the iPhone (`E2E_IOS_TARGET=device`, Debug, `DEAD_ENDPOINT` + * retention) cases 1–13 apply with the iOS values stated below; gated + * `E2E_RELEASE=1` needs `IOS_CONFIGURATION=Release`. + * + * Android preconditions: the debug app is installed on the WOD_LX1 + * (`AMRJCP4718402860`), Metro serves this checkout (`adb reverse tcp:8081 + * tcp:`), airplane mode is on before the app starts, and + * bundles are cleared. Gated release cases need `E2E_RELEASE=1` with the + * minified release APK installed. + * + * iOS preconditions: Debug app on the pinned simulator (`IOS_SIMULATOR_ID`) + * with Metro, or on the allowlisted iPhone XS. Retention is the dead + * endpoint (no airplane mode). NSLog text is contiguous: + * `BugseeRN exception handled sent bytes=` / + * `BugseeRN exception unhandled sent bytes=` / + * `BugseeRN exception unhandled completed`. + * + * Markers, from scenarios/exceptions.tsx: + * BUGSEE_E2E exc handled-sent / rejection-sent / prelaunch-sent nonce= + * BUGSEE_E2E exc boundary-onError / boundary-fallback + * BUGSEE_E2E exc app-handler fatal= + * + * Cases 7 and 10 are `it.failing` on Android for documented 7.3.0 gaps + * (labels ignored; unhandled also files an error report). Do not weaken them. + * On iOS both are plain `it` (labels apply; Debug files no second report). + * + * Case 12's red-box line must stay on Android. Gated release R2/R3 (controller + * ruling): R13's second Bugsee crash was not retained on SDK 7.3.0; logcat + * still shows RN's JavascriptException, but exactly one Bugsee bundle is filed. + * On iOS cases 9 and 12 the unhandled report is stored, not sent: a plain + * `it` asserts the bundle list is empty, then `terminateIosApp()` and + * `exc-observe`. Exactly one recovered crash is `it.failing` on beta3 + * `0d9c9d0a-9`, which does not claim `override_report.plcrash` on the next + * launch. The harness must not copy that file onto `live_report.plcrash`. + * The Release gate is the same split: the console ended and no + * `RCTFatalException` bundle on a plain `it`; exactly one + * `ReactNativeWebException` crash is `it.failing`. + */ +import { writeFileSync } from 'node:fs'; + +import { + type PulledBundle, + airplane, + crashOf, + listAndroidBundles, + pullAndroidBundles, + removePulledBundles, + terminateIosApp, +} from './bundles'; +import { ANDROID_PACKAGE, iosTarget } from './device'; +import { + ON_ANDROID, + ON_IOS, + type Run, + awaitBundles, + bridgeLine, + clearBundles, + listBundles, + TARGET_NAME, + describeDevice, + escape, + must, + report, + startRun, + useLog, +} from './harness'; +import { + type DeviceLog, + type LogLine, + Logcat, + IosConsole, + adb, + pidOf, + resetScenario, +} from './scenario'; + +const itAndroid = ON_ANDROID ? it : it.skip; +const ON_IOS_SIM = ON_IOS && iosTarget() === 'simulator'; +const ON_IOS_DEVICE = ON_IOS && iosTarget() === 'device'; +const itIosDevice = ON_IOS_DEVICE ? it : it.skip; +const describeIosSimulator = ON_IOS_SIM ? describe : describe.skip; +const describeIosDevice = ON_IOS_DEVICE ? describe : describe.skip; +const describeRelease = + ON_ANDROID && process.env.E2E_RELEASE === '1' ? describe : describe.skip; +const describeIosRelease = + ON_IOS_DEVICE && process.env.E2E_RELEASE === '1' ? describe : describe.skip; + +jest.setTimeout(20 * 60_000); + +let log: DeviceLog; + +/** Synthetic debug id the scenario registers for nonce `n`. */ +function debugIdFor(nonce: string): string { + return `8a1c2f4e-0d3b-5e6f-9a7b-${nonce.padStart(12, '0').slice(-12)}`; +} + +interface CrashException { + readonly name?: unknown; + readonly reason?: unknown; + readonly domain?: unknown; + readonly additional_signature?: unknown; + readonly [key: string]: unknown; +} + +interface JsPayload { + readonly name?: unknown; + readonly reason?: unknown; + readonly frames?: ReadonlyArray<{ + readonly data?: { readonly member?: unknown; readonly source?: unknown }; + readonly debug_id?: unknown; + readonly [key: string]: unknown; + }>; + readonly cause?: JsPayload; + readonly signature?: unknown; + readonly platform_os?: unknown; + readonly debug_ids?: unknown; + readonly [key: string]: unknown; +} + +function exceptionOf(crash: Record): CrashException { + const exception = crash.exception; + if (exception === null || typeof exception !== 'object') { + throw new Error(`crash.json has no exception object: ${JSON.stringify(crash)}`); + } + return exception as CrashException; +} + +/** JSON.parse of `exception.reason`, after asserting it starts with `{`. */ +function payloadOf(bundle: PulledBundle): JsPayload { + const crash = crashOf(bundle); + if (crash === undefined) { + throw new Error(`bundle ${bundle.file} has no crash capture`); + } + const reason = exceptionOf(crash).reason; + if (typeof reason !== 'string' || !reason.startsWith('{')) { + throw new Error( + `bundle ${bundle.file} exception.reason is not JSON text: ${String(reason).slice(0, 120)}`, + ); + } + return JSON.parse(reason) as JsPayload; +} + +function bundlesWithReason( + bundles: readonly PulledBundle[], + reason: string, +): PulledBundle[] { + return bundles.filter(b => { + try { + return payloadOf(b).reason === reason; + } catch { + return false; + } + }); +} + +function rawCrashContains(bundle: PulledBundle, needle: string): boolean { + const text = bundle.captures.get('crash'); + return text !== undefined && text.includes(needle); +} + +function innermost(payload: JsPayload): JsPayload { + let node: JsPayload = payload; + while (node.cause !== undefined) { + node = node.cause; + } + return node; +} + +/** + * Epoch logcat: `. : `. + * Returns [pid, tid] or undefined. + */ +function pidTidOf(line: LogLine): { pid: string; tid: string } | undefined { + const match = /^\s*\d+\.\d+\s+(\d+)\s+(\d+)\s+\w\s+\S+\s*:/.exec(line.text); + if (match === null) { + return undefined; + } + return { pid: match[1]!, tid: match[2]! }; +} + +async function awaitProcessGone(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if ((await pidOf()) === undefined) { + return true; + } + if (Date.now() >= deadline) { + return false; + } + await new Promise(resolve => setTimeout(resolve, 200)); + } +} + +async function processAliveAfter(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)); + return (await pidOf()) !== undefined; +} + +describeDevice(`JS exceptions on ${TARGET_NAME}`, () => { + beforeAll(async () => { + if (ON_IOS) { + log = IosConsole.start(); + useLog(log, '7.5b'); + return; + } + if (!ON_ANDROID) { + return; + } + log = await Logcat.start(); + useLog(log, '7.5a'); + await airplane(true); + }); + + afterAll(async () => { + if (!ON_ANDROID && !ON_IOS) { + return; + } + try { + if (ON_IOS) { + await terminateIosApp().catch(() => {}); + } else { + await adb('shell', 'am', 'force-stop', ANDROID_PACKAGE).catch(() => {}); + } + await clearBundles().catch((error: unknown) => report('cleanup clear failed', String(error))); + } finally { + try { + if (ON_ANDROID) { + await airplane(false); + } + } finally { + try { + const { removed, kept } = removePulledBundles(); + report('pulled bundle roots', { removed: removed.length, kept }); + } finally { + try { + if (log !== undefined) { + log.stop(); + const dump = process.env.E2E_LOGCAT_DUMP; + if (dump) { + writeFileSync(dump, log.lines.map(line => line.text).join('\n')); + } + } + } finally { + resetScenario(); + } + } + } + } + }); + + describe('exc-handled', () => { + let run: Run; + let nonce: string; + let bundles: PulledBundle[]; + let handled: PulledBundle; + let handledCrash: Record; + let handledPayload: JsPayload; + let objectBundle: PulledBundle; + let threadLine: LogLine | undefined; + let threadComm: string | undefined; + + beforeAll(async () => { + if (!ON_ANDROID) { + return; + } + await clearBundles(); + run = await startRun('exc-handled'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + expect(run.dev).toBe(true); + + must( + await log.waitFor( + new RegExp(`BUGSEE_E2E exc handled-sent nonce=${nonce}`), + 30_000, + run.launched.index, + ), + 'the handled-sent marker', + run.start, + ); + + const sent = must( + await log.waitFor(bridgeLine('exception handled sent'), 15_000, run.start), + 'exception handled sent', + run.start, + ); + threadLine = sent; + const ids = pidTidOf(sent); + if (ids !== undefined) { + threadComm = ( + await adb('shell', 'cat', `/proc/${ids.pid}/task/${ids.tid}/comm`) + ).trim(); + report('handled sent line', sent.text.trim()); + report('handled sent tid/comm', { tid: ids.tid, comm: threadComm }); + } + + bundles = await awaitBundles(2); + report( + 'handled bundle files', + bundles.map(b => ({ file: b.file, type: b.request.type })), + ); + + const handledOnes = bundlesWithReason(bundles, `E2E handled ${nonce}`); + expect(handledOnes).toHaveLength(1); + handled = handledOnes[0]!; + handledCrash = crashOf(handled)!; + handledPayload = payloadOf(handled); + + const objectOnes = bundlesWithReason(bundles, `obj-${nonce}`); + expect(objectOnes).toHaveLength(1); + objectBundle = objectOnes[0]!; + report('handled payload', handledPayload); + }); + + itAndroid( + 'a handled exception is an error report named ReactNativeWebException', + () => { + expect(handled.request.type).toBe('error'); + expect(handledCrash.handled).toBe(true); + expect(exceptionOf(handledCrash).name).toBe( + 'com.bugsee.reactnative.ReactNativeWebException', + ); + }, + ); + + itAndroid('the reason is the JS payload', () => { + expect(handledPayload.name).toBe('TypeError'); + expect(handledPayload.frames?.[0]?.data?.member).toBe('bugseeE2EThrowSite'); + expect(handledPayload.signature).toMatch(/^[0-9a-f]{40}$/); + expect(handledPayload.platform_os).toBe('android'); + expect(handledPayload.cause).toEqual( + expect.objectContaining({ + name: 'RangeError', + reason: `inner ${nonce}`, + }), + ); + }); + + itAndroid('debug IDs travel as a map and on each frame of the bundle', () => { + const ID = debugIdFor(nonce); + const frame0 = handledPayload.frames?.[0]; + expect(frame0).toBeDefined(); + // On Metro/Android, fileKey === cleanSource, so frames[0].data.source is K. + const K = frame0!.data?.source; + expect(typeof K).toBe('string'); + expect(handledPayload.debug_ids).toEqual({ [K as string]: ID }); + for (const frame of handledPayload.frames ?? []) { + if (frame.data?.source === K) { + expect(frame.debug_id).toBe(ID); + } + } + report('debug_ids', handledPayload.debug_ids); + report('handled sent line', threadLine?.text.trim()); + report('thread BugseeRN-exceptions', threadComm); + // Linux TASK_COMM_LEN is 16 (15 printable): `BugseeRN-exceptions` → `BugseeRN-except`. + expect(threadComm).toBe('BugseeRN-except'); + expect(threadLine).toBeDefined(); + }); + + itAndroid('the payload carries nothing else of the error', () => { + for (const bundle of bundles) { + expect(rawCrashContains(bundle, `tok-${nonce}`)).toBe(false); + expect(rawCrashContains(bundle, `pw-${nonce}`)).toBe(false); + } + const objectPayload = payloadOf(objectBundle); + expect(objectPayload.reason).toBe(`obj-${nonce}`); + expect(objectPayload.name).toBe('Error'); + }); + + itAndroid('the domain reaches the report', () => { + expect(exceptionOf(handledCrash).domain).toBe(`e2e-${nonce}`); + }); + + itAndroid('the client signature carries the JS signature', () => { + expect(exceptionOf(handledCrash).additional_signature).toBe( + handledPayload.signature, + ); + }); + + itAndroid.failing('labels reach the report', () => { + const labels = handled.request.labels; + report('case 7 actual labels', labels); + expect(labels).toEqual(expect.arrayContaining([`lbl-${nonce}`])); + }); + }); + + describe('exc-rejection', () => { + let run: Run; + let nonce: string; + let bundles: PulledBundle[]; + + beforeAll(async () => { + if (!ON_ANDROID) { + return; + } + await clearBundles(); + run = await startRun('exc-rejection'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + must( + await log.waitFor( + new RegExp(`BUGSEE_E2E exc rejection-sent nonce=${nonce}`), + 20_000, + run.launched.index, + ), + 'the rejection-sent marker', + run.start, + ); + bundles = await awaitBundles(1); + }); + + itAndroid('an unhandled rejection is one error report', () => { + const matches = bundlesWithReason(bundles, `E2E rejection ${nonce}`); + expect(matches).toHaveLength(1); + expect(matches[0]!.request.type).toBe('error'); + }); + }); + + describe('exc-fatal', () => { + let run: Run; + let nonce: string; + let bundles: PulledBundle[]; + let crashBundle: PulledBundle; + let sent: LogLine; + let completed: LogLine; + let appHandler: LogLine; + let alive: boolean; + + beforeAll(async () => { + if (!ON_ANDROID) { + return; + } + await clearBundles(); + run = await startRun('exc-fatal'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + expect(run.dev).toBe(true); + + sent = must( + await log.waitFor(bridgeLine('exception unhandled sent'), 20_000, run.launched.index), + 'exception unhandled sent', + run.start, + ); + completed = must( + await log.waitFor( + bridgeLine('exception unhandled completed'), + 10_000, + sent.index, + ), + 'exception unhandled completed', + run.start, + ); + appHandler = must( + await log.waitFor( + new RegExp(`BUGSEE_E2E exc app-handler fatal=true`), + 10_000, + completed.index, + ), + 'app-handler fatal=true', + run.start, + ); + alive = await processAliveAfter(3_000); + report('fatal log order', { + sent: sent.text.trim(), + completed: completed.text.trim(), + appHandler: appHandler.text.trim(), + alive, + }); + + // 7.3.0 also files an error report for the same incident (case 10). + // Wait for at least the crash, then a short beat for the extra error. + await awaitBundles(1, 30_000); + await new Promise(resolve => setTimeout(resolve, 3_000)); + bundles = await pullAndroidBundles(); + report( + 'fatal bundles', + bundles.map(b => ({ file: b.file, type: b.request.type })), + ); + + const crashes = bundlesWithReason(bundles, `E2E fatal ${nonce}`).filter( + b => b.request.type === 'crash', + ); + expect(crashes.length).toBeGreaterThanOrEqual(1); + crashBundle = crashes[0]!; + }); + + itAndroid('a fatal JS error is reported as a crash, then RN\'s handler runs', () => { + expect(crashBundle.request.type).toBe('crash'); + expect(payloadOf(crashBundle).reason).toBe(`E2E fatal ${nonce}`); + expect(sent.index).toBeLessThan(completed.index); + expect(completed.index).toBeLessThan(appHandler.index); + expect(alive).toBe(true); + }); + + itAndroid.failing( + 'the fatal error files no second report for the incident', + () => { + const others = bundles.filter(b => { + if (b.file === crashBundle.file) { + return false; + } + try { + return payloadOf(b).reason === `E2E fatal ${nonce}` || rawCrashContains(b, `E2E fatal ${nonce}`); + } catch { + return rawCrashContains(b, `E2E fatal ${nonce}`); + } + }); + report( + 'case 10 extra bundles', + others.map(b => ({ + file: b.file, + type: b.request.type, + name: crashOf(b) !== undefined ? exceptionOf(crashOf(b)!).name : undefined, + })), + ); + expect(others).toHaveLength(0); + }, + ); + }); + + describe('exc-boundary', () => { + let run: Run; + let nonce: string; + let bundles: PulledBundle[]; + let onError: LogLine; + let fallback: LogLine; + + beforeAll(async () => { + if (!ON_ANDROID) { + return; + } + await clearBundles(); + run = await startRun('exc-boundary'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + onError = must( + await log.waitFor(/BUGSEE_E2E exc boundary-onError/, 20_000, run.launched.index), + 'boundary-onError', + run.start, + ); + fallback = must( + await log.waitFor(/BUGSEE_E2E exc boundary-fallback/, 10_000, onError.index), + 'boundary-fallback', + run.start, + ); + bundles = await awaitBundles(1); + }); + + itAndroid( + 'a boundary catches, reports as handled and renders the fallback', + () => { + const matches = bundlesWithReason(bundles, `E2E boundary ${nonce}`); + expect(matches).toHaveLength(1); + expect(matches[0]!.request.type).toBe('error'); + const payload = payloadOf(matches[0]!); + const inner = innermost(payload); + expect(inner.name).toBe('ErrorBoundary Error'); + expect( + (inner.frames ?? []).some(f => f.data?.member === 'BugseeE2EThrower'), + ).toBe(true); + expect(onError).toBeDefined(); + expect(fallback).toBeDefined(); + }, + ); + }); + + describe('exc-root', () => { + let run: Run; + let nonce: string; + let bundles: PulledBundle[]; + let sent: LogLine; + let redBox: LogLine; + + beforeAll(async () => { + if (!ON_ANDROID) { + return; + } + await clearBundles(); + run = await startRun('exc-root'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + sent = must( + await log.waitFor(bridgeLine('exception unhandled sent'), 30_000, run.launched.index), + 'exception unhandled sent (root)', + run.start, + ); + // RN red box: ReactNativeJS E-level line carrying the message. + redBox = must( + await log.waitFor( + new RegExp(`ReactNativeJS\\s*:\\s*.*E2E boundary ${escape(nonce)}`), + 20_000, + sent.index, + ), + 'RN red-box line for the root render error', + run.start, + ); + // Confirm it is an E-level line (epoch: ... E ReactNativeJS: ...). + expect(redBox.text).toMatch(/\sE\s+ReactNativeJS\s*:/); + report('root red-box', redBox.text.trim()); + + // Poll until a crash with this reason exists (error twin may appear first + // or never). Do not require a second bundle. + const deadline = Date.now() + 30_000; + for (;;) { + bundles = await pullAndroidBundles(); + const crashMatch = bundlesWithReason(bundles, `E2E boundary ${nonce}`).find( + b => b.request.type === 'crash', + ); + if (crashMatch !== undefined || Date.now() >= deadline) { + break; + } + await new Promise(resolve => setTimeout(resolve, 1_000)); + } + report( + 'root bundles', + bundles.map(b => ({ + file: b.file, + type: b.request.type, + name: crashOf(b) ? exceptionOf(crashOf(b)!).name : undefined, + })), + ); + }); + + itAndroid( + 'a render error with no boundary is reported once, as a crash, by the root reporter', + () => { + // Brief: exactly one bundle with that reason *and* type crash. + const crashes = bundlesWithReason(bundles, `E2E boundary ${nonce}`).filter( + b => b.request.type === 'crash', + ); + expect(crashes).toHaveLength(1); + expect(sent.index).toBeLessThan(redBox.index); + }, + ); + }); + + describe('exc-prelaunch', () => { + let run: Run; + let nonce: string; + let preSent: LogLine; + let bundles: PulledBundle[]; + + beforeAll(async () => { + if (!ON_ANDROID) { + return; + } + await clearBundles(); + run = await startRun('exc-prelaunch'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + preSent = must( + log.all(new RegExp(`BUGSEE_E2E exc prelaunch-sent nonce=${nonce}`), run.start)[0], + 'prelaunch-sent (must precede Launched)', + run.start, + ); + expect(preSent.index).toBeLessThan(run.launched.index); + + await new Promise(resolve => setTimeout(resolve, 10_000)); + const names = await listAndroidBundles(); + bundles = names.length === 0 ? [] : await pullAndroidBundles(); + report('prelaunch bundle count', bundles.length); + }); + + itAndroid('nothing reported before launch reaches a bundle', () => { + expect(preSent.index).toBeLessThan(run.launched.index); + for (const bundle of bundles) { + const crashText = bundle.captures.get('crash') ?? ''; + expect(crashText.includes(`pre-${nonce}`)).toBe(false); + try { + expect(payloadOf(bundle).reason).not.toBe(`pre-${nonce}`); + } catch { + // no crash capture — fine + } + } + }); + }); + + describeRelease('gated E2E_RELEASE=1', () => { + let run: Run; + let nonce: string; + let observe: Run; + let bundles: PulledBundle[]; + let gone: boolean; + + beforeAll(async () => { + await clearBundles(); + run = await startRun('exc-fatal'); + nonce = run.scenario.nonce; + report('release banner', run.banner.text.trim()); + expect(run.dev).toBe(false); + + must( + await log.waitFor(/FATAL EXCEPTION/, 30_000, run.launched.index), + 'FATAL EXCEPTION', + run.start, + ); + must( + await log.waitFor( + /com\.facebook\.react\.common\.JavascriptException/, + 10_000, + run.launched.index, + ), + 'JavascriptException', + run.start, + ); + gone = await awaitProcessGone(15_000); + report('release pid gone', gone); + expect(gone).toBe(true); + + observe = await startRun('exc-observe'); + report('observe banner', observe.banner.text.trim()); + // Wait for recovery / bundling. One retained Bugsee bundle (R13's second + // Bugsee crash is not retained on SDK 7.3.0 — controller ruling). + await new Promise(resolve => setTimeout(resolve, 5_000)); + bundles = await awaitBundles(1, 60_000); + report( + 'R1–R3 bundles', + bundles.map(b => { + const crash = crashOf(b); + return { + file: b.file, + type: b.request.type, + name: crash !== undefined ? exceptionOf(crash).name : undefined, + reason: + crash !== undefined && typeof exceptionOf(crash).reason === 'string' + ? String(exceptionOf(crash).reason).slice(0, 80) + : undefined, + }; + }), + ); + }); + + itAndroid('ours survives R8', () => { + const ours = bundles.filter(b => { + if (b.request.type !== 'crash') { + return false; + } + const crash = crashOf(b); + if (crash === undefined) { + return false; + } + if (exceptionOf(crash).name !== 'com.bugsee.reactnative.ReactNativeWebException') { + return false; + } + try { + return payloadOf(b).reason === `E2E fatal ${nonce}`; + } catch { + return false; + } + }); + expect(ours).toHaveLength(1); + report('R1 ours', ours.map(b => b.file)); + }); + + // Controller ruling (task 7.5a): R13's second Bugsee crash was not retained + // on SDK 7.3.0. Logcat still shows RN's JavascriptException and the process + // dies; Bugsee keeps exactly one crash — ReactNativeWebException. + itAndroid('RN JavascriptException is not a second Bugsee bundle', () => { + const rnNamed = bundles.filter(b => { + const crash = crashOf(b); + if (crash === undefined) { + return false; + } + return String(exceptionOf(crash).name ?? '').includes('JavascriptException'); + }); + expect(rnNamed).toHaveLength(0); + expect(bundles).toHaveLength(1); + const only = bundles[0]!; + expect(only.request.type).toBe('crash'); + expect(exceptionOf(crashOf(only)!).name).toBe( + 'com.bugsee.reactnative.ReactNativeWebException', + ); + report('R2 single bundle', { file: only.file, type: only.request.type }); + }); + + // Not it.failing on release: debug case 10 stays it.failing for the + // crash+error double-file; release retains one bundle for the incident. + itAndroid('exactly one bundle contains the fatal reason', () => { + const withFatal = bundles.filter(b => rawCrashContains(b, `E2E fatal ${nonce}`)); + expect(withFatal).toHaveLength(1); + report('R3 fatal bundles', withFatal.map(b => b.file)); + }); + }); + + // --- Task 7.5b: iOS simulator documented behaviour ------------------------- + + describeIosSimulator('simulator slice (logException compiled out)', () => { + it('the simulator slice reports no JS exception', async () => { + await clearBundles(); + const run = await startRun('exc-handled'); + const nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + expect(run.dev).toBe(true); + + must( + await log.waitFor( + new RegExp(`BUGSEE_E2E exc handled-sent nonce=${nonce}`), + 30_000, + run.launched.index, + ), + 'the handled-sent marker', + run.start, + ); + + const sent = must( + await log.waitFor(bridgeLine('exception handled sent'), 15_000, run.start), + 'exception handled sent', + run.start, + ); + report('simulator handled sent', sent.text.trim()); + expect(sent.text).toMatch(/BugseeRN exception handled sent bytes=\d+/); + + await new Promise(resolve => setTimeout(resolve, 15_000)); + const names = await listBundles(); + report('simulator bundle count after 15s', names.length); + expect(names).toHaveLength(0); + }); + }); + + // --- Task 7.5b: iPhone Debug cases 1–13 ------------------------------------ + + describeIosDevice('exc-handled (iPhone)', () => { + let run: Run; + let nonce: string; + let bundles: PulledBundle[]; + let handled: PulledBundle; + let handledCrash: Record; + let handledPayload: JsPayload; + let objectBundle: PulledBundle; + + beforeAll(async () => { + await clearBundles(); + run = await startRun('exc-handled'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + expect(run.dev).toBe(true); + + must( + await log.waitFor( + new RegExp(`BUGSEE_E2E exc handled-sent nonce=${nonce}`), + 30_000, + run.launched.index, + ), + 'the handled-sent marker', + run.start, + ); + must( + await log.waitFor(bridgeLine('exception handled sent'), 15_000, run.start), + 'exception handled sent', + run.start, + ); + + bundles = await awaitBundles(2); + report( + 'handled bundle files', + bundles.map(b => ({ file: b.file, type: b.request.type })), + ); + + const handledOnes = bundlesWithReason(bundles, `E2E handled ${nonce}`); + expect(handledOnes).toHaveLength(1); + handled = handledOnes[0]!; + handledCrash = crashOf(handled)!; + handledPayload = payloadOf(handled); + + const objectOnes = bundlesWithReason(bundles, `obj-${nonce}`); + expect(objectOnes).toHaveLength(1); + objectBundle = objectOnes[0]!; + report('handled payload', handledPayload); + report('handled crash keys', { + managed: handledCrash.managed, + type: handledCrash.type, + handled: handledCrash.handled, + }); + }); + + itIosDevice( + 'a handled exception is an error report named ReactNativeWebException', + () => { + expect(handled.request.type).toBe('error'); + expect(exceptionOf(handledCrash).name).toBe('ReactNativeWebException'); + expect(handledCrash.managed).toBe(true); + expect(handledCrash.type).toBe('ios'); + expect(handledCrash.handled).toBe(true); + }, + ); + + itIosDevice('the reason is the JS payload', () => { + expect(handledPayload.name).toBe('TypeError'); + expect(handledPayload.frames?.[0]?.data?.member).toBe('bugseeE2EThrowSite'); + expect(handledPayload.signature).toMatch(/^[0-9a-f]{40}$/); + expect(handledPayload.platform_os).toBe('ios'); + expect(handledPayload.cause).toEqual( + expect.objectContaining({ + name: 'RangeError', + reason: `inner ${nonce}`, + }), + ); + }); + + itIosDevice('debug IDs travel as a map and on each frame of the bundle', () => { + const ID = debugIdFor(nonce); + const frame0 = handledPayload.frames?.[0]; + expect(frame0).toBeDefined(); + const K = frame0!.data?.source; + expect(typeof K).toBe('string'); + expect(handledPayload.debug_ids).toEqual({ [K as string]: ID }); + for (const frame of handledPayload.frames ?? []) { + if (frame.data?.source === K) { + expect(frame.debug_id).toBe(ID); + } + } + report('debug_ids', handledPayload.debug_ids); + }); + + itIosDevice('the payload carries nothing else of the error', () => { + for (const bundle of bundles) { + expect(rawCrashContains(bundle, `tok-${nonce}`)).toBe(false); + expect(rawCrashContains(bundle, `pw-${nonce}`)).toBe(false); + } + const objectPayload = payloadOf(objectBundle); + expect(objectPayload.reason).toBe(`obj-${nonce}`); + expect(objectPayload.name).toBe('Error'); + }); + + itIosDevice('the domain reaches the report', () => { + const opts = handledCrash.exceptionLoggingOptions as + | Record + | undefined; + expect(opts).toBeDefined(); + expect(opts!.exceptionDomain).toBe(`e2e-${nonce}`); + expect(opts!.includeVideo).toBe(true); + }); + + itIosDevice('the client signature carries the JS signature', () => { + expect(exceptionOf(handledCrash).additional_signature).toBe( + handledPayload.signature, + ); + }); + + itIosDevice('labels reach the report', () => { + const labels = handled.request.labels; + report('case 7 iOS labels', labels); + expect(labels).toEqual(expect.arrayContaining([`lbl-${nonce}`])); + }); + }); + + describeIosDevice('exc-rejection (iPhone)', () => { + let run: Run; + let nonce: string; + let bundles: PulledBundle[]; + + beforeAll(async () => { + await clearBundles(); + run = await startRun('exc-rejection'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + must( + await log.waitFor( + new RegExp(`BUGSEE_E2E exc rejection-sent nonce=${nonce}`), + 20_000, + run.launched.index, + ), + 'the rejection-sent marker', + run.start, + ); + bundles = await awaitBundles(1); + }); + + itIosDevice('an unhandled rejection is one error report', () => { + const matches = bundlesWithReason(bundles, `E2E rejection ${nonce}`); + expect(matches).toHaveLength(1); + expect(matches[0]!.request.type).toBe('error'); + }); + }); + + describeIosDevice('exc-fatal (iPhone)', () => { + let run: Run; + let nonce: string; + let bundles: PulledBundle[]; + let stored: string[]; + let sent: LogLine; + let completed: LogLine; + let appHandler: LogLine; + + beforeAll(async () => { + await clearBundles(); + run = await startRun('exc-fatal'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + expect(run.dev).toBe(true); + + sent = must( + await log.waitFor(bridgeLine('exception unhandled sent'), 20_000, run.launched.index), + 'exception unhandled sent', + run.start, + ); + completed = must( + await log.waitFor( + bridgeLine('exception unhandled completed'), + 10_000, + sent.index, + ), + 'exception unhandled completed', + run.start, + ); + appHandler = must( + await log.waitFor( + new RegExp(`BUGSEE_E2E exc app-handler fatal=true`), + 10_000, + completed.index, + ), + 'app-handler fatal=true', + run.start, + ); + report('fatal log order', { + sent: sent.text.trim(), + completed: completed.text.trim(), + appHandler: appHandler.text.trim(), + }); + + // Stored, not sent. The empty-list assertion is a plain `it` below, + // so a hook does not fail the suite. The next launch is the files the + // process left; the harness does not copy override_report.plcrash. + stored = await listBundles(); + report('fatal bundles before terminate', stored); + await terminateIosApp(); + const observe = await startRun('exc-observe'); + report('observe banner', observe.banner.text.trim()); + bundles = await awaitBundles(1); + report( + 'fatal recovered bundles', + bundles.map(b => { + const crash = crashOf(b); + const exception = crash?.exception; + const reason = + exception !== null && typeof exception === 'object' + ? String((exception as { reason?: unknown }).reason).slice(0, 160) + : undefined; + const name = + exception !== null && typeof exception === 'object' + ? (exception as { name?: unknown }).name + : undefined; + return { + file: b.file, + type: b.request.type, + name, + reason, + hasFatal: rawCrashContains(b, `E2E fatal ${nonce}`), + }; + }), + ); + }); + + itIosDevice('the unhandled fatal is stored, not sent', () => { + expect(stored).toEqual([]); + expect(sent.index).toBeLessThan(completed.index); + expect(completed.index).toBeLessThan(appHandler.index); + }); + + // beta3 0d9c9d0a-9 does not claim override_report.plcrash on the next + // launch, so this list is empty. The harness must not copy that file + // onto live_report.plcrash. The body asserts the correct outcome; + // remove .failing when a beta recovers the stored report. + itIosDevice.failing( + 'a fatal JS error is reported as a crash, then RN\'s handler runs', + () => { + const crashes = bundlesWithReason(bundles, `E2E fatal ${nonce}`).filter( + b => b.request.type === 'crash', + ); + expect(crashes).toHaveLength(1); + expect(crashes[0]!.request.type).toBe('crash'); + expect(payloadOf(crashes[0]!).reason).toBe(`E2E fatal ${nonce}`); + }, + ); + + itIosDevice('the fatal error files no second report for the incident', () => { + const others = bundles.filter(b => { + try { + return ( + payloadOf(b).reason === `E2E fatal ${nonce}` || + rawCrashContains(b, `E2E fatal ${nonce}`) + ); + } catch { + return rawCrashContains(b, `E2E fatal ${nonce}`); + } + }); + report( + 'case 10 iOS extra bundles', + others.map(b => ({ + file: b.file, + type: b.request.type, + name: crashOf(b) !== undefined ? exceptionOf(crashOf(b)!).name : undefined, + })), + ); + // The one recovered crash, when the SDK starts producing it, is case 9. + // Zero bundles is not a second report. More than one is. + expect(others.length).toBeLessThanOrEqual(1); + }); + }); + + describeIosDevice('exc-boundary (iPhone)', () => { + let run: Run; + let nonce: string; + let bundles: PulledBundle[]; + let onError: LogLine; + let fallback: LogLine; + + beforeAll(async () => { + await clearBundles(); + run = await startRun('exc-boundary'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + onError = must( + await log.waitFor(/BUGSEE_E2E exc boundary-onError/, 20_000, run.launched.index), + 'boundary-onError', + run.start, + ); + fallback = must( + await log.waitFor(/BUGSEE_E2E exc boundary-fallback/, 10_000, onError.index), + 'boundary-fallback', + run.start, + ); + bundles = await awaitBundles(1); + }); + + itIosDevice( + 'a boundary catches, reports as handled and renders the fallback', + () => { + const matches = bundlesWithReason(bundles, `E2E boundary ${nonce}`); + expect(matches).toHaveLength(1); + expect(matches[0]!.request.type).toBe('error'); + const payload = payloadOf(matches[0]!); + const inner = innermost(payload); + expect(inner.name).toBe('ErrorBoundary Error'); + expect( + (inner.frames ?? []).some(f => f.data?.member === 'BugseeE2EThrower'), + ).toBe(true); + expect(onError).toBeDefined(); + expect(fallback).toBeDefined(); + }, + ); + }); + + describeIosDevice('exc-root (iPhone)', () => { + let run: Run; + let nonce: string; + let bundles: PulledBundle[]; + let stored: string[]; + let sent: LogLine; + + beforeAll(async () => { + await clearBundles(); + run = await startRun('exc-root'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + sent = must( + await log.waitFor(bridgeLine('exception unhandled sent'), 30_000, run.launched.index), + 'exception unhandled sent (root)', + run.start, + ); + must( + await log.waitFor( + bridgeLine('exception unhandled completed'), + 15_000, + sent.index, + ), + 'exception unhandled completed (root)', + run.start, + ); + report('root unhandled sent', sent.text.trim()); + + stored = await listBundles(); + report('root bundles before terminate', stored); + await terminateIosApp(); + const observe = await startRun('exc-observe'); + report('root observe banner', observe.banner.text.trim()); + bundles = await awaitBundles(1); + report( + 'root recovered bundles', + bundles.map(b => ({ + file: b.file, + type: b.request.type, + name: crashOf(b) ? exceptionOf(crashOf(b)!).name : undefined, + })), + ); + }); + + itIosDevice('the unhandled root error is stored, not sent', () => { + expect(stored).toEqual([]); + }); + + // beta3 0d9c9d0a-9 does not claim override_report.plcrash on the next + // launch, so this list is empty. The harness must not copy that file + // onto live_report.plcrash. The body asserts the correct outcome; + // remove .failing when a beta recovers the stored report. + itIosDevice.failing( + 'a render error with no boundary is reported once, as a crash, by the root reporter', + () => { + const crashes = bundlesWithReason(bundles, `E2E boundary ${nonce}`).filter( + b => b.request.type === 'crash', + ); + expect(crashes).toHaveLength(1); + expect(crashes[0]!.request.type).toBe('crash'); + expect(payloadOf(crashes[0]!).reason).toBe(`E2E boundary ${nonce}`); + expect(bundles).toHaveLength(1); + }, + ); + }); + + describeIosDevice('exc-prelaunch (iPhone)', () => { + let run: Run; + let nonce: string; + let preSent: LogLine; + let bundles: PulledBundle[]; + + beforeAll(async () => { + await clearBundles(); + run = await startRun('exc-prelaunch'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + preSent = must( + log.all(new RegExp(`BUGSEE_E2E exc prelaunch-sent nonce=${nonce}`), run.start)[0], + 'prelaunch-sent (must precede Launched)', + run.start, + ); + expect(preSent.index).toBeLessThan(run.launched.index); + + await new Promise(resolve => setTimeout(resolve, 10_000)); + const names = await listBundles(); + bundles = names.length === 0 ? [] : await awaitBundles(names.length, 5_000); + report('prelaunch bundle count', bundles.length); + }); + + itIosDevice('nothing reported before launch reaches a bundle', () => { + expect(preSent.index).toBeLessThan(run.launched.index); + for (const bundle of bundles) { + const crashText = bundle.captures.get('crash') ?? ''; + expect(crashText.includes(`pre-${nonce}`)).toBe(false); + try { + expect(payloadOf(bundle).reason).not.toBe(`pre-${nonce}`); + } catch { + // no crash capture — fine + } + } + }); + }); + + describeIosRelease('gated E2E_RELEASE=1 (iPhone Release)', () => { + let run: Run; + let observe: Run; + let nonce: string; + let bundles: PulledBundle[]; + let consoleEnded: boolean; + + beforeAll(async () => { + await clearBundles(); + run = await startRun('exc-fatal'); + nonce = run.scenario.nonce; + report('release banner', run.banner.text.trim()); + expect(run.dev).toBe(false); + + must( + await log.waitFor(bridgeLine('exception unhandled sent'), 30_000, run.launched.index), + 'exception unhandled sent (release)', + run.start, + ); + + // Release: the process dies; the console stream ends with it. + const ended = run.launch?.ended; + if (ended === undefined) { + throw new Error('iOS release run has no launch.ended promise'); + } + const race = await Promise.race([ + ended.then(() => 'ended' as const), + new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 30_000)), + ]); + consoleEnded = race === 'ended'; + report('release console ended', consoleEnded); + + observe = await startRun('exc-observe'); + report('release observe banner', observe.banner.text.trim()); + await new Promise(resolve => setTimeout(resolve, 5_000)); + bundles = await awaitBundles(1, 60_000); + report( + 'iOS release bundles', + bundles.map(b => { + const crash = crashOf(b); + return { + file: b.file, + type: b.request.type, + name: crash !== undefined ? exceptionOf(crash).name : undefined, + reason: + crash !== undefined && typeof exceptionOf(crash).reason === 'string' + ? String(exceptionOf(crash).reason).slice(0, 80) + : undefined, + }; + }), + ); + }); + + itIosDevice('the process died and no RCTFatalException bundle was exported', () => { + expect(consoleEnded).toBe(true); + const rctFatal = bundles.filter(b => { + const crash = crashOf(b); + if (crash === undefined) { + return false; + } + return String(exceptionOf(crash).name ?? '').includes('RCTFatalException'); + }); + if (rctFatal.length > 0) { + throw new Error( + `RCTFatalException bundle arrived: ${rctFatal.map(b => b.file).join(', ')}`, + ); + } + }); + + // beta3 0d9c9d0a-9 does not claim override_report.plcrash on the next + // launch, so this list is empty. The harness must not copy that file + // onto live_report.plcrash. The body asserts the correct outcome; + // remove .failing when a beta recovers the stored report. + itIosDevice.failing('exactly one ReactNativeWebException crash for the fatal', () => { + const ours = bundles.filter(b => { + if (b.request.type !== 'crash') { + return false; + } + const crash = crashOf(b); + if (crash === undefined) { + return false; + } + if (exceptionOf(crash).name !== 'ReactNativeWebException') { + return false; + } + try { + return payloadOf(b).reason === `E2E fatal ${nonce}`; + } catch { + return false; + } + }); + expect(ours).toHaveLength(1); + expect(bundles).toHaveLength(1); + report('iOS release single bundle', { file: ours[0]!.file }); + }); + }); +}); diff --git a/examples/bare/e2e/scenario.ts b/examples/bare/e2e/scenario.ts index 97e153d9..d70cc53b 100644 --- a/examples/bare/e2e/scenario.ts +++ b/examples/bare/e2e/scenario.ts @@ -127,13 +127,21 @@ export function metroPort(): number { } /** - * `-RCT_jsLocation localhost:`, which `RCTBundleURLProvider` reads from + * `-RCT_jsLocation :`, which `RCTBundleURLProvider` reads from * NSUserDefaults (the launch arguments' volatile domain), when a port is * configured; nothing when it is unset, so a default run launches exactly as * before. A bad value throws, as `parseMetroPort` says. + * + * The host is `localhost` unless `E2E_METRO_HOST` is set. A simulator reaches + * the host's ports as localhost. A physical iPhone does not: `localhost` is + * the phone, so a non-default port also needs this Mac's LAN address. */ export function metroArgs(raw: string | undefined = process.env.E2E_METRO_PORT): string[] { - return raw === undefined ? [] : ['-RCT_jsLocation', `localhost:${parseMetroPort(raw)}`]; + if (raw === undefined) { + return []; + } + const host = process.env.E2E_METRO_HOST?.trim() || 'localhost'; + return ['-RCT_jsLocation', `${host}:${parseMetroPort(raw)}`]; } /** Runs adb against the handset under test, returning stdout. */ diff --git a/examples/bare/ios/BareExample/AppDelegate.swift b/examples/bare/ios/BareExample/AppDelegate.swift index cc5c8c48..80abc7d9 100644 --- a/examples/bare/ios/BareExample/AppDelegate.swift +++ b/examples/bare/ios/BareExample/AppDelegate.swift @@ -59,26 +59,23 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { /// Without this the e2e sees the app start and then nothing at all, which /// looks exactly like the SDK failing to launch. /// -/// Debug only, and only in the example app. Nothing in the SDK depends on it. -/// -/// The e2e markers also go out through `NSLog`. Once the SDK is launched it -/// points stderr at a pipe and drops whatever is still unread when capture -/// stops, which is the window where the app logs the option it just read -/// back. `simctl launch --console-pty` still streams unified logging, so the -/// marker arrives even when that pipe is discarded. +/// Debug mirrors every React Native log to stderr. Release only `NSLog`s +/// lines that contain `BUGSEE_E2E`: a Release build does not compile the +/// stderr mirror, and `console.log` otherwise stays in `os_log`, which +/// `devicectl` console cannot stream. The native `BugseeRN` lines are already +/// `NSLog`. private func mirrorReactNativeLogToStandardError() { -#if DEBUG let osLog = RCTDefaultLogFunction RCTSetLogFunction { level, source, fileName, lineNumber, message in osLog?(level, source, fileName, lineNumber, message) - if let message { - if message.contains("BUGSEE_E2E") { - NSLog("%@", message) - } - if let data = (message + "\n").data(using: .utf8) { - FileHandle.standardError.write(data) - } + guard let message else { return } + if message.contains("BUGSEE_E2E") { + NSLog("%@", message) + } +#if DEBUG + if let data = (message + "\n").data(using: .utf8) { + FileHandle.standardError.write(data) } - } #endif + } } diff --git a/examples/bare/scenarios/exceptions.tsx b/examples/bare/scenarios/exceptions.tsx new file mode 100644 index 00000000..5f92ba02 --- /dev/null +++ b/examples/bare/scenarios/exceptions.tsx @@ -0,0 +1,182 @@ +/** + * The JS-exception scenarios Task 7.5a drives on a device + * (e2e/exceptions.test.ts). + * + * Every value carries the run's nonce. Before the first throw, the scenario + * registers `globalThis._bugseeDebugIds` from inside a function in this file + * (its top frame shares the bundle with every throw site), simulating Phase + * 13's inject stub. The app-level ErrorUtils handler is installed before + * `launch()` so Bugsee's handler chains to it (case 9). + * + * Markers (console.log → ReactNativeJS on Android): + * BUGSEE_E2E exc handled-sent nonce= + * BUGSEE_E2E exc rejection-sent nonce= + * BUGSEE_E2E exc prelaunch-sent nonce= + * BUGSEE_E2E exc boundary-onError + * BUGSEE_E2E exc boundary-fallback + * BUGSEE_E2E exc app-handler fatal= + */ +import { useEffect } from 'react'; +import { Text, View } from 'react-native'; +import Bugsee, { ErrorBoundary } from '@bugsee/react-native'; + +export const EXCEPTION_SCENARIOS = [ + 'exc-handled', + 'exc-rejection', + 'exc-fatal', + 'exc-boundary', + 'exc-root', + 'exc-prelaunch', + 'exc-observe', +] as const; + +export type ExceptionScenario = (typeof EXCEPTION_SCENARIOS)[number]; + +export function isExceptionScenario(name: string): name is ExceptionScenario { + return (EXCEPTION_SCENARIOS as readonly string[]).includes(name); +} + +/** Scenarios that mount a React tree after Launched. */ +export function isExceptionRenderScenario( + name: string, +): name is 'exc-boundary' | 'exc-root' { + return name === 'exc-boundary' || name === 'exc-root'; +} + +function mark(message: string): void { + console.log(`BUGSEE_E2E exc ${message}`); +} + +function debugIdFor(nonce: string): string { + return `8a1c2f4e-0d3b-5e6f-9a7b-${nonce.padStart(12, '0').slice(-12)}`; +} + +/** + * Registers a synthetic debug-ID map whose key is a stack whose top frame is + * in this bundle — the same bundle every throw site lives in. + */ +export function registerDebugIds(nonce: string): void { + const ID = debugIdFor(nonce); + (globalThis as { _bugseeDebugIds?: Record })._bugseeDebugIds = { + [new Error().stack!]: ID, + }; +} + +export function bugseeE2EThrowSite(n: string): never { + const e = new TypeError(`E2E handled ${n}`); + (e as { cause?: unknown }).cause = new RangeError(`inner ${n}`); + (e as { secretToken?: string }).secretToken = `tok-${n}`; + throw e; +} + +type ErrorUtilsLike = { + getGlobalHandler(): (error: unknown, isFatal?: boolean) => void; + setGlobalHandler(handler: (error: unknown, isFatal?: boolean) => void): void; +}; + +/** + * Installs the app-level ErrorUtils handler before `launch()`, so Bugsee's + * installer chains to it. Proves the fatal path reaches RN after ours. + */ +export function installExceptionAppHandler(): void { + const utils = (globalThis as { ErrorUtils?: ErrorUtilsLike }).ErrorUtils; + if (utils === undefined) { + mark('no-ErrorUtils'); + return; + } + const rnDefault = utils.getGlobalHandler(); + utils.setGlobalHandler((e, f) => { + mark(`app-handler fatal=${String(f)}`); + rnDefault(e, f); + }); +} + +/** `exc-prelaunch`: a handled report before `launch()`, which must not file. */ +export function preLaunchExceptionProbe(nonce: string): void { + registerDebugIds(nonce); + Bugsee.logException(new Error(`pre-${nonce}`)); + mark(`prelaunch-sent nonce=${nonce}`); +} + +export function BugseeE2EThrower({ n }: { n: string }): never { + throw new Error(`E2E boundary ${n}`); +} + +function BoundaryFallback({ n }: { n: string }) { + useEffect(() => { + mark('boundary-fallback'); + }, [n]); + return ( + + boundary-fallback {n} + + ); +} + +/** Mounted after Launched for `exc-boundary` / `exc-root`. */ +export function ExceptionStage({ + scenario, + nonce, +}: { + scenario: 'exc-boundary' | 'exc-root'; + nonce: string; +}) { + // Before BugseeE2EThrower's first render throw — useEffect would never run. + registerDebugIds(nonce); + + if (scenario === 'exc-boundary') { + return ( + } + onError={() => mark('boundary-onError')} + > + + + ); + } + return ; +} + +/** What the scenario does once the SDK is Launched (non-render cases). */ +export function runExceptionScenario( + scenario: ExceptionScenario, + nonce: string, +): void { + if (scenario === 'exc-observe' || isExceptionRenderScenario(scenario)) { + return; + } + if (scenario === 'exc-prelaunch') { + return; + } + + registerDebugIds(nonce); + + if (scenario === 'exc-handled') { + try { + bugseeE2EThrowSite(nonce); + } catch (e) { + Bugsee.logException(e, { + domain: `e2e-${nonce}`, + labels: ['e2e', `lbl-${nonce}`], + includeVideo: true, + }); + } + Bugsee.logException({ password: `pw-${nonce}`, message: `obj-${nonce}` }); + mark(`handled-sent nonce=${nonce}`); + return; + } + + if (scenario === 'exc-rejection') { + // Never handled: the rejection tracker reports it as a handled error. + Promise.reject(new Error(`E2E rejection ${nonce}`)); + setTimeout(() => mark(`rejection-sent nonce=${nonce}`), 3_000); + return; + } + + if (scenario === 'exc-fatal') { + setTimeout(() => { + throw new Error(`E2E fatal ${nonce}`); + }, 0); + return; + } +} diff --git a/examples/bare/scripts/run-ios.sh b/examples/bare/scripts/run-ios.sh index 30ec6387..07d41b57 100755 --- a/examples/bare/scripts/run-ios.sh +++ b/examples/bare/scripts/run-ios.sh @@ -17,7 +17,9 @@ cd "$(dirname "$0")/.." DEVICE="${IOS_DEVICE_ID:-345BA7FE-2C29-5722-892A-BFCB1FD34D0C}" : "${IOS_DEVELOPMENT_TEAM:?set IOS_DEVELOPMENT_TEAM to your Apple Developer team id}" -APP="ios/build/Build/Products/Debug-iphoneos/BareExample.app" +# Debug by default; Release for the gated E2E_RELEASE=1 device cases (Task 7.5b). +CONFIGURATION="${IOS_CONFIGURATION:-Debug}" +APP="ios/build/Build/Products/${CONFIGURATION}-iphoneos/BareExample.app" node scripts/write-credentials.mjs @@ -26,7 +28,7 @@ node scripts/write-credentials.mjs xcodebuild \ -workspace ios/BareExample.xcworkspace \ -scheme BareExample \ - -configuration Debug \ + -configuration "$CONFIGURATION" \ -destination "id=$DEVICE" \ -derivedDataPath ios/build \ DEVELOPMENT_TEAM="$IOS_DEVELOPMENT_TEAM" \ diff --git a/packages/react-native/android/build.gradle b/packages/react-native/android/build.gradle index 50d9fe1f..fdd568a5 100644 --- a/packages/react-native/android/build.gradle +++ b/packages/react-native/android/build.gradle @@ -80,6 +80,10 @@ android { // Read by ReactNativeWrapperInitProvider, which registers the wrapper // before any JS exists and so cannot be handed this from the bridge. buildConfigField 'String', 'WRAPPER_VERSION', "\"${wrapperVersion}\"" + + // The backend routes RN exceptions on ReactNativeWebException's FQCN; + // keep the name through the app's R8 pass (Task 7.1c / 7.5a). + consumerProguardFiles 'consumer-rules.pro' } buildFeatures { diff --git a/packages/react-native/android/consumer-rules.pro b/packages/react-native/android/consumer-rules.pro new file mode 100644 index 00000000..cb22bacf --- /dev/null +++ b/packages/react-native/android/consumer-rules.pro @@ -0,0 +1,2 @@ +# The Bugsee backend routes React Native exceptions on this class name. +-keepnames class com.bugsee.reactnative.ReactNativeWebException diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index fd32fc3d..b63d1480 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -45,6 +45,22 @@ public class BugseeModule extends NativeBugseeSpec /** The wrapper's log tag, as ReportHandlerBridge and WrapperEventBus use. */ private static final String TAG = "BugseeRN"; + /** Production adapter: the SDK's handled / unhandled entry points. */ + private static final ExceptionBridge.Sdk PROD_EXCEPTION_SDK = new ExceptionBridge.Sdk() { + @Override + public void logException(final Throwable t, @Nullable final Map options) { + Bugsee.logException(t, options); + } + + @Override + public void logUnhandledException( + final Throwable t, + @Nullable final Map options + ) { + Bugsee.logUnhandledException(t, options); + } + }; + // The stable codes of src/report/errors.ts. An app matches on these. private static final String E_REPORT_HANDLE_DEAD = "E_REPORT_HANDLE_DEAD"; private static final String E_REPORT_ATTACHMENT_REJECTED = "E_REPORT_ATTACHMENT_REJECTED"; @@ -309,16 +325,41 @@ public void testCrash() { Bugsee.testCrash(); } - // Task 7.1c — real bridge; stub for now so the TurboModule spec is complete. + /** + * A handled JS exception. Posted onto {@link ExceptionExecutors#HANDLED}: + * the SDK must not run on the native-modules thread. + */ @Override public void logException(final String payloadJson, final @Nullable String optionsJson) { - // no-op + ExceptionExecutors.HANDLED.execute(() -> { + try { + ExceptionBridge.logHandled(PROD_EXCEPTION_SDK, payloadJson, optionsJson); + Log.i(TAG, "exception handled sent bytes=" + + (payloadJson == null ? 0 : payloadJson.length())); + } catch (final RuntimeException e) { + Log.e(TAG, "logException failed", e); + } + }); } - // Task 7.1c — real bridge; stub resolves at once. + /** + * An unhandled JS exception. Posted onto {@link ExceptionExecutors#UNHANDLED}, + * not behind handled reports: JS waits at most 1500 ms. The promise + * resolves {@code null} exactly once in a {@code finally} after that + * background call returns, including when the SDK throws. + */ @Override public void logUnhandledException(final String payloadJson, final Promise promise) { - promise.resolve(null); + ExceptionExecutors.UNHANDLED.execute(() -> { + try { + ExceptionBridge.logUnhandled(PROD_EXCEPTION_SDK, payloadJson); + Log.i(TAG, "exception unhandled sent bytes=" + + (payloadJson == null ? 0 : payloadJson.length())); + Log.i(TAG, "exception unhandled completed"); + } finally { + promise.resolve(null); + } + }); } // The two-argument overload only -- severity and labels are Phase 8. JS diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ExceptionBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ExceptionBridge.java new file mode 100644 index 00000000..d44a8a00 --- /dev/null +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ExceptionBridge.java @@ -0,0 +1,118 @@ +package com.bugsee.reactnative; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import android.util.Log; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Turns a JS exception payload (and optional options JSON) into the SDK's + * {@code logException} / {@code logUnhandledException} calls. Plain Java so + * the rules here are unit-tested without React Native or a device. + * + *

Both send paths never throw: a bad options blob is logged (message only, + * never the text) and the exception is still sent without options; a throwing + * SDK is swallowed the same way every other void bridge method defends the + * host. + */ +final class ExceptionBridge { + + private static final String TAG = "BugseeRN"; + + /** What the module implements against; the production adapter calls {@code Bugsee.*}. */ + interface Sdk { + void logException(Throwable t, @Nullable Map options); + + void logUnhandledException(Throwable t, @Nullable Map options); + } + + private ExceptionBridge() { + } + + /** + * {@code domain} → {@link String}, {@code labels} → {@link List}{@code String}, + * {@code includeVideo} → {@link Boolean}; other keys dropped. {@code null} + * for null text. + * + * @throws BridgeJson.BadJson when the text is unparseable, or a known key + * has the wrong runtime type. + */ + @Nullable + static Map options(@Nullable final String optionsJson) throws BridgeJson.BadJson { + if (optionsJson == null) { + return null; + } + final Map raw = BridgeJson.parseObject(optionsJson); + final Map result = new HashMap<>(); + if (raw.containsKey("domain")) { + final Object domain = raw.get("domain"); + if (!(domain instanceof String)) { + throw new BridgeJson.BadJson("domain must be a string"); + } + result.put("domain", domain); + } + if (raw.containsKey("labels")) { + result.put("labels", stringList(raw.get("labels"))); + } + if (raw.containsKey("includeVideo")) { + final Object includeVideo = raw.get("includeVideo"); + if (!(includeVideo instanceof Boolean)) { + throw new BridgeJson.BadJson("includeVideo must be a boolean"); + } + result.put("includeVideo", includeVideo); + } + return result; + } + + /** + * Never throws. Bad options are logged (code only) and the exception is + * still sent, without options. + */ + static void logHandled( + @NonNull final Sdk sdk, + @NonNull final String payloadJson, + @Nullable final String optionsJson + ) { + Map opts = null; + try { + opts = options(optionsJson); + } catch (final BridgeJson.BadJson e) { + Log.e(TAG, "exception options rejected: " + e.getMessage()); + } + try { + sdk.logException(new ReactNativeWebException(payloadJson), opts); + } catch (final RuntimeException e) { + Log.e(TAG, "logException failed", e); + } + } + + /** Never throws. */ + static void logUnhandled(@NonNull final Sdk sdk, @NonNull final String payloadJson) { + try { + sdk.logUnhandledException(new ReactNativeWebException(payloadJson), null); + } catch (final RuntimeException e) { + Log.e(TAG, "logUnhandledException failed", e); + } + } + + @NonNull + private static List stringList(@Nullable final Object value) throws BridgeJson.BadJson { + if (!(value instanceof List)) { + throw new BridgeJson.BadJson("labels must be an array of strings"); + } + final List raw = (List) value; + final List result = new ArrayList<>(raw.size()); + for (final Object element : raw) { + if (!(element instanceof String)) { + throw new BridgeJson.BadJson("labels must be an array of strings"); + } + result.add((String) element); + } + return result; + } +} diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ExceptionExecutors.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ExceptionExecutors.java new file mode 100644 index 00000000..70ada06e --- /dev/null +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ExceptionExecutors.java @@ -0,0 +1,33 @@ +package com.bugsee.reactnative; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * Where {@code Bugsee.logException} and {@code logUnhandledException} run. + * + *

Android 7.3.0 blocks the caller for the view hierarchy. That caller must + * not be the React Native modules thread, or the hierarchy request cannot be + * answered. Handled and unhandled do not share a queue: JS waits at most + * 1500 ms for an unhandled report, and a handled backlog on one thread would + * spend that wait before the fatal call starts. + */ +final class ExceptionExecutors { + + /** Handled reports. A backlog here must not delay {@link #UNHANDLED}. */ + static final Executor HANDLED = singleThread("BugseeRN-exceptions"); + + /** Fatal reports. Starts even while {@link #HANDLED} is blocked in the SDK. */ + static final Executor UNHANDLED = singleThread("BugseeRN-fatal"); + + private ExceptionExecutors() { + } + + private static Executor singleThread(final String name) { + return Executors.newSingleThreadExecutor(r -> { + final Thread thread = new Thread(r, name); + thread.setDaemon(true); + return thread; + }); + } +} diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWebException.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWebException.java new file mode 100644 index 00000000..4bf9a7ad --- /dev/null +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactNativeWebException.java @@ -0,0 +1,19 @@ +package com.bugsee.reactnative; + +import androidx.annotation.NonNull; + +/** + * The backend routes React Native JS exceptions on this class name (worker: + * {@code 'ReactNativeWebException' in name}). {@code consumer-rules.pro} keeps + * the name through R8. Never rename or nest it. + * + *

Extends {@link Throwable} (not {@link Exception}): 6.x wrote + * {@code exception_type "throwable"} in {@code crash.json}, and the reason is + * the JSON payload verbatim in {@link #getMessage()}. + */ +final class ReactNativeWebException extends Throwable { + + ReactNativeWebException(@NonNull final String payloadJson) { + super(payloadJson); + } +} diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ExceptionBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ExceptionBridgeTest.java new file mode 100644 index 00000000..1516a3cf --- /dev/null +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ExceptionBridgeTest.java @@ -0,0 +1,172 @@ +package com.bugsee.reactnative; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import androidx.annotation.Nullable; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * The plain-Java exception bridge: class-name contract, options shape, and + * never-throwing send paths. No React Native, no device. + */ +public class ExceptionBridgeTest { + + private static final class RecordingSdk implements ExceptionBridge.Sdk { + final List calls = new ArrayList<>(); + @Nullable RuntimeException throwOnNext; + + static final class Call { + final String kind; + final Throwable throwable; + @Nullable final Map options; + + Call(final String kind, final Throwable throwable, @Nullable final Map options) { + this.kind = kind; + this.throwable = throwable; + this.options = options; + } + } + + @Override + public void logException(final Throwable t, @Nullable final Map options) { + calls.add(new Call("handled", t, options)); + if (throwOnNext != null) { + final RuntimeException e = throwOnNext; + throwOnNext = null; + throw e; + } + } + + @Override + public void logUnhandledException(final Throwable t, @Nullable final Map options) { + calls.add(new Call("unhandled", t, options)); + if (throwOnNext != null) { + final RuntimeException e = throwOnNext; + throwOnNext = null; + throw e; + } + } + } + + @Test + public void theClassNameIsTheBackendContract() { + assertEquals( + "com.bugsee.reactnative.ReactNativeWebException", + ReactNativeWebException.class.getName()); + assertTrue(ReactNativeWebException.class.getSimpleName().contains("ReactNativeWebException")); + } + + @Test + public void theReasonIsThePayloadVerbatim() { + // Non-BMP (U+1F600 grinning face) plus surrounding whitespace that must + // survive: the backend parses the reason as JSON after stripping a + // class-name prefix, so a trimmed or re-encoded message would break it. + final String payload = " {\"m\":\"\uD83D\uDE00\"} "; + final ReactNativeWebException ex = new ReactNativeWebException(payload); + assertEquals(payload, ex.getMessage()); + } + + @Test + public void handledGoesToLogExceptionWithItsOptions() throws Exception { + final RecordingSdk sdk = new RecordingSdk(); + final String payload = "{\"name\":\"Error\",\"message\":\"x\"}"; + final String optionsJson = "{\"domain\":\"auth\",\"labels\":[\"a\",\"b\"],\"includeVideo\":true}"; + ExceptionBridge.logHandled(sdk, payload, optionsJson); + assertEquals(1, sdk.calls.size()); + final RecordingSdk.Call call = sdk.calls.get(0); + assertEquals("handled", call.kind); + assertTrue(call.throwable instanceof ReactNativeWebException); + assertEquals(payload, call.throwable.getMessage()); + assertNotNull(call.options); + assertEquals("auth", call.options.get("domain")); + assertEquals(Arrays.asList("a", "b"), call.options.get("labels")); + assertEquals(Boolean.TRUE, call.options.get("includeVideo")); + } + + @Test + public void unhandledGoesToLogUnhandledExceptionWithoutOptions() { + final RecordingSdk sdk = new RecordingSdk(); + final String payload = "{\"name\":\"Error\",\"message\":\"fatal\"}"; + ExceptionBridge.logUnhandled(sdk, payload); + assertEquals(1, sdk.calls.size()); + final RecordingSdk.Call call = sdk.calls.get(0); + assertEquals("unhandled", call.kind); + assertTrue(call.throwable instanceof ReactNativeWebException); + assertEquals(payload, call.throwable.getMessage()); + assertNull(call.options); + } + + @Test + public void optionsMapDomainLabelsAndIncludeVideo() throws Exception { + final Map options = ExceptionBridge.options( + "{\"domain\":\"d\",\"labels\":[\"one\",\"two\"],\"includeVideo\":false}"); + assertNotNull(options); + assertEquals("d", options.get("domain")); + final Object labels = options.get("labels"); + assertTrue(labels instanceof List); + @SuppressWarnings("unchecked") + final List list = (List) labels; + assertEquals(Arrays.asList("one", "two"), list); + assertTrue(options.get("includeVideo") instanceof Boolean); + assertEquals(Boolean.FALSE, options.get("includeVideo")); + } + + @Test + public void unknownOptionKeysAreDropped() throws Exception { + final Map options = ExceptionBridge.options( + "{\"domain\":\"d\",\"skipFrames\":2,\"extra\":true}"); + assertNotNull(options); + assertEquals(Collections.singleton("domain"), options.keySet()); + assertEquals("d", options.get("domain")); + } + + @Test + public void nullOptionsTextIsNullOptions() throws Exception { + assertNull(ExceptionBridge.options(null)); + } + + @Test + public void unparseableOptionsStillSendTheException() { + final RecordingSdk sdk = new RecordingSdk(); + final String payload = "{\"name\":\"Error\",\"message\":\"still\"}"; + ExceptionBridge.logHandled(sdk, payload, "not-json"); + assertEquals(1, sdk.calls.size()); + final RecordingSdk.Call call = sdk.calls.get(0); + assertEquals("handled", call.kind); + assertEquals(payload, call.throwable.getMessage()); + assertNull(call.options); + } + + @Test + public void aWronglyTypedLabelIsABadJson() { + try { + ExceptionBridge.options("{\"labels\":[1,2]}"); + fail("expected BadJson for non-string labels"); + } catch (final BridgeJson.BadJson expected) { + // Reached: a number in labels is not a List. + } + } + + @Test + public void aThrowingSdkDoesNotEscape() { + final RecordingSdk sdk = new RecordingSdk(); + sdk.throwOnNext = new RuntimeException("handled boom"); + ExceptionBridge.logHandled(sdk, "{}", null); + assertEquals(1, sdk.calls.size()); + + sdk.throwOnNext = new RuntimeException("unhandled boom"); + ExceptionBridge.logUnhandled(sdk, "{}"); + assertEquals(2, sdk.calls.size()); + } +} diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ExceptionExecutorsTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ExceptionExecutorsTest.java new file mode 100644 index 00000000..c34528a9 --- /dev/null +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ExceptionExecutorsTest.java @@ -0,0 +1,40 @@ +package com.bugsee.reactnative; + +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * An unhandled report must start while a handled call is still blocked. JS + * gives up on the fatal path after 1500 ms. + */ +public class ExceptionExecutorsTest { + + @Test + public void unhandledDoesNotWaitForABlockedHandledCall() throws Exception { + final CountDownLatch handledRunning = new CountDownLatch(1); + final CountDownLatch releaseHandled = new CountDownLatch(1); + ExceptionExecutors.HANDLED.execute(() -> { + handledRunning.countDown(); + try { + releaseHandled.await(5, TimeUnit.SECONDS); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + try { + assertTrue(handledRunning.await(2, TimeUnit.SECONDS)); + final long started = System.nanoTime(); + final CountDownLatch unhandledRan = new CountDownLatch(1); + ExceptionExecutors.UNHANDLED.execute(unhandledRan::countDown); + assertTrue(unhandledRan.await(500, TimeUnit.MILLISECONDS)); + final long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + assertTrue("unhandled waited " + elapsedMs + " ms behind handled", elapsedMs < 1500); + } finally { + releaseHandled.countDown(); + } + } +} diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 302bf04e..575e3f4c 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -27,6 +27,8 @@ #import #import #import +#import +#import #else #import "BGSRNMainThread.h" #import "BGSRNWrapper.h" @@ -43,6 +45,8 @@ #import "BGSRNAttributes.h" #import "BGSRNDataRequestBridge.h" #import "BGSRNReactWindow.h" +#import "BGSRNExceptions.h" +#import "BGSRNSettleOnce.h" #endif /// The conformance lives here rather than in the Support package so that the @@ -559,17 +563,49 @@ - (void)testCrash { }); } -/// Task 7.1d — real bridge; stub for now so the TurboModule spec is complete. +/// A handled JS exception. `payloadJson` is the Task 7.1a payload, forwarded +/// verbatim as the reason; `optionsJson` is `{domain?, labels?, includeVideo?}` +/// or null. Unparseable options are logged and the exception is still sent +/// with nil options — a void method has no promise to reject. - (void)logException:(NSString *)payloadJson optionsJson:(NSString * _Nullable)optionsJson { - // no-op + BGSRNRunOnMain(^{ + NSError *error = nil; + BugseeExceptionLoggingOptions *opts = + [BGSRNExceptions loggingOptionsFromJSON:optionsJson error:&error]; + if (optionsJson != nil && opts == nil) { + NSLog(@"BugseeRN exception options unparseable: %@", error.localizedDescription); + } + [Bugsee logException:BGSRNReactNativeExceptionName + reason:payloadJson + options:opts + completion:nil]; + NSUInteger bytes = [payloadJson lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + NSLog(@"BugseeRN exception handled sent bytes=%lu", (unsigned long)bytes); + }); } -/// Task 7.1d — real bridge; stub resolves at once. +/// An unhandled JS exception. The call stores `override_report.plcrash`. +/// iOS SDK 7.0.0-beta3 `0d9c9d0a-9` claims only `live_report.plcrash` on the +/// next launch, so that report is not recovered. Do not copy one file onto +/// the other. The completion is wrapped in `BGSRNSettleOnce`: on the +/// simulator the SDK compiles `logUnhandledException` out and never calls +/// this completion (verified facts), and a promise must still settle. - (void)logUnhandledException:(NSString *)payloadJson resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - resolve(nil); + BGSRNRunOnMain(^{ + NSUInteger bytes = [payloadJson lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + NSLog(@"BugseeRN exception unhandled sent bytes=%lu", (unsigned long)bytes); + dispatch_block_t done = + BGSRNSettleOnce(BGSRNUnhandledCompletionDeadlineMs, dispatch_get_main_queue(), ^{ + NSLog(@"BugseeRN exception unhandled completed"); + resolve(nil); + }); + [Bugsee logUnhandledException:BGSRNReactNativeExceptionName + reason:payloadJson + completion:done]; + }); } /// The two-argument form only -- severity and labels are Phase 8. JS has diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNExceptions.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNExceptions.m new file mode 100644 index 00000000..08ce631d --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNExceptions.m @@ -0,0 +1,82 @@ +#import "BGSRNExceptions.h" + +#import "BGSRNJSON.h" + +NSString *const BGSRNReactNativeExceptionName = @"ReactNativeWebException"; +NSErrorDomain const BGSRNExceptionsErrorDomain = @"BGSRNExceptionsErrorDomain"; + +static id Fail(NSError **error, NSString *message) { + if (error != NULL) { + *error = [NSError errorWithDomain:BGSRNExceptionsErrorDomain + code:1 + userInfo:@{NSLocalizedDescriptionKey : message}]; + } + return nil; +} + +static BOOL IsCFBoolean(id value) { + return [value isKindOfClass:NSNumber.class] && + CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID(); +} + +@implementation BGSRNExceptions + ++ (BugseeExceptionLoggingOptions *)loggingOptionsFromJSON:(NSString *)json error:(NSError **)error { + if (json == nil) { + if (error != NULL) { + *error = nil; + } + return nil; + } + + NSError *parseError = nil; + NSDictionary *object = BGSRNJSONObject(json, &parseError); + if (object == nil) { + return Fail(error, parseError.localizedDescription ?: @"unparseable options JSON"); + } + + BugseeExceptionLoggingOptions *opts = [BugseeExceptionLoggingOptions new]; + // The SDK's own object defaults includeVideo to NO; JS's contract is YES + // unless the caller set false. Force it before reading the wire. + opts.includeVideo = YES; + + id domain = object[@"domain"]; + if (domain != nil) { + if (![domain isKindOfClass:NSString.class]) { + return Fail(error, @"domain must be a string"); + } + opts.exceptionDomain = (NSString *)domain; + } + + id labels = object[@"labels"]; + if (labels != nil) { + if (![labels isKindOfClass:NSArray.class]) { + return Fail(error, @"labels must be an array of strings"); + } + NSMutableArray *kept = [NSMutableArray array]; + for (id element in (NSArray *)labels) { + if (![element isKindOfClass:NSString.class]) { + return Fail(error, @"labels must be an array of strings"); + } + [kept addObject:element]; + } + opts.labels = kept; + } + + id includeVideo = object[@"includeVideo"]; + if (includeVideo != nil) { + if (!IsCFBoolean(includeVideo)) { + return Fail(error, @"includeVideo must be a boolean"); + } + opts.includeVideo = [(NSNumber *)includeVideo boolValue]; + } + + // Unknown keys (skipFrames, mergingRules, …) are ignored: JS already + // rejects them, and a future key must not refuse a report the facade sent. + if (error != NULL) { + *error = nil; + } + return opts; +} + +@end diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSettleOnce.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSettleOnce.m new file mode 100644 index 00000000..44de3137 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSettleOnce.m @@ -0,0 +1,62 @@ +#import "BGSRNSettleOnce.h" + +#import + +const int64_t BGSRNUnhandledCompletionDeadlineMs = 1500; + +dispatch_block_t BGSRNSettleOnceRaceWindow = nil; + +/// Shared by the returned trigger and the deadline block. Lives as long as +/// either capture does, so a late call after the other path already settled +/// never touches freed memory. +@interface BGSRNSettleOnceState : NSObject { + @public + atomic_bool once; +} +@property (nonatomic, copy) dispatch_block_t settle; +@end + +@implementation BGSRNSettleOnceState +@end + +static void RunRaceWindow(void) { + dispatch_block_t window = BGSRNSettleOnceRaceWindow; + if (window != nil) { + window(); + } +} + +dispatch_block_t BGSRNSettleOnce(int64_t deadlineMs, dispatch_queue_t queue, dispatch_block_t settle) { + if (settle == nil || queue == nil) { + return ^{ + }; + } + + BGSRNSettleOnceState *state = [BGSRNSettleOnceState new]; + atomic_init(&state->once, false); + state.settle = settle; + + void (^fire)(void) = ^{ + // Two windows so a test can park both callers before the load and again + // after it: a non-atomic once-flag then loses deterministically (both saw + // unset), while atomic_exchange still admits only one settler. + RunRaceWindow(); + const bool already = atomic_load(&state->once); + RunRaceWindow(); + if (already) { + return; + } + if (atomic_exchange(&state->once, true)) { + return; + } + state.settle(); + }; + + if (deadlineMs > 0) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, deadlineMs * (int64_t)NSEC_PER_MSEC), queue, ^{ + fire(); + }); + } + + return [fire copy]; +} diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNExceptions.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNExceptions.h new file mode 100644 index 00000000..4b411148 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNExceptions.h @@ -0,0 +1,25 @@ +#import +// Not `@import Bugsee;`: this header is included from ObjC++, where C++ modules +// are off on both delivery paths and a module import is a hard error. +#import + +NS_ASSUME_NONNULL_BEGIN + +/// The backend routes React Native JS exceptions on this exact substring +/// (`'ReactNativeWebException' in name`). Never rename it. +FOUNDATION_EXPORT NSString *const BGSRNReactNativeExceptionName; + +FOUNDATION_EXPORT NSErrorDomain const BGSRNExceptionsErrorDomain; + +@interface BGSRNExceptions : NSObject + +/// `{domain?, labels?, includeVideo?}` as SDK options: domain → +/// `exceptionDomain`; `includeVideo` defaults to YES (the SDK's own object +/// defaults to NO). nil for nil text. Unparseable text or a wrongly typed +/// value → nil and `*error`. ++ (nullable BugseeExceptionLoggingOptions *)loggingOptionsFromJSON:(nullable NSString *)json + error:(NSError *_Nullable *_Nullable)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSettleOnce.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSettleOnce.h new file mode 100644 index 00000000..51b62c35 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSettleOnce.h @@ -0,0 +1,25 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Matches JS `UNHANDLED_REPORT_WAIT_MS`: the most the fatal path waits for +/// native before RN's handler runs. On the simulator the SDK never calls the +/// `logUnhandledException` completion (verified facts), so the promise must +/// still settle by this deadline. +FOUNDATION_EXPORT const int64_t BGSRNUnhandledCompletionDeadlineMs; + +/// Runs `settle` exactly once: when the returned block is first called, or +/// after `deadlineMs` on `queue`, whichever comes first. The once-flag is +/// atomic: the SDK may call back on any thread. +FOUNDATION_EXPORT dispatch_block_t BGSRNSettleOnce(int64_t deadlineMs, + dispatch_queue_t queue, + dispatch_block_t settle); + +/// Test-only: runs twice on every claim attempt — once before the once-flag +/// load and once after it. Nil in production is a no-op. The concurrent settle +/// test parks both callers at each phase so a non-atomic once-flag loses +/// deterministically (both saw unset) while `atomic_exchange` still admits +/// only one settler. +FOUNDATION_EXPORT _Nullable dispatch_block_t BGSRNSettleOnceRaceWindow; + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNExceptionsTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNExceptionsTests.m new file mode 100644 index 00000000..21bbe0a7 --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNExceptionsTests.m @@ -0,0 +1,84 @@ +@import XCTest; +@import Bugsee; +@import BugseeRNSupport; + +/// Pins the iOS half of the exception-options wire shape and the backend +/// exception name contract. Mirrors the brief's case names. +@interface BGSRNExceptionsTests : XCTestCase +@end + +@implementation BGSRNExceptionsTests + +- (BugseeExceptionLoggingOptions *)optionsFrom:(NSString *_Nullable)json { + NSError *error = nil; + BugseeExceptionLoggingOptions *opts = [BGSRNExceptions loggingOptionsFromJSON:json error:&error]; + XCTAssertNotNil(opts, @"rejected %@: %@", json, error); + XCTAssertNil(error); + return opts; +} + +- (void)assertBad:(NSString *_Nullable)json { + NSError *error = nil; + XCTAssertNil([BGSRNExceptions loggingOptionsFromJSON:json error:&error], @"accepted %@", json); + XCTAssertNotNil(error, @"no error for %@", json); + XCTAssertEqualObjects(error.domain, BGSRNExceptionsErrorDomain); +} + +- (void)testTheNameIsTheBackendContract { + XCTAssertEqualObjects(BGSRNReactNativeExceptionName, @"ReactNativeWebException"); +} + +- (void)testDomainBecomesExceptionDomain { + BugseeExceptionLoggingOptions *opts = [self optionsFrom:@"{\"domain\":\"auth\"}"]; + XCTAssertEqualObjects(opts.exceptionDomain, @"auth"); +} + +- (void)testIncludeVideoDefaultsToYes { + BugseeExceptionLoggingOptions *empty = [self optionsFrom:@"{}"]; + XCTAssertTrue(empty.includeVideo, @"SDK defaults to NO; the wrapper must force YES"); + + BugseeExceptionLoggingOptions *domainOnly = [self optionsFrom:@"{\"domain\":\"x\"}"]; + XCTAssertTrue(domainOnly.includeVideo); +} + +- (void)testIncludeVideoFalseIsKept { + BugseeExceptionLoggingOptions *opts = [self optionsFrom:@"{\"includeVideo\":false}"]; + XCTAssertFalse(opts.includeVideo); +} + +- (void)testLabelsAreKept { + BugseeExceptionLoggingOptions *opts = [self optionsFrom:@"{\"labels\":[\"a\",\"b\"]}"]; + XCTAssertEqualObjects(opts.labels, (@[ @"a", @"b" ])); +} + +- (void)testNilJsonIsNilOptions { + NSError *error = nil; + XCTAssertNil([BGSRNExceptions loggingOptionsFromJSON:nil error:&error]); + XCTAssertNil(error, @"nil text is not an error; it is no options"); +} + +- (void)testUnparseableJsonIsAnError { + [self assertBad:@"{\"domain\":"]; + [self assertBad:@"[]"]; + [self assertBad:@"\"s\""]; + [self assertBad:@""]; + [self assertBad:@"not json"]; +} + +- (void)testANonStringLabelIsAnError { + [self assertBad:@"{\"labels\":[\"a\",1]}"]; + [self assertBad:@"{\"labels\":\"a\"}"]; + [self assertBad:@"{\"domain\":3}"]; + [self assertBad:@"{\"includeVideo\":1}"]; +} + +- (void)testUnknownKeysAreIgnored { + BugseeExceptionLoggingOptions *opts = + [self optionsFrom:@"{\"domain\":\"d\",\"skipFrames\":2,\"mergingRules\":{},\"extra\":true}"]; + XCTAssertEqualObjects(opts.exceptionDomain, @"d"); + XCTAssertTrue(opts.includeVideo); + XCTAssertNil(opts.labels); + XCTAssertNil(opts.mergingRules); +} + +@end diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSettleOnceTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSettleOnceTests.m new file mode 100644 index 00000000..c20217ab --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSettleOnceTests.m @@ -0,0 +1,134 @@ +@import XCTest; +@import BugseeRNSupport; + +#import + +/// Pins `BGSRNSettleOnce`: a promise-returning native method must settle +/// exactly once even when the SDK never calls its completion (iOS simulator +/// verified fact for `logUnhandledException`). +@interface BGSRNSettleOnceTests : XCTestCase +@end + +@implementation BGSRNSettleOnceTests + +- (void)tearDown { + BGSRNSettleOnceRaceWindow = nil; + [super tearDown]; +} + +- (void)testSettlesOnceWhenCalled { + XCTestExpectation *settled = [self expectationWithDescription:@"settle ran"]; + __block NSInteger count = 0; + + dispatch_queue_t queue = dispatch_queue_create("com.bugsee.rn.settle-once.test", DISPATCH_QUEUE_SERIAL); + dispatch_block_t trigger = BGSRNSettleOnce(5000, queue, ^{ + count += 1; + [settled fulfill]; + }); + + trigger(); + trigger(); + + [self waitForExpectations:@[ settled ] timeout:2]; + XCTAssertEqual(count, 1); + XCTAssertEqual(BGSRNUnhandledCompletionDeadlineMs, 1500); +} + +- (void)testSettlesAtTheDeadlineWhenNeverCalled { + XCTestExpectation *settled = [self expectationWithDescription:@"deadline settle"]; + __block NSInteger count = 0; + + dispatch_queue_t queue = dispatch_queue_create("com.bugsee.rn.settle-once.deadline", DISPATCH_QUEUE_SERIAL); + (void)BGSRNSettleOnce(50, queue, ^{ + count += 1; + [settled fulfill]; + }); + + [self waitForExpectations:@[ settled ] timeout:2]; + XCTAssertEqual(count, 1); +} + +- (void)testACallAfterTheDeadlineDoesNothing { + XCTestExpectation *settled = [self expectationWithDescription:@"deadline only"]; + __block NSInteger count = 0; + + dispatch_queue_t queue = dispatch_queue_create("com.bugsee.rn.settle-once.after", DISPATCH_QUEUE_SERIAL); + dispatch_block_t trigger = BGSRNSettleOnce(50, queue, ^{ + count += 1; + [settled fulfill]; + }); + + [self waitForExpectations:@[ settled ] timeout:2]; + XCTAssertEqual(count, 1); + + trigger(); + // Give any late call a turn on the queue; count must stay 1. + XCTestExpectation *quiet = [self expectationWithDescription:@"quiet after"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(100 * NSEC_PER_MSEC)), queue, ^{ + [quiet fulfill]; + }); + [self waitForExpectations:@[ quiet ] timeout:2]; + XCTAssertEqual(count, 1); +} + +/// Two threads share a two-phase barrier around the once-flag load. An atomic +/// exchange still settles once; a plain `BOOL` lets both through. The barrier +/// makes that race deterministic rather than relying on luck. +/// +/// `settle` runs on the caller threads, not the serial deadline queue, so the +/// counter is atomic: a non-atomic `count += 1` can lose a double-settle and +/// leave mutate (3) green while settling twice. +- (void)testTwoConcurrentCallsSettleOnce { + __block atomic_int count = 0; + dispatch_queue_t queue = dispatch_queue_create("com.bugsee.rn.settle-once.race", DISPATCH_QUEUE_SERIAL); + // No deadline. A positive deadline is a third caller into this two-party + // barrier, and on a slow CI runner it fires while the two threads are still + // parked and steals a `go` signal. The threads then never leave the group. + dispatch_block_t trigger = BGSRNSettleOnce(0, queue, ^{ + atomic_fetch_add_explicit(&count, 1, memory_order_relaxed); + }); + + // One pair per window. A shared `go` lets the faster caller leave the + // first window, enter the second, and consume the other caller's release. + // That caller then never reaches the second window (CI: caller 3 timed out). + dispatch_semaphore_t entered0 = dispatch_semaphore_create(0); + dispatch_semaphore_t entered1 = dispatch_semaphore_create(0); + dispatch_semaphore_t release0 = dispatch_semaphore_create(0); + dispatch_semaphore_t release1 = dispatch_semaphore_create(0); + BGSRNSettleOnceRaceWindow = ^{ + static _Thread_local int phase = 0; + const int step = phase++; + dispatch_semaphore_signal(step == 0 ? entered0 : entered1); + dispatch_semaphore_wait(step == 0 ? release0 : release1, DISPATCH_TIME_FOREVER); + }; + + dispatch_group_t group = dispatch_group_create(); + for (int i = 0; i < 2; i++) { + dispatch_group_async(group, dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + trigger(); + }); + } + + // Window 0: both arrive before the load. Window 1: both have loaded. + // A finite wait: DISPATCH_TIME_FOREVER here never returns if a caller fails + // to arrive, and xcodebuild then sits until the job is killed. The deadline + // is per window so the first window cannot consume the second's budget. + dispatch_semaphore_t entered[2] = {entered0, entered1}; + dispatch_semaphore_t release[2] = {release0, release1}; + for (int phase = 0; phase < 2; phase++) { + const dispatch_time_t arrived = dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC); + XCTAssertEqual(dispatch_semaphore_wait(entered[phase], arrived), 0, + @"caller 0 did not reach window %d", phase); + XCTAssertEqual(dispatch_semaphore_wait(entered[phase], arrived), 0, + @"caller 1 did not reach window %d", phase); + dispatch_semaphore_signal(release[phase]); + dispatch_semaphore_signal(release[phase]); + } + + XCTAssertEqual(dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC)), 0, + @"concurrent callers did not finish"); + XCTAssertEqual(atomic_load_explicit(&count, memory_order_relaxed), 1, + @"two concurrent calls settled more than once"); +} + +@end diff --git a/scripts/__tests__/android-keep-rules.test.ts b/scripts/__tests__/android-keep-rules.test.ts new file mode 100644 index 00000000..8668d810 --- /dev/null +++ b/scripts/__tests__/android-keep-rules.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const repo = join(__dirname, '..', '..'); +const android = (...p: string[]) => + readFileSync(join(repo, 'packages', 'react-native', 'android', ...p), 'utf8'); + +describe('Android keep rules for ReactNativeWebException', () => { + it('consumer-rules.pro keeps ReactNativeWebException by name', () => { + const rules = android('consumer-rules.pro'); + expect(rules).toContain( + '-keepnames class com.bugsee.reactnative.ReactNativeWebException', + ); + }); + + it("the library's build.gradle declares consumer-rules.pro", () => { + const gradle = android('build.gradle'); + expect(gradle).toMatch(/consumerProguardFiles\s+['"]consumer-rules\.pro['"]/); + }); + + it('ReactNativeWebException is a top-level class in com.bugsee.reactnative', () => { + const src = android( + 'src', + 'main', + 'java', + 'com', + 'bugsee', + 'reactnative', + 'ReactNativeWebException.java', + ); + expect(src).toMatch(/^package com\.bugsee\.reactnative;/m); + // Top-level: the file declares the class, not a nested Static member of + // another type. The backend routes on this exact FQCN. + expect(src).toMatch( + /(?:^|\n)(?:public\s+|final\s+|public\s+final\s+|final\s+public\s+)*class\s+ReactNativeWebException\b/, + ); + expect(src).not.toMatch( + /class\s+\w+\s*\{[\s\S]*class\s+ReactNativeWebException\b/, + ); + }); +}); diff --git a/scripts/__tests__/e2e-capture-files.test.ts b/scripts/__tests__/e2e-capture-files.test.ts index 572dc48c..50264ec5 100644 --- a/scripts/__tests__/e2e-capture-files.test.ts +++ b/scripts/__tests__/e2e-capture-files.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { captureEvents, parseBundle } from '../../examples/bare/e2e/bundles'; +import { captureEvents, crashOf, parseBundle } from '../../examples/bare/e2e/bundles'; /** * The device e2e reads a retained bundle's capture files -- `log`, @@ -60,6 +60,23 @@ describe('bundle capture files', () => { expect(bundle.captures.has('video')).toBe(false); }); + it('crashOf parses the crash capture', () => { + const crash = '{"handled":true,"exception":{"name":"com.bugsee.reactnative.ReactNativeWebException","reason":"{\\"name\\":\\"TypeError\\"}"}}'; + writeManifest([{ type: 'crash', filename: 'crash.json' }]); + writeFileSync(join(dir, 'crash.json'), crash); + + const bundle = parseBundle('x.bundle.zip', dir); + + expect(crashOf(bundle)).toEqual(JSON.parse(crash)); + }); + + it('crashOf is undefined without a crash capture', () => { + writeManifest([{ type: 'log', filename: 'log.json' }]); + writeFileSync(join(dir, 'log.json'), '{"version":1,"events":[]}'); + + expect(crashOf(parseBundle('x.bundle.zip', dir))).toBeUndefined(); + }); + it('captureEvents is empty for a type the manifest lacks', () => { writeManifest([{ type: 'log', filename: 'log.json' }]); writeFileSync(join(dir, 'log.json'), '{"version":1,"events":[{"message":"m"}]}'); diff --git a/scripts/__tests__/e2e-metro-port.test.ts b/scripts/__tests__/e2e-metro-port.test.ts index f07e62a7..3acb515a 100644 --- a/scripts/__tests__/e2e-metro-port.test.ts +++ b/scripts/__tests__/e2e-metro-port.test.ts @@ -33,7 +33,31 @@ describe('E2E_METRO_PORT', () => { }); it("tells the simulator app the port through React Native's RCT_jsLocation", () => { - expect(metroArgs('8082')).toEqual(['-RCT_jsLocation', 'localhost:8082']); + const saved = process.env.E2E_METRO_HOST; + delete process.env.E2E_METRO_HOST; + try { + expect(metroArgs('8082')).toEqual(['-RCT_jsLocation', 'localhost:8082']); + } finally { + if (saved === undefined) { + delete process.env.E2E_METRO_HOST; + } else { + process.env.E2E_METRO_HOST = saved; + } + } + }); + + it('a physical iPhone is told E2E_METRO_HOST with the port', () => { + const saved = process.env.E2E_METRO_HOST; + process.env.E2E_METRO_HOST = '10.1.2.3'; + try { + expect(metroArgs('8082')).toEqual(['-RCT_jsLocation', '10.1.2.3:8082']); + } finally { + if (saved === undefined) { + delete process.env.E2E_METRO_HOST; + } else { + process.env.E2E_METRO_HOST = saved; + } + } }); it('refuses a bad port rather than launching against it', () => { diff --git a/scripts/__tests__/run-ios-configuration.test.ts b/scripts/__tests__/run-ios-configuration.test.ts new file mode 100644 index 00000000..68324474 --- /dev/null +++ b/scripts/__tests__/run-ios-configuration.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const scriptPath = join(__dirname, '..', '..', 'examples', 'bare', 'scripts', 'run-ios.sh'); +const script = readFileSync(scriptPath, 'utf8'); + +/** Drop shell comments so prose about Debug is not read as the rule. */ +function code(source: string): string { + return source + .split('\n') + .filter(line => !/^\s*#/.test(line)) + .join('\n'); +} + +describe('run-ios.sh IOS_CONFIGURATION', () => { + it('the embed check follows IOS_CONFIGURATION', () => { + const body = code(script); + expect(body).toMatch(/CONFIGURATION="\$\{IOS_CONFIGURATION:-Debug\}"/); + expect(body).toMatch( + /APP="ios\/build\/Build\/Products\/\$\{CONFIGURATION\}-iphoneos\/BareExample\.app"/, + ); + expect(body).toMatch(/-configuration "\$CONFIGURATION"/); + // The embed assertion must receive that same APP, not a hard-coded Debug path. + expect(body).toMatch(/cli-assert-framework-embedded\.ts "\$APP"/); + expect(body).not.toMatch(/APP="ios\/build\/Build\/Products\/Debug-iphoneos/); + }); +});