From 30e96e782cb2cbaf6f78bf99b328cd5ee2e44e27 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 16:33:27 +0500 Subject: [PATCH 1/7] build(android): adopt Bugsee Android SDK 7.3.1 and Gradle plugin 4.0.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit native-versions.json pins android.sdk 7.3.1 and gradlePlugin 4.0.8; the bare example's plugins block, the baked plugin versions, the regenerated Android options manifest (only sdkVersion/generatedAt change), the single-source tests and the design/plan version lines follow. Android pins flipped for the bugs 7.3.1 fixes: - lifecycle itDelete: plain it on Android (#214 via #222); iOS stays (cocoa#195) - feedback itGreeting: plain it (#215 via #225) - exceptions case 10: plain it (unhandled files a crash only, #192) - launch-options: environment.sdk.options records enums by value (#216 via #223), so the frame-rate and video-mode witnesses expect 1/3/21 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/2026-09-15-sdk-design.md | 4 +-- .../plans/2026-09-16-implementation-plan.md | 2 +- examples/bare/android/build.gradle | 4 +-- examples/bare/e2e/exceptions.test.ts | 17 ++++++---- examples/bare/e2e/feedback.test.ts | 9 +++--- examples/bare/e2e/launch-options.test.ts | 32 +++++++++---------- examples/bare/e2e/lifecycle.test.ts | 18 +++++------ examples/bare/e2e/smoke.test.ts | 3 +- native-versions.json | 4 +-- .../src/__tests__/pins.test.ts | 2 +- .../plugin/build/native-versions.baked.json | 4 +-- .../src/options/android-options-manifest.json | 4 +-- scripts/__tests__/native-versions.test.ts | 4 +-- 13 files changed, 54 insertions(+), 53 deletions(-) diff --git a/docs/design/2026-09-15-sdk-design.md b/docs/design/2026-09-15-sdk-design.md index 0306325c..8d8381ec 100644 --- a/docs/design/2026-09-15-sdk-design.md +++ b/docs/design/2026-09-15-sdk-design.md @@ -14,7 +14,7 @@ The single most important finding behind this design: **iOS 7.x and Android 7.x ## 2. Goals -- Ship a 7.x-native React Native SDK for the two current native lines: Android **7.3.0** (pinned transitionally as `7.3.0-SNAPSHOT` while the release is pending โ€” see the plan's Phase 3 rulings) and iOS **7.0.0-beta5** (SPM only, iOS deployment target 15.0). +- Ship a 7.x-native React Native SDK for the two current native lines: Android **7.3.1** (7.3.0 first, pinned transitionally as `7.3.0-SNAPSHOT` while that release was pending โ€” see the plan's Phase 3 rulings; 7.3.1 fixes bugsee-android #214โ€“#221 found by the beta campaign) and iOS **7.0.0-beta5** (SPM only, iOS deployment target 15.0). - Expose the capabilities 7.x added and 6.x never had: breadcrumbs, notification relay, APM, user identity, hang/HTTP-error/frustration/anomaly detection, SDK status, report handlers. - Make the wrapper's option surface **provably** consistent with the native SDKs, enforced in CI rather than by review. - Work on bare React Native and on Expo, including Expo apps that regenerate native projects with `prebuild`. @@ -422,7 +422,7 @@ Four implementation constraints: - iOS Debug builds skip the upload unless `BUGSEE_UPLOAD_DEBUG_SOURCEMAPS=true`. - **`uploadSymbols`** โ€” default on, both platforms. - iOS: `false` removes the Archive dSYM post-action. - - Android: `false` disables the Gradle plugin's `uploadBugsee*` tasks (R8 mapping, NDK symbols, build info) in a marked block, because Gradle plugin 4.0.7 has no switch of its own. + - Android: `false` disables the Gradle plugin's `uploadBugsee*` tasks (R8 mapping, NDK symbols, build info) in a marked block, because the Gradle plugin (4.0.7, and still 4.0.8) has no switch of its own. - **`nativeCrashReporting`** โ€” default on (ยง11.1). - **`gradlePluginVersion`** โ€” default: the pin in `native-versions.json`. A `--no-clean` prebuild rewrites an older pin in place. - **`autoLaunch`** โ€” default off. Writes the manifest token for a real token only. diff --git a/docs/design/plans/2026-09-16-implementation-plan.md b/docs/design/plans/2026-09-16-implementation-plan.md index 1dabcf77..4cec75d8 100644 --- a/docs/design/plans/2026-09-16-implementation-plan.md +++ b/docs/design/plans/2026-09-16-implementation-plan.md @@ -17,7 +17,7 @@ Every phase's requirements implicitly include this section. - React Native floor **0.81.0**, New Architecture required. No `oldarch` source set, no legacy bridge fallback. 0.81 still allows opting out of the New Architecture; that is a documented requirement on the consumer, not a second code path. Hard technical floor is 0.80 (`codegenConfig.ios.modulesProvider`). -- Android SDK **7.3.0** (pinned transitionally as `7.3.0-SNAPSHOT` while the release is pending โ€” see Phase 3's rulings), Gradle plugin **4.0.7**, pinned exactly. Neither older pin carries what this wrapper needs: 7.2.0 has no wrapper channel, report-contract methods, `BugseeReportHandlerThread` or `com.bugsee.option.$$WRAPPER` consumer, and 4.0.6 strips every extension's provider (workbook 1.4). The plugin marker resolves from **Maven Central**, not the Gradle Plugin Portal. +- Android SDK **7.3.1** (7.3.0 first, pinned transitionally as `7.3.0-SNAPSHOT` while that release was pending โ€” see Phase 3's rulings; 7.3.1 fixes bugsee-android #214โ€“#221), Gradle plugin **4.0.8** (4.0.7 first; 4.0.8 uploads native symbols from the unstripped `merged_native_libs`, independent of `debugSymbolLevel`, with a bugsee-cli floor of 0.8.1), pinned exactly. Neither older pin carries what this wrapper needs: 7.2.0 has no wrapper channel, report-contract methods, `BugseeReportHandlerThread` or `com.bugsee.option.$$WRAPPER` consumer, and 4.0.6 strips every extension's provider (workbook 1.4). The plugin marker resolves from **Maven Central**, not the Gradle Plugin Portal. - iOS SDK **7.0.0-beta5** from `https://github.com/bugsee/spm`, requirement `exact`. SwiftPM will not admit a prerelease into a range. iOS deployment target **15.0**. - Package `@bugsee/react-native`. `toSwiftName` maps it to `ReactNative`, which is **reserved**, so `react-native.config.js` pins `spm: { name: 'BugseeReactNative' }` and the SPM product name must match exactly. - All native versions live in **one** file, `native-versions.json`, consumed by the podspec, `Package.swift` and the Gradle module. The SPM pin and podspec URL must never disagree. diff --git a/examples/bare/android/build.gradle b/examples/bare/android/build.gradle index 7e062886..8442bc85 100644 --- a/examples/bare/android/build.gradle +++ b/examples/bare/android/build.gradle @@ -30,7 +30,7 @@ plugins { // 'com.android.library' first!", since it hangs its tasks off an Android // variant. The token file still belongs to this directory, the root // project, not to app/. - id 'com.bugsee.android.gradle' version '4.0.7' apply false + id 'com.bugsee.android.gradle' version '4.0.8' apply false } // The token lives in bugsee.properties next to this file โ€” the *root* Gradle @@ -40,7 +40,7 @@ plugins { def nativeVersions = new groovy.json.JsonSlurper().parse(file("../../../native-versions.json")) def pinned = nativeVersions.android.gradlePlugin -def applied = '4.0.7' +def applied = '4.0.8' if (pinned != applied) { throw new GradleException( "Bugsee Gradle plugin is pinned to ${pinned} in native-versions.json " + diff --git a/examples/bare/e2e/exceptions.test.ts b/examples/bare/e2e/exceptions.test.ts index d1844b55..c2de618d 100644 --- a/examples/bare/e2e/exceptions.test.ts +++ b/examples/bare/e2e/exceptions.test.ts @@ -28,8 +28,10 @@ * 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. + * Case 7 is `it.failing` on Android for a documented gap (labels ignored, + * still in 7.3.1). Do not weaken it. Case 10 was `it.failing` on 7.3.0, which + * also filed an error report for an unhandled exception; 7.3.1 files the + * crash only (bugsee-android d390b5203, #192), so it is a plain `it`. * 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 @@ -489,8 +491,9 @@ describeDevice(`JS exceptions on ${TARGET_NAME}`, () => { 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. + // 7.3.0 also filed an error report for the same incident (case 10). + // Wait for at least the crash, then a short beat so an extra report, + // if one is filed, is seen by case 10. await awaitBundles(1, 30_000); await new Promise(resolve => setTimeout(resolve, 3_000)); bundles = await pullAndroidBundles(); @@ -514,7 +517,7 @@ describeDevice(`JS exceptions on ${TARGET_NAME}`, () => { expect(alive).toBe(true); }); - itAndroid.failing( + itAndroid( 'the fatal error files no second report for the incident', () => { const others = bundles.filter(b => { @@ -798,8 +801,8 @@ describeDevice(`JS exceptions on ${TARGET_NAME}`, () => { 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. + // Release retains one bundle for the incident (debug case 10 asserts the + // same since 7.3.1 stopped filing an error report next to the crash). itAndroid('exactly one bundle contains the fatal reason', () => { const withFatal = bundles.filter(b => rawCrashContains(b, `E2E fatal ${nonce}`)); expect(withFatal).toHaveLength(1); diff --git a/examples/bare/e2e/feedback.test.ts b/examples/bare/e2e/feedback.test.ts index 1c4a6aa2..dfdb17c3 100644 --- a/examples/bare/e2e/feedback.test.ts +++ b/examples/bare/e2e/feedback.test.ts @@ -44,12 +44,11 @@ const CHAT_BACKGROUND = '#112233'; */ const itColour = ON_IOS ? it.failing : it; /** - * Android 7.3.0 stores the greeting (`setDefaultFeedbackGreeting` writes the - * feedback preferences' `greeting`) and nothing ever reads it back: the chat - * opens empty, offline. iOS shows it. Pinned until the Android SDK renders it. - * Filed: https://github.com/bugsee/bugsee-android/issues/215 + * Android 7.3.0 stored the greeting and never read it back: the chat opened + * empty (bugsee-android #215). 7.3.1 shows it in an empty chat (#225), as iOS + * does. */ -const itGreeting = ON_ANDROID ? it.failing : it; +const itGreeting = it; describeDevice(`the feedback chat on ${TARGET_NAME}`, () => { let log: DeviceLog | undefined; diff --git a/examples/bare/e2e/launch-options.test.ts b/examples/bare/e2e/launch-options.test.ts index 0e9531b6..77c65016 100644 --- a/examples/bare/e2e/launch-options.test.ts +++ b/examples/bare/e2e/launch-options.test.ts @@ -22,13 +22,13 @@ * IssueSeverity.Critical 4 (values()[4] is Blocker) / VeryLow 1 / Medium 2 * (js-crash.test.ts), VideoMode.None 0 and DirectBuffers 21 (no ordinal 21). * - * Android 7.3.0 writes the options in effect into every report, - * `environment.sdk.options`, keys with `:` for `.` -- and writes an enum - * option as its constant's ORDINAL (IssueSeverity.Critical, value 4, is - * written 3). That record is the only device-visible witness for the frame - * rate (frames depend on what the screen does), so `androidRecorded` reads - * it with that rule stated. Filed (bug 10): - * https://github.com/bugsee/bugsee-android/issues/216 + * Android writes the options in effect into every report, + * `environment.sdk.options`, keys with `:` for `.`. 7.3.0 wrote an enum option + * as its constant's ORDINAL (IssueSeverity.Critical, value 4, was written 3; + * bugsee-android #216); 7.3.1 writes its value (#223). That record is the only + * device-visible witness for the frame rate (frames depend on what the screen + * does), so `androidRecorded` reads it. The values chosen above still differ + * from their ordinals, so a regression to ordinals fails these cases. * * Options with no effect a device can observe without a human, a backend or * hardware are listed in the beta-coverage report, not here. @@ -109,8 +109,8 @@ function hasLine(outcome: Outcome, text: string): boolean { } /** - * An option as Android 7.3.0 recorded it in the report: dots become colons, - * and an enum is written as its constant's ordinal (see the top of the file). + * An option as Android recorded it in the report: dots become colons, and + * (since 7.3.1) an enum is written as its value (see the top of the file). */ function androidRecorded(bundle: PulledBundle, key: string): unknown { const sdk = (bundle.request.environment as { sdk?: { options?: Record } }).sdk; @@ -234,8 +234,8 @@ const CASES: readonly Case[] = [ set: { 'com.bugsee.option.capture.video.frame-rate': 1 }, effect: async (outcome, control) => { if (ON_ANDROID) { - // FrameRate.Low is ordinal 0; values()[1] would be Medium (1). - expect(androidRecorded(the(outcome), 'com.bugsee.option.capture.video.frame-rate')).toBe(0); + // FrameRate.Low is value 1 (ordinal 0); values()[1] would be Medium. + expect(androidRecorded(the(outcome), 'com.bugsee.option.capture.video.frame-rate')).toBe(1); } else { const low = await frameCount(file(the(outcome), 'video')); const normal = await frameCount(file(the(control), 'video')); @@ -248,9 +248,9 @@ const CASES: readonly Case[] = [ name: 'frame-rate-high', platforms: ['android'], set: { 'com.bugsee.option.capture.video.frame-rate': 3 }, - // FrameRate.High is ordinal 2; values()[3] would be Raw (3). + // FrameRate.High is value 3 (ordinal 2); values()[3] would be Raw. effect: outcome => { - expect(androidRecorded(the(outcome), 'com.bugsee.option.capture.video.frame-rate')).toBe(2); + expect(androidRecorded(the(outcome), 'com.bugsee.option.capture.video.frame-rate')).toBe(3); }, }, { @@ -328,10 +328,10 @@ const CASES: readonly Case[] = [ name: 'video-mode-direct', platforms: ['android'], set: { 'com.bugsee.option.capture.video.mode': 21 }, - // DirectBuffers is ordinal 4; an ordinal lookup has nothing at 21 and - // would leave the default, V2 (ordinal 2). + // DirectBuffers is value 21 (ordinal 4); an ordinal lookup has nothing at + // 21 and would leave the default, V2. effect: async outcome => { - expect(androidRecorded(the(outcome), 'com.bugsee.option.capture.video.mode')).toBe(4); + expect(androidRecorded(the(outcome), 'com.bugsee.option.capture.video.mode')).toBe(21); expect(await probeCodec(file(the(outcome), 'video'))).toBe('h264'); }, }, diff --git a/examples/bare/e2e/lifecycle.test.ts b/examples/bare/e2e/lifecycle.test.ts index fc2923c9..8ebb3d4e 100644 --- a/examples/bare/e2e/lifecycle.test.ts +++ b/examples/bare/e2e/lifecycle.test.ts @@ -20,7 +20,7 @@ * removes every generation's parts and keeps `bundles/`. */ import { type PulledBundle } from './bundles'; -import { type Run, TARGET_NAME, awaitBundles, describeDevice, listBundles, must, report, startRun } from './harness'; +import { ON_IOS, type Run, TARGET_NAME, awaitBundles, describeDevice, listBundles, must, report, startRun } from './harness'; import { beginRetainingSuite, captureGenerationFiles, endRetainingSuite } from './observe'; import { type DeviceLog, type LogLine } from './scenario'; @@ -111,18 +111,16 @@ describeDevice(`stop, status, lifecycle events and data deletion on ${TARGET_NAM }); /** - * Android 7.3.0: deleteCaptureDataOnDisk calls - * `removeOtherGenerationsFilesSync(-1)`, which keeps every generation - * numbered `>= -1` -- all of them (they are timestamps) -- and resolves - * true. Seen on the WOD_LX1: 30 part files before, 69 after "deleted". + * Android 7.3.0 called `removeOtherGenerationsFilesSync(-1)`, which kept + * every generation (30 part files before, 69 after "deleted", WOD_LX1); + * 7.3.1 removes every generation (bugsee-android #214, fixed by #222), so + * this is a plain `it` there. * iOS 7.0.0-beta4: `clearGenerationsFolder` empties the legacy generations * folder, which the nextgen capture no longer writes; the parts under - * `capture/generations` stay (simulator: 18 before, 66 after). - * - * Filed: Android https://github.com/bugsee/bugsee-android/issues/214, - * iOS https://github.com/bugsee/bugsee-cocoa/issues/195. + * `capture/generations` stay (simulator: 18 before, 66 after; + * https://github.com/bugsee/bugsee-cocoa/issues/195). */ - const itDelete = it.failing; + const itDelete = ON_IOS ? it.failing : it; itDelete('deleteCollectedDataOnDevice(true) after stop resolves true and removes the rolling capture', () => { expect(deleted.text).toMatch(/result=true\b/); expect(partsWhileLaunched.length).toBeGreaterThan(0); diff --git a/examples/bare/e2e/smoke.test.ts b/examples/bare/e2e/smoke.test.ts index ff327e03..1e77b678 100644 --- a/examples/bare/e2e/smoke.test.ts +++ b/examples/bare/e2e/smoke.test.ts @@ -380,7 +380,8 @@ describeDevice(`the smoke set on ${TARGET_NAME}`, () => { // already filed (Android). await startRun('smoke-relaunch'); await awaitBundles(1, 60_000); - // Android 7.3.0 also files an error for the same incident (R10): a beat for it. + // Android 7.3.0 also filed an error for the same incident (R10; 7.3.1 + // files the crash only): a beat so an extra report would be counted. await new Promise(resolve => setTimeout(resolve, 3_000)); const all = await awaitBundles(1, 1_000); report('S7 bundles', all.map(b => ({ file: b.file, type: b.request.type, reason: payloadOf(b)?.reason }))); diff --git a/native-versions.json b/native-versions.json index 0f1bd02d..89dba084 100644 --- a/native-versions.json +++ b/native-versions.json @@ -1,7 +1,7 @@ { "android": { - "sdk": "7.3.0", - "gradlePlugin": "4.0.7" + "sdk": "7.3.1", + "gradlePlugin": "4.0.8" }, "ios": { "sdk": "7.0.0-beta5", diff --git a/packages/react-native-feedback/src/__tests__/pins.test.ts b/packages/react-native-feedback/src/__tests__/pins.test.ts index 9ed76bb1..93dcdbd3 100644 --- a/packages/react-native-feedback/src/__tests__/pins.test.ts +++ b/packages/react-native-feedback/src/__tests__/pins.test.ts @@ -55,7 +55,7 @@ describe('the feedback package pins the same native versions as the core', () => expect(gradle).toMatch( /api\s+"com\.bugsee:bugsee-android:\$\{nativeVersions\.android\.sdk\}"/, ); - expect(versions.android.sdk).toBe('7.3.0'); + expect(versions.android.sdk).toBe('7.3.1'); }); it('reaches Android feedback through Bugsee.ext(Feedback.class)', () => { diff --git a/packages/react-native/plugin/build/native-versions.baked.json b/packages/react-native/plugin/build/native-versions.baked.json index 95aedd61..847d1955 100644 --- a/packages/react-native/plugin/build/native-versions.baked.json +++ b/packages/react-native/plugin/build/native-versions.baked.json @@ -1,4 +1,4 @@ { - "sdk": "7.3.0", - "gradlePlugin": "4.0.7" + "sdk": "7.3.1", + "gradlePlugin": "4.0.8" } diff --git a/packages/react-native/src/options/android-options-manifest.json b/packages/react-native/src/options/android-options-manifest.json index c780f902..943c70dd 100644 --- a/packages/react-native/src/options/android-options-manifest.json +++ b/packages/react-native/src/options/android-options-manifest.json @@ -1,9 +1,9 @@ { "manifestVersion": 1, "sdk": "android", - "sdkVersion": "7.3.0", + "sdkVersion": "7.3.1", "buildConfiguration": "release", - "generatedAt": "2026-09-29T15:25:45Z", + "generatedAt": "2026-10-09T11:29:32Z", "options": [ { "key": "com.bugsee.option.config.duration", diff --git a/scripts/__tests__/native-versions.test.ts b/scripts/__tests__/native-versions.test.ts index e0dbbd9a..0cd36003 100644 --- a/scripts/__tests__/native-versions.test.ts +++ b/scripts/__tests__/native-versions.test.ts @@ -8,8 +8,8 @@ const valid: NativeVersions = { describe('readNativeVersions', () => { it('exposes the pinned native versions', () => { const v = readNativeVersions(); - expect(v.android.sdk).toBe('7.3.0'); - expect(v.android.gradlePlugin).toBe('4.0.7'); + expect(v.android.sdk).toBe('7.3.1'); + expect(v.android.gradlePlugin).toBe('4.0.8'); // A released pin carries no snapshot provenance. expect(v.android).not.toHaveProperty('snapshotCommit'); expect(v.ios.sdk).toBe('7.0.0-beta5'); From 7cdbab00c142225a1a1d6f178b2671edc649e041 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 16:36:56 +0500 Subject: [PATCH 2/7] build(android): the bare example sets no debugSymbolLevel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugsee Gradle plugin 4.0.8 uploads native symbols from the unstripped libraries in merged_native_libs, independent of ndk.debugSymbolLevel (bugsee-android-gradle-plugin #10, PR #13). Proof (Release + R8, 4 ABIs, loopback stub only): uploadBugseeReleaseNative ran bugsee-cli 0.8.1 on merged_native_libs/release, 64 libraries, each build-id equal to its stripped copy in the APK; libbugsee_e2e_native, libappmodules, libreactnative, libjsi and libfbjni went up as dwarf. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/android/app/build.gradle | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/examples/bare/android/app/build.gradle b/examples/bare/android/app/build.gradle index 38d13c65..0e834ec9 100644 --- a/examples/bare/android/app/build.gradle +++ b/examples/bare/android/app/build.gradle @@ -115,13 +115,10 @@ android { buildTypes { debug { signingConfig signingConfigs.debug - // AGP defaults this to NONE, so the plugin's native upload finds - // nothing and skips. SYMBOL_TABLE emits symbols for code this app - // builds. Maven Hermes and libreactnative.so are pre-stripped; - // this level does not symbolicate those two. - ndk { - debugSymbolLevel 'SYMBOL_TABLE' - } + // No ndk.debugSymbolLevel: Bugsee Gradle plugin 4.0.8 uploads + // native symbols from the unstripped libraries in + // build/intermediates/merged_native_libs, whatever the level. + // The level only decides what AGP packs for Google Play. } release { // Caution! In production, you need to generate your own keystore file. @@ -135,12 +132,7 @@ android { debuggable findProperty('bugseeE2eDebuggable') == 'true' minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" - // Same level as debug: run-android.sh is assembleDebug, and a - // release build needs the metadata too. Pre-stripped Maven Hermes - // and libreactnative.so are not symbolicated by this. - ndk { - debugSymbolLevel 'SYMBOL_TABLE' - } + // No ndk.debugSymbolLevel here either (see debug). } } } From 49bbacd2629403d65dbe458fdbf405d43c831888 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 16:48:55 +0500 Subject: [PATCH 3/7] e2e: read Android 7.3.1's OTLP performance capture; README pins plugin 4.0.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android 7.3.1 writes performance.json as an OTLP/JSON trace export (bugsee-android #207 via #208; bugsee/specs sdk/reporting/bundle/ performance.md) instead of {"transactions": [...]}. span-lifecycle and apm read transactions.filter / span.description, so they failed on the device with "transactions is undefined". scripts/performance-capture.ts reads either layout: legacy as is, OTLP by the spec's profile-2 reader rules (local roots, dedup on traceId/spanId, detail from url.full / db.query.text / file.path / bugsee.description), mapped to the fields the tests already assert (operation = bugsee.operation, status = bugsee.span.status). The assertions are unchanged. feedback: 7.3.1 paints the feedback action bar (#218 via #226) and OCR now reads the back arrow into the title line ("< Feedback"); a leading arrow glyph is dropped before the exact "Feedback" match. README: the manual setup's plugins block names 4.0.8, and a test keeps it on native-versions.json's android.gradlePlugin. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/e2e/apm.test.ts | 33 +--- examples/bare/e2e/feedback.test.ts | 5 +- examples/bare/e2e/span-lifecycle.test.ts | 26 ++- packages/react-native/README.md | 2 +- scripts/__tests__/native-versions.test.ts | 12 ++ scripts/__tests__/performance-capture.test.ts | 134 ++++++++++++++ scripts/performance-capture.ts | 173 ++++++++++++++++++ 7 files changed, 345 insertions(+), 40 deletions(-) create mode 100644 scripts/__tests__/performance-capture.test.ts create mode 100644 scripts/performance-capture.ts diff --git a/examples/bare/e2e/apm.test.ts b/examples/bare/e2e/apm.test.ts index 567203d7..03a11a53 100644 --- a/examples/bare/e2e/apm.test.ts +++ b/examples/bare/e2e/apm.test.ts @@ -10,6 +10,7 @@ * Marker, from scenarios/apm.ts: * BUGSEE_E2E apm notify=notify- txn=txn- */ +import { performanceTransactions } from '../../../scripts/performance-capture'; import { type PulledBundle, airplane, relayTexts, removePulledBundles, terminateIosApp } from './bundles'; import { ANDROID_PACKAGE } from './device'; import { @@ -124,30 +125,16 @@ describeDevice(`a notification and a transaction on ${TARGET_NAME}`, () => { }); }); -/** The span object whose `description` is `wanted`, anywhere in the capture. */ +/** + * The span whose `description` is `wanted`, in any transaction of the capture. + * performanceTransactions reads both layouts: iOS's legacy one and Android + * 7.3.1's OTLP (bugsee-android #207), where the description is + * `bugsee.description` and the attributes are flattened to key -> value. + */ function spanByDescription(capture: string, wanted: string): { attributes?: unknown } | undefined { - let found: { attributes?: unknown } | undefined; - const walk = (node: unknown): void => { - if (found !== undefined || node === null || typeof node !== 'object') { - return; - } - if (Array.isArray(node)) { - for (const item of node) { - walk(item); - } - return; - } - const record = node as { description?: unknown; attributes?: unknown }; - if (record.description === wanted) { - found = record; - return; - } - for (const value of Object.values(record)) { - walk(value); - } - }; - walk(JSON.parse(capture) as unknown); - return found; + return performanceTransactions(capture) + .flatMap(transaction => transaction.spans ?? []) + .find(span => span.description === wanted); } async function awaitRelay(title: string, timeoutMs = 20_000): Promise { diff --git a/examples/bare/e2e/feedback.test.ts b/examples/bare/e2e/feedback.test.ts index dfdb17c3..e5191483 100644 --- a/examples/bare/e2e/feedback.test.ts +++ b/examples/bare/e2e/feedback.test.ts @@ -102,8 +102,11 @@ describeDevice(`the feedback chat on ${TARGET_NAME}`, () => { expect(top).toBe(FEEDBACK_ACTIVITY); } // Both platforms title the screen "Feedback", in every locale tried. + // Since Android 7.3.1 paints the feedback action bar (bugsee-android + // #218), OCR can read the back arrow into the title's line ("< Feedback"); + // the arrow glyph is dropped, the title still has to match exactly. const text = await screenText('feedback'); - expect(text).toContain('Feedback'); + expect(text.map(line => line.replace(/^[<โ€นโ†]\s*/, ''))).toContain('Feedback'); // The app's own screen is gone from view: its status label is not read. expect(text).not.toContain('Bugsee React Native'); }); diff --git a/examples/bare/e2e/span-lifecycle.test.ts b/examples/bare/e2e/span-lifecycle.test.ts index ceb35f1c..0fbfd485 100644 --- a/examples/bare/e2e/span-lifecycle.test.ts +++ b/examples/bare/e2e/span-lifecycle.test.ts @@ -15,6 +15,7 @@ * the documented meaning of the no-arg finish (span.ts) -- so a status set * that way is not in the report and is not asserted; `finish(status)` is. */ +import { type CaptureTransaction, performanceTransactions } from '../../../scripts/performance-capture'; import { type PulledBundle } from './bundles'; import { ON_IOS, type Run, TARGET_NAME, awaitBundles, describeDevice, must, report, startRun } from './harness'; import { beginRetainingSuite, endRetainingSuite } from './observe'; @@ -22,19 +23,14 @@ import { type DeviceLog } from './scenario'; jest.setTimeout(5 * 60_000); -interface Span { - spanId?: string; - parentSpanId?: string; - operation?: string; - description?: string; - status?: string; -} -interface Transaction { - name?: string; - operation?: string; - status?: string; - spans?: Span[]; -} +/** + * Read through performanceTransactions: iOS writes the legacy layout, Android + * 7.3.1 OTLP (bugsee-android #207), which it maps to the same fields + * (`operation` = `bugsee.operation`, `status` = `bugsee.span.status`, + * `description` = `bugsee.description`, the root first with no parent). + */ +type Transaction = CaptureTransaction; +type Span = NonNullable[number]; /** * span.ts documents `setName` as the native setName, "which sets the @@ -62,8 +58,8 @@ describeDevice(`span names, descriptions, statuses and the active span on ${TARG const bundles: PulledBundle[] = await awaitBundles(1); const bundle = bundles.find(b => b.request.summary === `cov-apm-${nonce}`); expect(bundle).toBeDefined(); - const capture = JSON.parse(bundle!.captures.get('performance') ?? '{"transactions":[]}') as { transactions: Transaction[] }; - const mine = capture.transactions.filter(t => JSON.stringify(t).includes(`txn-desc-${nonce}`)); + const transactions = performanceTransactions(bundle!.captures.get('performance') ?? '{"transactions":[]}'); + const mine = transactions.filter(t => JSON.stringify(t).includes(`txn-desc-${nonce}`)); report('transaction', mine); expect(mine).toHaveLength(1); transaction = mine[0]!; diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 5fe39595..cab55dd6 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -208,7 +208,7 @@ against (`android.gradlePlugin` in ```groovy plugins { - id 'com.bugsee.android.gradle' version '4.0.7' apply false + id 'com.bugsee.android.gradle' version '4.0.8' apply false } ``` diff --git a/scripts/__tests__/native-versions.test.ts b/scripts/__tests__/native-versions.test.ts index 0cd36003..366368bc 100644 --- a/scripts/__tests__/native-versions.test.ts +++ b/scripts/__tests__/native-versions.test.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + import { readNativeVersions, type NativeVersions } from '../native-versions'; const valid: NativeVersions = { @@ -129,3 +132,12 @@ describe('readNativeVersions', () => { }); }); }); + +describe('the README restates the pins', () => { + it('the manual setup declares the pinned Gradle plugin, and no other version', () => { + const readme = readFileSync(join(__dirname, '..', '..', 'packages', 'react-native', 'README.md'), 'utf8'); + const declared = [...readme.matchAll(/id 'com\.bugsee\.android\.gradle' version '([^']+)'/g)].map(m => m[1]); + expect(declared.length).toBeGreaterThan(0); + expect(new Set(declared)).toEqual(new Set([readNativeVersions().android.gradlePlugin])); + }); +}); diff --git a/scripts/__tests__/performance-capture.test.ts b/scripts/__tests__/performance-capture.test.ts new file mode 100644 index 00000000..8dffd1bc --- /dev/null +++ b/scripts/__tests__/performance-capture.test.ts @@ -0,0 +1,134 @@ +import { otlpValue, performanceTransactions } from '../performance-capture'; + +const str = (key: string, value: string) => ({ key, value: { stringValue: value } }); + +interface Fields { + spanId: string; + name: string; + parentSpanId?: string; + traceId?: string; + flags?: number; +} + +function span(fields: Fields, attributes: Array<{ key: string; value: unknown }>) { + return { traceId: 'AB'.repeat(16), flags: 256, kind: 1, ...fields, attributes }; +} + +const ROOT = span({ spanId: '1111111111111111', name: 'txn-start' }, [ + str('bugsee.operation', 'txn-renamed'), + str('bugsee.span.status', 'ERROR'), + str('bugsee.description', 'txn-desc'), +]); +const CHILD = span({ spanId: '2222222222222222', parentSpanId: '1111111111111111', name: 'cov.child' }, [ + str('bugsee.operation', 'cov.child'), + str('bugsee.span.status', 'CANCELLED'), + str('bugsee.description', 'child-desc'), + { key: 'n', value: { intValue: '42' } }, +]); +const GRANDCHILD = span({ spanId: '3333333333333333', parentSpanId: '2222222222222222', name: 'GET' }, [ + str('bugsee.operation', 'http.client'), + str('bugsee.span.status', 'OK'), + str('url.full', 'https://127.0.0.1:9/x'), + str('bugsee.description', 'not this'), +]); +const OTHER_ROOT = span({ traceId: 'cd'.repeat(16), spanId: '4444444444444444', name: 'other' }, [str('bugsee.operation', 'other.op')]); + +const otlp = (spans: unknown[], extra: unknown[] = []) => + JSON.stringify({ + resourceSpans: [ + { resource: { attributes: [] }, scopeSpans: [{ scope: { name: 'com.bugsee.android' }, spans }] }, + ...extra, + ], + }); + +describe('performanceTransactions', () => { + it('returns a legacy capture as is', () => { + const legacy = { transactions: [{ name: 't', operation: 'op', spans: [{ spanId: 'a', description: 'd' }] }] }; + expect(performanceTransactions(JSON.stringify(legacy))).toEqual(legacy.transactions); + expect(performanceTransactions('{}')).toEqual([]); + }); + + it('reads an OTLP capture as one transaction per local root, root first', () => { + const [txn, other, ...rest] = performanceTransactions(otlp([ROOT, CHILD, GRANDCHILD, OTHER_ROOT])); + expect(rest).toEqual([]); + expect(txn).toEqual({ + name: 'txn-start', + operation: 'txn-renamed', + status: 'ERROR', + spans: [ + { + spanId: '1111111111111111', + operation: 'txn-renamed', + status: 'ERROR', + description: 'txn-desc', + attributes: { 'bugsee.operation': 'txn-renamed', 'bugsee.span.status': 'ERROR', 'bugsee.description': 'txn-desc' }, + }, + { + spanId: '2222222222222222', + parentSpanId: '1111111111111111', + operation: 'cov.child', + status: 'CANCELLED', + description: 'child-desc', + attributes: { 'bugsee.operation': 'cov.child', 'bugsee.span.status': 'CANCELLED', 'bugsee.description': 'child-desc', n: 42 }, + }, + { + spanId: '3333333333333333', + parentSpanId: '2222222222222222', + operation: 'http.client', + status: 'OK', + // url.full comes before bugsee.description. + description: 'https://127.0.0.1:9/x', + attributes: expect.any(Object), + }, + ], + }); + expect(other).toEqual({ + name: 'other', + operation: 'other.op', + status: undefined, + spans: [{ spanId: '4444444444444444', operation: 'other.op', status: undefined, attributes: { 'bugsee.operation': 'other.op' } }], + }); + }); + + it('finds children in any order, across resources, with ids in any case', () => { + const upper = { ...CHILD, spanId: CHILD.spanId, parentSpanId: '1111111111111111'.toUpperCase(), traceId: 'ab'.repeat(16) }; + const [txn] = performanceTransactions( + otlp([GRANDCHILD], [{ scopeSpans: [{ spans: [upper] }, { spans: [ROOT] }] }]), + ); + expect(txn!.spans!.map(s => s.spanId)).toEqual(['1111111111111111', '2222222222222222', '3333333333333333']); + }); + + it('takes a continued root (remote parent flag) as a root, and a span of another trace as not a child', () => { + const continued = span({ spanId: '5555555555555555', parentSpanId: '9999999999999999', flags: 256 | 0x200, name: 'continued' }, []); + const stranger = span({ traceId: 'ef'.repeat(16), spanId: '6666666666666666', parentSpanId: '1111111111111111', name: 's' }, []); + const names = performanceTransactions(otlp([ROOT, continued, stranger])).map(t => [t.name, t.spans!.length]); + expect(names).toEqual([['txn-start', 1], ['continued', 1]]); + }); + + it('keeps the completed copy of a span written twice, whichever comes first', () => { + const snap = { ...CHILD, attributes: [...CHILD.attributes, { key: 'bugsee.snapshot', value: { boolValue: true } }, str('bugsee.description', 'snap')] }; + for (const order of [[ROOT, snap, CHILD], [ROOT, CHILD, snap]]) { + const [txn] = performanceTransactions(otlp(order)); + expect(txn!.spans!.map(s => s.description)).toEqual(['txn-desc', 'child-desc']); + } + }); + + it('reads every OTLP value kind', () => { + expect(otlpValue(undefined)).toBeUndefined(); + expect(otlpValue({})).toBeUndefined(); + expect(otlpValue({ stringValue: '' })).toBe(''); + expect(otlpValue({ boolValue: false })).toBe(false); + expect(otlpValue({ intValue: '0' })).toBe(0); + expect(otlpValue({ intValue: 7 })).toBe(7); + expect(otlpValue({ doubleValue: 0 })).toBe(0); + expect(otlpValue({ arrayValue: { values: [{ stringValue: 'a' }, { intValue: '2' }] } })).toEqual(['a', 2]); + expect(otlpValue({ arrayValue: {} })).toEqual([]); + expect(otlpValue({ kvlistValue: { values: [{ key: 'k', value: { boolValue: true } }] } })).toEqual({ k: true }); + expect(otlpValue({ kvlistValue: {} })).toEqual({}); + }); + + it('treats an empty parentSpanId as no parent', () => { + const [txn] = performanceTransactions(otlp([{ ...ROOT, parentSpanId: '' }])); + expect(txn!.name).toBe('txn-start'); + }); +}); diff --git a/scripts/performance-capture.ts b/scripts/performance-capture.ts new file mode 100644 index 00000000..a3af9217 --- /dev/null +++ b/scripts/performance-capture.ts @@ -0,0 +1,173 @@ +/** + * Reads a report's `performance` capture as transactions, whichever layout + * the SDK wrote (bugsee/specs `sdk/reporting/bundle/performance.md`): + * + * - legacy, `{"transactions": [...]}`: iOS 7.0.0-beta5 and Android up to + * 7.3.0. Returned as is. + * - OTLP/JSON, `{"resourceSpans": [...]}`: Android 7.3.1 (bugsee-android + * #207). Read by the spec's "OTLP reader (profile 2)" rules: every + * resourceSpans and scopeSpans, local roots by the local-root rule, + * deduplicated on (traceId, spanId) keeping the copy without + * `bugsee.snapshot`, and each span's detail from the first present of + * `url.full`, `db.query.text`, `file.path`, `bugsee.description`. + * + * Each root becomes one transaction in the legacy shape the e2e asserts on: + * `name` is the root's name, `operation` and `status` its `bugsee.operation` + * and `bugsee.span.status`, and `spans` holds the root itself first (no + * `parentSpanId`, as Android's legacy layout repeated it) followed by the + * rest of its trace. `attributes` is a flat key -> value object. + */ + +export interface CaptureSpan { + spanId?: string; + parentSpanId?: string; + operation?: string; + description?: string; + status?: string; + attributes?: Record; +} + +export interface CaptureTransaction { + name?: string; + operation?: string; + status?: string; + spans?: CaptureSpan[]; +} + +interface OtlpValue { + stringValue?: string; + boolValue?: boolean; + intValue?: string | number; + doubleValue?: number; + arrayValue?: { values?: OtlpValue[] }; + kvlistValue?: { values?: OtlpAttribute[] }; +} +interface OtlpAttribute { + key: string; + value?: OtlpValue; +} +interface OtlpSpan { + traceId?: string; + spanId?: string; + parentSpanId?: string; + flags?: number; + name?: string; + attributes?: OtlpAttribute[]; +} + +/** `SPAN_FLAGS_CONTEXT_IS_REMOTE_MASK`: the parent is in another process. */ +const REMOTE_PARENT = 0x200; +const DETAIL_KEYS = ['url.full', 'db.query.text', 'file.path', 'bugsee.description'] as const; + +export function otlpValue(value: OtlpValue | undefined): unknown { + if (value === undefined) { + return undefined; + } + if (value.stringValue !== undefined) { + return value.stringValue; + } + if (value.boolValue !== undefined) { + return value.boolValue; + } + if (value.intValue !== undefined) { + return Number(value.intValue); + } + if (value.doubleValue !== undefined) { + return value.doubleValue; + } + if (value.arrayValue !== undefined) { + return (value.arrayValue.values ?? []).map(otlpValue); + } + if (value.kvlistValue !== undefined) { + return flatten(value.kvlistValue.values); + } + return undefined; +} + +function flatten(attributes: readonly OtlpAttribute[] | undefined): Record { + const out: Record = {}; + for (const attribute of attributes ?? []) { + out[attribute.key] = otlpValue(attribute.value); + } + return out; +} + +const lower = (id: string | undefined): string | undefined => (id === undefined || id === '' ? undefined : id.toLowerCase()); + +function isLocalRoot(span: OtlpSpan): boolean { + return lower(span.parentSpanId) === undefined || ((span.flags ?? 0) & REMOTE_PARENT) !== 0; +} + +function toSpan(span: OtlpSpan, root: boolean): CaptureSpan { + const attributes = flatten(span.attributes); + const detail = DETAIL_KEYS.map(key => attributes[key]).find(value => value !== undefined); + const out: CaptureSpan = { + spanId: lower(span.spanId), + operation: attributes['bugsee.operation'] as string | undefined, + status: attributes['bugsee.span.status'] as string | undefined, + attributes, + }; + if (!root) { + out.parentSpanId = lower(span.parentSpanId); + } + if (detail !== undefined) { + out.description = String(detail); + } + return out; +} + +/** The OTLP spans of a capture, every resource and scope, deduplicated on (traceId, spanId). */ +function otlpSpans(document: { resourceSpans?: unknown }): OtlpSpan[] { + const byId = new Map(); + const resources = Array.isArray(document.resourceSpans) ? document.resourceSpans : []; + for (const resource of resources as Array<{ scopeSpans?: Array<{ spans?: OtlpSpan[] }> }>) { + for (const scope of resource.scopeSpans ?? []) { + for (const span of scope.spans ?? []) { + const key = `${lower(span.traceId)}/${lower(span.spanId)}`; + const kept = byId.get(key); + if (kept === undefined || (snapshot(kept) && !snapshot(span))) { + byId.set(key, span); + } + } + } + } + return [...byId.values()]; +} + +const snapshot = (span: OtlpSpan): boolean => flatten(span.attributes)['bugsee.snapshot'] === true; + +/** The transactions in a `performance` capture's text, either layout. */ +export function performanceTransactions(text: string): CaptureTransaction[] { + const document = JSON.parse(text) as { transactions?: CaptureTransaction[]; resourceSpans?: unknown }; + if (document.resourceSpans === undefined) { + return document.transactions ?? []; + } + const spans = otlpSpans(document); + return spans.filter(isLocalRoot).map(root => { + const trace = lower(root.traceId); + const rootSpan = toSpan(root, true); + // A root's trace: its descendants in this file, by parent links. + const members: CaptureSpan[] = [rootSpan]; + const ids = new Set([rootSpan.spanId]); + let grew = true; + while (grew) { + grew = false; + for (const span of spans) { + if (lower(span.traceId) !== trace || ids.has(lower(span.spanId)) || isLocalRoot(span)) { + continue; + } + if (ids.has(lower(span.parentSpanId))) { + ids.add(lower(span.spanId)); + members.push(toSpan(span, false)); + grew = true; + } + } + } + return { + name: root.name, + operation: rootSpan.operation, + status: rootSpan.status, + spans: members, + }; + }); +} From 80fa8029228f206c932631059b27b350d13a7307 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 16:51:55 +0500 Subject: [PATCH 4/7] test(scripts): pin the OTLP reader against its mutants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focused Stryker on scripts/performance-capture.ts: 92.86% -> 98.41% (116 killed, 8 timeouts, 2 equivalent survivors: a non-object resource or scope has no scopeSpans/spans to read either way). toStrictEqual where toEqual ignored undefined fields, hex ids with letters, resources without scopes or spans, and the first of two equal copies. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- scripts/__tests__/performance-capture.test.ts | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/scripts/__tests__/performance-capture.test.ts b/scripts/__tests__/performance-capture.test.ts index 8dffd1bc..234419ac 100644 --- a/scripts/__tests__/performance-capture.test.ts +++ b/scripts/__tests__/performance-capture.test.ts @@ -14,18 +14,18 @@ function span(fields: Fields, attributes: Array<{ key: string; value: unknown }> return { traceId: 'AB'.repeat(16), flags: 256, kind: 1, ...fields, attributes }; } -const ROOT = span({ spanId: '1111111111111111', name: 'txn-start' }, [ +const ROOT = span({ spanId: 'a111111111111111', name: 'txn-start' }, [ str('bugsee.operation', 'txn-renamed'), str('bugsee.span.status', 'ERROR'), str('bugsee.description', 'txn-desc'), ]); -const CHILD = span({ spanId: '2222222222222222', parentSpanId: '1111111111111111', name: 'cov.child' }, [ +const CHILD = span({ spanId: 'b222222222222222', parentSpanId: 'a111111111111111', name: 'cov.child' }, [ str('bugsee.operation', 'cov.child'), str('bugsee.span.status', 'CANCELLED'), str('bugsee.description', 'child-desc'), { key: 'n', value: { intValue: '42' } }, ]); -const GRANDCHILD = span({ spanId: '3333333333333333', parentSpanId: '2222222222222222', name: 'GET' }, [ +const GRANDCHILD = span({ spanId: 'c333333333333333', parentSpanId: 'b222222222222222', name: 'GET' }, [ str('bugsee.operation', 'http.client'), str('bugsee.span.status', 'OK'), str('url.full', 'https://127.0.0.1:9/x'), @@ -51,38 +51,43 @@ describe('performanceTransactions', () => { it('reads an OTLP capture as one transaction per local root, root first', () => { const [txn, other, ...rest] = performanceTransactions(otlp([ROOT, CHILD, GRANDCHILD, OTHER_ROOT])); expect(rest).toEqual([]); - expect(txn).toEqual({ + expect(txn).toStrictEqual({ name: 'txn-start', operation: 'txn-renamed', status: 'ERROR', spans: [ { - spanId: '1111111111111111', + spanId: 'a111111111111111', operation: 'txn-renamed', status: 'ERROR', description: 'txn-desc', attributes: { 'bugsee.operation': 'txn-renamed', 'bugsee.span.status': 'ERROR', 'bugsee.description': 'txn-desc' }, }, { - spanId: '2222222222222222', - parentSpanId: '1111111111111111', + spanId: 'b222222222222222', + parentSpanId: 'a111111111111111', operation: 'cov.child', status: 'CANCELLED', description: 'child-desc', attributes: { 'bugsee.operation': 'cov.child', 'bugsee.span.status': 'CANCELLED', 'bugsee.description': 'child-desc', n: 42 }, }, { - spanId: '3333333333333333', - parentSpanId: '2222222222222222', + spanId: 'c333333333333333', + parentSpanId: 'b222222222222222', operation: 'http.client', status: 'OK', // url.full comes before bugsee.description. description: 'https://127.0.0.1:9/x', - attributes: expect.any(Object), + attributes: { + 'bugsee.operation': 'http.client', + 'bugsee.span.status': 'OK', + 'url.full': 'https://127.0.0.1:9/x', + 'bugsee.description': 'not this', + }, }, ], }); - expect(other).toEqual({ + expect(other).toStrictEqual({ name: 'other', operation: 'other.op', status: undefined, @@ -91,16 +96,31 @@ describe('performanceTransactions', () => { }); it('finds children in any order, across resources, with ids in any case', () => { - const upper = { ...CHILD, spanId: CHILD.spanId, parentSpanId: '1111111111111111'.toUpperCase(), traceId: 'ab'.repeat(16) }; + const upper = { ...CHILD, spanId: CHILD.spanId.toUpperCase(), parentSpanId: 'a111111111111111'.toUpperCase(), traceId: 'AB'.repeat(16) }; const [txn] = performanceTransactions( otlp([GRANDCHILD], [{ scopeSpans: [{ spans: [upper] }, { spans: [ROOT] }] }]), ); - expect(txn!.spans!.map(s => s.spanId)).toEqual(['1111111111111111', '2222222222222222', '3333333333333333']); + expect(txn!.spans!.map(s => [s.spanId, s.parentSpanId])).toStrictEqual([ + ['a111111111111111', undefined], + ['b222222222222222', 'a111111111111111'], + ['c333333333333333', 'b222222222222222'], + ]); + }); + + it('skips a resource without scopes and a scope without spans, and a non-array resourceSpans', () => { + expect(performanceTransactions(otlp([ROOT], [{}, { scopeSpans: [{}] }])).map(t => t.name)).toStrictEqual(['txn-start']); + expect(performanceTransactions(JSON.stringify({ resourceSpans: {} }))).toStrictEqual([]); + }); + + it('keeps the first of two equal copies (both snapshots)', () => { + const snap = (text: string) => ({ ...ROOT, attributes: [{ key: 'bugsee.snapshot', value: { boolValue: true } }, str('bugsee.description', text)] }); + const [txn] = performanceTransactions(otlp([snap('first'), snap('second')])); + expect(txn!.spans!.map(s => s.description)).toStrictEqual(['first']); }); it('takes a continued root (remote parent flag) as a root, and a span of another trace as not a child', () => { const continued = span({ spanId: '5555555555555555', parentSpanId: '9999999999999999', flags: 256 | 0x200, name: 'continued' }, []); - const stranger = span({ traceId: 'ef'.repeat(16), spanId: '6666666666666666', parentSpanId: '1111111111111111', name: 's' }, []); + const stranger = span({ traceId: 'ef'.repeat(16), spanId: '6666666666666666', parentSpanId: 'a111111111111111', name: 's' }, []); const names = performanceTransactions(otlp([ROOT, continued, stranger])).map(t => [t.name, t.spans!.length]); expect(names).toEqual([['txn-start', 1], ['continued', 1]]); }); @@ -122,9 +142,9 @@ describe('performanceTransactions', () => { expect(otlpValue({ intValue: 7 })).toBe(7); expect(otlpValue({ doubleValue: 0 })).toBe(0); expect(otlpValue({ arrayValue: { values: [{ stringValue: 'a' }, { intValue: '2' }] } })).toEqual(['a', 2]); - expect(otlpValue({ arrayValue: {} })).toEqual([]); + expect(otlpValue({ arrayValue: {} })).toStrictEqual([]); expect(otlpValue({ kvlistValue: { values: [{ key: 'k', value: { boolValue: true } }] } })).toEqual({ k: true }); - expect(otlpValue({ kvlistValue: {} })).toEqual({}); + expect(otlpValue({ kvlistValue: {} })).toStrictEqual({}); }); it('treats an empty parentSpanId as no parent', () => { From fbe2bc6f3be67cfa2565f78910049499c79874af Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 17:09:10 +0500 Subject: [PATCH 5/7] e2e: Android 7.3.1 network bodies and the feedback greeting, as the device shows them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launch-options custom-option (body-size-limit 7): 7.3.1 drops a body over the limit whole and says why (bugsee-android #231 via #232, so a cut body cannot slip past redaction). On Android every start event of the 64-byte POST must now carry no body and no_body_reason size_too_large, a direct witness instead of the control comparison; iOS keeps the comparison. Control's POST to the dead endpoint used to keep its 64-byte request body; 7.3.1 reads a request body only while OkHttp writes it, so a refused connection records no_body_reason no_data (WOD_LX1, 2 runs). That is a capture regression: filed bugsee/bugsee-android#246 and pinned as its own it.failing on Android. feedback itGreeting: #225 does not show the greeting on this flow (set just before showFeedbackActivity, chat opened directly, offline): an empty chat, while the feedback preferences hold the run's greeting, 2 of 2 runs. Filed bugsee/bugsee-android#245; it.failing on Android again, iOS plain. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/e2e/feedback.test.ts | 9 ++-- examples/bare/e2e/launch-options.test.ts | 63 +++++++++++++++++++----- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/examples/bare/e2e/feedback.test.ts b/examples/bare/e2e/feedback.test.ts index e5191483..85a8d2fb 100644 --- a/examples/bare/e2e/feedback.test.ts +++ b/examples/bare/e2e/feedback.test.ts @@ -45,10 +45,13 @@ const CHAT_BACKGROUND = '#112233'; const itColour = ON_IOS ? it.failing : it; /** * Android 7.3.0 stored the greeting and never read it back: the chat opened - * empty (bugsee-android #215). 7.3.1 shows it in an empty chat (#225), as iOS - * does. + * empty (bugsee-android #215). 7.3.1 (#225) is meant to show it in an empty + * chat, as iOS does, but on this flow (greeting set just before + * showFeedbackActivity, chat opened directly, offline) the WOD_LX1 still shows + * an empty chat while the feedback preferences hold the greeting, 2 of 2 runs: + * https://github.com/bugsee/bugsee-android/issues/245. Pinned until it shows. */ -const itGreeting = it; +const itGreeting = ON_ANDROID ? it.failing : it; describeDevice(`the feedback chat on ${TARGET_NAME}`, () => { let log: DeviceLog | undefined; diff --git a/examples/bare/e2e/launch-options.test.ts b/examples/bare/e2e/launch-options.test.ts index 77c65016..01717db7 100644 --- a/examples/bare/e2e/launch-options.test.ts +++ b/examples/bare/e2e/launch-options.test.ts @@ -351,19 +351,27 @@ const CASES: readonly Case[] = [ name: 'custom-option', platforms: ['android', 'ios'], set: { 'com.bugsee.option.capture.network.body-size-limit': 7 }, - // A key no accessor surfaces, through setCustomOption: network bodies - // are cut at 7 bytes, so the capture shrinks against control. + // A key no accessor surfaces, through setCustomOption. Android 7.3.1 + // drops a body over the limit whole and says why (bugsee-android #231 via + // #232: a cut body could not be redacted), so the 64-byte POST body is the + // witness on its own. iOS is read against control: bodies are cut at 7 + // bytes, so the capture shrinks. effect: (outcome, control) => { - const bodies = (o: Outcome) => - captureEvents(the(o), 'network') - .filter(event => event.url === `https://127.0.0.1:9/cov-post/${o.run.scenario.nonce}`) - .map(event => (event.custom as { body?: unknown } | undefined)?.body ?? event.body) - .filter((body): body is string => typeof body === 'string'); - report('cov-post bodies', { limited: bodies(outcome), control: bodies(control) }); + expect(networkUrls(the(outcome))).toContain(covPostUrl(outcome)); + if (ON_ANDROID) { + const starts = covPostEvents(outcome).filter(event => event.type === 'before'); + report('cov-post starts', starts.map(event => event.custom)); + expect(starts.length).toBeGreaterThan(0); + for (const event of starts) { + const custom = event.custom as { body?: unknown; no_body_reason?: unknown } | undefined; + expect([custom?.body ?? null, custom?.no_body_reason]).toEqual([null, 'size_too_large']); + } + return; + } + report('cov-post bodies', { limited: covPostBodies(outcome), control: covPostBodies(control) }); // Control keeps the whole 64-byte body (scenarios/coverage.ts POST_BODY). - expect(bodies(control)).toContain(POST_BODY); - expect(networkUrls(the(outcome))).toContain(`https://127.0.0.1:9/cov-post/${outcome.run.scenario.nonce}`); - expect(bodies(outcome).every(body => body.length <= 7)).toBe(true); + expect(covPostBodies(control)).toContain(POST_BODY); + expect(covPostBodies(outcome).every(body => body.length <= 7)).toBe(true); }, // Filed: https://github.com/bugsee/bugsee-cocoa/issues/197 readbackFails: { @@ -375,6 +383,23 @@ const CASES: readonly Case[] = [ const outcomes = new Map(); +/** The url scenarios/coverage.ts POSTs to in this outcome's run. */ +function covPostUrl(o: Outcome): string { + return `https://127.0.0.1:9/cov-post/${o.run.scenario.nonce}`; +} + +/** The network events of that POST. */ +function covPostEvents(o: Outcome): Array> { + return captureEvents(the(o), 'network').filter(event => event.url === covPostUrl(o)); +} + +/** The bodies recorded for that POST. */ +function covPostBodies(o: Outcome): string[] { + return covPostEvents(o) + .map(event => (event.custom as { body?: unknown } | undefined)?.body ?? event.body) + .filter((body): body is string => typeof body === 'string'); +} + /** The URLs in the report's network capture. */ function networkUrls(bundle: PulledBundle): string[] { return captureEvents(bundle, 'network').map(event => String(event.url ?? '')); @@ -463,4 +488,20 @@ describeDevice(`launch options and their effects on ${TARGET_NAME}`, () => { await item.effect!(outcome, control); }); } + + /** + * Control's own POST fails to connect (the dead endpoint); its request body + * is still in the capture. Android 7.3.0 recorded it. 7.3.1 reads the body + * only while OkHttp writes it, which a refused connection never does, and + * records no_body_reason no_data instead. Android only: iOS was not + * measured here. + */ + (ON_ANDROID ? it.failing : it.skip)( + 'control: a POST that fails before any response keeps its request body [known: Android 7.3.1 records it without the body (no_data), https://github.com/bugsee/bugsee-android/issues/246]', + async () => { + const control = await runCase(CASES[0]!); + report('control cov-post bodies', covPostBodies(control)); + expect(covPostBodies(control)).toContain(POST_BODY); + }, + ); }); From 02dd607d058fff7b77d2034020952619d58e4068 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 17:17:04 +0500 Subject: [PATCH 6/7] Network filter: expose Android 7.3.1's override flag; README: no debugSymbolLevel needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 7.3.1 can offer one request to the filter as more than one event with the same id: a supplement (typically the request body) has NetworkEvent.isOverride() true. The Android snapshot now carries it as a read-only `override`, NetworkFilterEvent documents it, and a reply cannot change it (native writes only its known keys). JVM test included. - README manual setup: Gradle plugin 4.0.8 uploads native symbols from the unstripped merged_native_libs, so no ndk.debugSymbolLevel is needed for Bugsee; set one only for Google Play. Addresses cursor[bot] review on #68 (P2 override flag, P3 README). ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/README.md | 16 +++------ .../reactnative/NetworkFilterBridge.java | 4 +++ .../reactnative/NetworkFilterBridgeTest.java | 33 +++++++++++++++++++ packages/react-native/src/network/filter.ts | 8 +++++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index cab55dd6..fd890a80 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -213,8 +213,10 @@ plugins { ``` `android/app/build.gradle` โ€” apply it, and for native crash symbols add the -NDK module at the SDK version the package pins, with symbol extraction on -(AGP extracts none by default, so there would be nothing to upload): +NDK module at the SDK version the package pins. No `ndk.debugSymbolLevel` is +needed for Bugsee: Gradle plugin 4.0.8 uploads native symbols from the +unstripped libraries in `build/intermediates/merged_native_libs`, whatever +the level. Set one only for what Google Play should get. ```groovy apply plugin: "com.android.application" @@ -222,16 +224,6 @@ apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" apply plugin: "com.bugsee.android.gradle" -android { - buildTypes { - release { - ndk { - debugSymbolLevel 'SYMBOL_TABLE' - } - } - } -} - // bugseeDir as in "Android source maps" above. def bugseeVersions = new groovy.json.JsonSlurper().parse(new File(bugseeDir, "native-versions.json")) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java index 5ba68192..6c480874 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/NetworkFilterBridge.java @@ -429,6 +429,10 @@ private static String snapshot(@NonNull final NetworkEvent event) throws JSONExc : null; put(object, "websocketEvent", websocketEvent); object.put("responseCode", event.getResponseCode()); + // Read-only. Since Android 7.3.1 a request can reach the filter as more + // than one event with the same id: a supplement (typically the request + // body, known only once it is written) has isOverride() true. + object.put("override", event.isOverride()); final Map headers = event.getHeaders(); if (headers == null) { object.put("headers", JSONObject.NULL); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java index 0c327919..588c9736 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/NetworkFilterBridgeTest.java @@ -106,6 +106,7 @@ private static final class MutableNetwork implements NetworkEvent { @Nullable String errorDescription; @Nullable String errorShortMessage; @Nullable String statusText; + boolean override; int setUrlCalls; MutableNetwork(@NonNull final String url) { @@ -118,6 +119,11 @@ public long getTimestamp() { return 1L; } + @Override + public boolean isOverride() { + return override; + } + @Override public String getId() { return id; @@ -351,6 +357,33 @@ public void aDefaultSanitizerRedactsTokenBeforeTheUrlIsRemembered() throws Excep assertFalse(event.url.contains("bugsee-secret-token-value")); } + @Test + public void snapshotCarriesTheOverrideFlagAndAReplyCannotChangeIt() throws Exception { + final RecordingSink sink = new RecordingSink(); + bridge.attach(sink); + final MutableNetwork start = new MutableNetwork("https://api.example/v1/items"); + final MutableNetwork supplement = new MutableNetwork("https://api.example/v1/items"); + supplement.override = true; + supplement.body = "{\"a\":1}"; + final RecordingCallback startCallback = new RecordingCallback(); + final RecordingCallback supplementCallback = new RecordingCallback(); + bridge.ask(start, startCallback); + bridge.ask(supplement, supplementCallback); + + final JSONObject first = new JSONObject(sink.json.get(0)); + final JSONObject second = new JSONObject(sink.json.get(1)); + assertFalse(first.getBoolean("override")); + assertTrue(second.getBoolean("override")); + assertEquals("{\"a\":1}", second.getString("body")); + + // JS echoes the key back; it is not written to the event. + second.put("override", false); + bridge.reply(sink.ids.get(1), second.toString()); + assertTrue(supplement.override); + assertEquals(1, supplementCallback.runs); + assertSame(supplement, supplementCallback.last); + } + @Test public void jsonNullClearsErrorFieldsAndStatusText() throws Exception { final RecordingSink sink = new RecordingSink(); diff --git a/packages/react-native/src/network/filter.ts b/packages/react-native/src/network/filter.ts index fa7c55d0..bc13e4a3 100644 --- a/packages/react-native/src/network/filter.ts +++ b/packages/react-native/src/network/filter.ts @@ -32,6 +32,14 @@ export interface NetworkFilterEvent { redirectedFromURL?: string | null; /** iOS. A dictionary, or `null`, when the snapshot sent the key. */ error?: Record | null; + /** + * Android, read-only. Since Android SDK 7.3.1 one request can reach the + * filter as more than one event with the same `id` and `type`: a supplement + * of an earlier one, carrying a part known only later (typically the + * request body), has `override` true. Filter it like any other event; a + * changed value is not written back. + */ + override?: boolean; } export type NetworkFilter = ( From b4d99facc2fb8cfd45f7acaea64d330ccb504670 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 18:32:57 +0500 Subject: [PATCH 7/7] e2e: flip the Android 7.3.1 pins from #63's suites; device-proven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WOD_LX1, 7.3.1 [0aa9ae1ff], after each pin was seen to pass unchanged: - report-dialog-keys RA-02 actionBarColor paints the dialog (#217 via #224): 22/22 - feedback-keys FA-01 actionBarColor, FA-11 emailContinueNotActiveColor, FA-20 inputTextHintColor (#218 via #226): 41/41. FA-19 inputTextColor was seen through the 7.3.0 hint, which 7.3.1 draws in inputTextHintColor; it now types into the input and finds the colour on the text (1524 px). - sdk-breadcrumbs FLOW-15 (#219 via #228): 2/2 - options-readback: environment.sdk.options records enum values, not ordinals (#216 via #223): 81/81 - option-effects OPT-025 native crash with detect.crash=false (#220 via #227) and OPT-081 out-of-process report (#221 via #229): plain tests now. OPT-002 was pinned on #219; with crumbs back, extras adds no crumbs on Android but copies the intent extras into system crumbs' data, which is now the witness (1 vs 0). 16 passed, 10 skipped. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/e2e/feedback-keys.test.ts | 63 ++++++++++---------- examples/bare/e2e/option-effects.test.ts | 32 ++++++---- examples/bare/e2e/options-readback.test.ts | 26 ++------ examples/bare/e2e/report-dialog-keys.test.ts | 7 +-- examples/bare/e2e/sdk-breadcrumbs.test.ts | 10 ++-- 5 files changed, 61 insertions(+), 77 deletions(-) diff --git a/examples/bare/e2e/feedback-keys.test.ts b/examples/bare/e2e/feedback-keys.test.ts index b2423af7..92029425 100644 --- a/examples/bare/e2e/feedback-keys.test.ts +++ b/examples/bare/e2e/feedback-keys.test.ts @@ -49,7 +49,8 @@ const MIN_PIXELS = 40; /** Keys the open chat paints, per platform. */ const CHAT_PIXEL: Record<'android' | 'ios', readonly string[]> = { - android: ['actionBarColor', 'backgroundColor', 'inputTextColor', 'inputTextHintColor', 'titleTextColor'], + // inputTextColor: its own test below, on typed text (7.3.1 draws the hint in inputTextHintColor). + android: ['actionBarColor', 'backgroundColor', 'inputTextHintColor', 'titleTextColor'], ios: ['backgroundColor', 'barsColor', 'closeButtonColor', 'inputBackgroundColor', 'navigationBarColor', 'titleTextColor'], }; /** Keys the e-mail screen paints, per platform. */ @@ -58,26 +59,6 @@ const EMAIL_PIXEL: Record<'android' | 'ios', readonly string[]> = { ios: ['emailBackgroundColor', 'emailContinueNotActiveColor', 'emailSkipColor'], }; -function blend(top: string, under: string, alpha: number): string { - const channel = (hex: string, at: number) => Number.parseInt(hex.slice(at, at + 2), 16); - return `#${[1, 3, 5] - .map(at => Math.round(alpha * channel(top, at) + (1 - alpha) * channel(under, at)).toString(16).padStart(2, '0')) - .join('')}`; -} - -/** - * The colour a key shows as, where the SDK draws it blended. Android 7.3.0's - * Compose chat draws the empty input's hint in `inputText` at alpha 0.5 over - * the background (ChatMessageInput.kt), so that is where inputTextColor shows; - * `bottomDelimiterColor` is the e-mail field's border (RequestEmailScreen.kt). - */ -function paintedAs(key: string): string { - if (ON_ANDROID && key === 'inputTextColor') { - return blend(FEEDBACK_COLOURS.inputTextColor!, FEEDBACK_COLOURS.backgroundColor!, 0.5); - } - return FEEDBACK_COLOURS[key]!; -} - /** * iOS 7.0.0-beta5's feedback (BugseeFeedback, SwiftUI) has no e-mail screen: * showFeedbackUI() presents FeedbackChatView with Bugsee.getUserIdentifier() @@ -89,18 +70,13 @@ const IOS_NO_EMAIL_SCREEN = 'iOS beta5 feedback has no e-mail screen (bugsee-coc const IOS_NO_EMAIL_SCREEN_KEY = 'iOS beta5 feedback has no e-mail screen to paint (bugsee-cocoa#202)'; /** - * Keys Android 7.3.0 reads back but never paints, pinned (it.failing): the - * feedback module (FeedbackColors.kt, ChatMessageInput.kt) never reads - * Feedback::ActionBarColor or Feedback::EmailContinueNotActiveColor, and the - * chat's hint is drawn from InputTextColor, not InputTextHintColor - * (WOD_LX1, 2026-10-07). Filed: bugsee-android#218. + * Keys a platform reads back but never paints, pinned (it.failing). Android + * 7.3.0 never read Feedback::ActionBarColor or EmailContinueNotActiveColor + * and drew the hint from InputTextColor (bugsee-android#218); 7.3.1 paints + * all three (#226, WOD_LX1), so Android has none. */ const KNOWN_NOT_PAINTED: Record<'android' | 'ios', Record> = { - android: { - actionBarColor: 'Android 7.3.0 never reads Feedback::ActionBarColor (bugsee-android#218)', - emailContinueNotActiveColor: 'Android 7.3.0 never reads Feedback::EmailContinueNotActiveColor (bugsee-android#218)', - inputTextHintColor: 'Android 7.3.0 draws the hint from InputTextColor at alpha 0.5 (bugsee-android#218)', - }, + android: {}, ios: { emailBackgroundColor: IOS_NO_EMAIL_SCREEN_KEY, emailContinueNotActiveColor: IOS_NO_EMAIL_SCREEN_KEY, @@ -112,8 +88,8 @@ const KNOWN_NOT_PAINTED: Record<'android' | 'ios', Record> = { function pixelIt(base: jest.It, key: string, where: string, shot: () => string): void { const known = KNOWN_NOT_PAINTED[PLATFORM][key]; (known !== undefined ? base.failing : base)(`[${faOf(key)}] ${key} paints the ${where}${known !== undefined ? ` [known: ${known}]` : ''}`, async () => { - const found = await colourPixels(shot(), colourOf(paintedAs(key)), 20); - report(`${key} ${paintedAs(key)} pixels`, { count: found.count, box: found.box }); + const found = await colourPixels(shot(), colourOf(FEEDBACK_COLOURS[key]!), 20); + report(`${key} ${FEEDBACK_COLOURS[key]} pixels`, { count: found.count, box: found.box }); expect(found.count).toBeGreaterThan(MIN_PIXELS); }); } @@ -224,6 +200,27 @@ describeDevice(`the feedback package's keys, nulls and pre-launch use on ${TARGE } pixelIt(it, key, 'chat', () => shot); } + + /** + * Android 7.3.0 drew the empty input's hint in inputTextColor at alpha 0.5, + * which is where this key used to be seen; 7.3.1 draws the hint in + * inputTextHintColor (bugsee-android #218 via #226). The input text colour + * is now only on text in the field, so this types some (nothing is sent). + */ + (ON_ANDROID ? it : it.skip)('[FA-19] inputTextColor paints text typed into the chat input', async () => { + const { xml } = await uiDump(); + const field = /]*class="android\.widget\.EditText"[^>]*>/.exec(xml)?.[0]; + expect(field).toBeDefined(); + const [left, top, right, bottom] = /bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(field!)!.slice(1, 5).map(Number) as [number, number, number, number]; + await adbStatus('shell', 'input', 'tap', String(Math.round((left + right) / 2)), String(Math.round((top + bottom) / 2))); + await adbStatus('shell', 'input', 'text', 'WWWWWWWWWWWW'); + await new Promise(resolve => setTimeout(resolve, 1_500)); + const typed = await captureScreen('feedback-typed'); + keepShot(typed, `chat-typed-${run.scenario.nonce}`); + const found = await colourPixels(typed, colourOf(FEEDBACK_COLOURS.inputTextColor!), 20); + report(`inputTextColor ${FEEDBACK_COLOURS.inputTextColor} typed pixels`, { count: found.count, box: found.box }); + expect(found.count).toBeGreaterThan(MIN_PIXELS); + }); }); describe('the e-mail screen on a first open', () => { diff --git a/examples/bare/e2e/option-effects.test.ts b/examples/bare/e2e/option-effects.test.ts index 4922af5f..50e6052a 100644 --- a/examples/bare/e2e/option-effects.test.ts +++ b/examples/bare/e2e/option-effects.test.ts @@ -174,11 +174,10 @@ describeDevice(`launch option effects on ${TARGET_NAME}`, () => { }); /** - * Android 7.3.0 still recovers a native SIGSEGV at the next launch with - * detect.crash=false ("Native crash: null pointer dereference", WOD_LX1): - * bugsee-android#220, fixed in 7.3.1 -- flip this pin with that adoption. + * Android 7.3.0 still recovered a native SIGSEGV at the next launch with + * detect.crash=false (bugsee-android#220); 7.3.1 does not (#227, WOD_LX1). */ - (HERE === 'A' ? it.failing : on(['X']))(`[OPT-025] detect.crash=false: a native crash is not reported at the next launch${HERE === 'A' ? ' [known: Android NDK crashes ignore detect.crash=false (bugsee-android#220)]' : ''}`, async () => { + on(['A', 'X'])('[OPT-025] detect.crash=false: a native crash is not reported at the next launch', async () => { const crashes = await crashesAfter(async () => { const native = await launch('crash-off', NATIVE_CRASH); expect(jsonAfter(native.options.text, 'values')).toEqual({ 'com.bugsee.option.detect.crash': false }); @@ -419,8 +418,14 @@ describeDevice(`launch option effects on ${TARGET_NAME}`, () => { // Android only: there the app is driven to the background and back; iOS // runs undriven differ only by chance (simulator: 2 crumbs with extras, 12 // without, the control's being http crumbs). - /** Android 7.3.0 records no SDK breadcrumb at all (sdk-breadcrumbs.test.ts, N-11): nothing to add to. */ - (HERE === 'A' ? it.failing : it.skip)('[OPT-002] capture.breadcrumbs.extras adds SDK breadcrumbs the control does not record [known: Android 7.3.0 records no SDK breadcrumbs (bugsee-android#219)]', async () => { + /** + * Android 7.3.0 recorded no SDK breadcrumb at all (bugsee-android#219), so + * this was pinned. 7.3.1 records them, and shows what the option does: + * BreadcrumbSystemEvents copies the broadcast intent's extras into the + * system crumb's data as `extras`. It adds no crumbs (WOD_LX1: the same + * five kinds, 16 crumbs, in both runs), so that is the witness. + */ + (HERE === 'A' ? it : it.skip)('[OPT-002] capture.breadcrumbs.extras attaches the intent extras to system breadcrumbs, which the control does not', async () => { // Android is sent to the background and back; iOS is not driven // (switching apps loses the run's console) and uploads after 4 s. const step = ON_ANDROID ? 'background' : 'run'; @@ -452,7 +457,11 @@ describeDevice(`launch option effects on ${TARGET_NAME}`, () => { const control = await crumbsOf('crumb-extras-control'); const kinds = (crumbs: Array>) => [...new Set(crumbs.map(c => `${String(c.type)}/${String(c.category)}`))].sort(); report('crumb kinds', { extras: kinds(extras), control: kinds(control), counts: { extras: extras.length, control: control.length } }); - expect(extras.length).toBeGreaterThan(control.length); + const carryingExtras = (crumbs: Array>) => + crumbs.filter(c => c.type === 'system' && /"extras":\{"/.test(JSON.stringify(c))); + report('system crumbs with extras', { extras: carryingExtras(extras).length, control: carryingExtras(control).length }); + expect(carryingExtras(control)).toEqual([]); + expect(carryingExtras(extras).length).toBeGreaterThan(0); }); on(['A'])('[OPT-045][OPT-047] reporting.ui.labels-enabled and priority-selector-enabled add fields to the dialog', async () => { @@ -606,10 +615,9 @@ describeDevice(`launch option effects on ${TARGET_NAME}`, () => { /** * Out of process, Android assembles the report through JobScheduler. On - * the WOD_LX1 (7.3.0, 2026-10-07) no report is filed within 90 s, in - * airplane mode or with the network on (dead endpoint): the option is read - * back as set, but the upload never becomes a bundle. Filed: - * bugsee-android#221, pending the SDK team's view of the job's constraints. + * the WOD_LX1 7.3.0 filed no report within 90 s (bugsee-android#221); + * 7.3.1 keeps the BackgroundJob constructors the job needs (#229) and the + * report is filed. */ const outOfProcess = async (): Promise => { const key = 'out-of-process--run'; @@ -640,7 +648,7 @@ describeDevice(`launch option effects on ${TARGET_NAME}`, () => { expect(jsonAfter(options.text, 'values')).toEqual({ 'com.bugsee.option.config.report-processing-in-process': false }); }); - (HERE === 'A' ? it.failing : it.skip)('[OPT-081] config.report-processing-in-process=false still files the report (network on, dead endpoint) [known: Android 7.3.0 files no report out of process (bugsee-android#221)]', async () => { + on(['A'])('[OPT-081] config.report-processing-in-process=false still files the report (network on, dead endpoint)', async () => { const { run, bundles } = await outOfProcess(); expect(bundles.map(b => b.request.summary)).toContain(`api-eff-out-of-process-${run.scenario.nonce}`); }); diff --git a/examples/bare/e2e/options-readback.test.ts b/examples/bare/e2e/options-readback.test.ts index 83abc204..8121179d 100644 --- a/examples/bare/e2e/options-readback.test.ts +++ b/examples/bare/e2e/options-readback.test.ts @@ -13,9 +13,9 @@ * setCustomOption -- reads each back, refreshes a fresh * options object from the SDK and reads its getters, and * uploads one report, whose `environment.sdk.options` - * must record each value. Android writes an enum there as - * its constant's ORDINAL (bug 10, bugsee-android#216): - * asserted as that rule, not hidden. + * must record each value. Android 7.3.0 wrote an enum + * there as its constant's ORDINAL (bug 10, + * bugsee-android#216); 7.3.1 writes the value (#223). * api-opt-enums relaunches once per internal value of each enum key * and reads each back. * @@ -34,9 +34,7 @@ import { READBACK_IOS, READBACK_SHARED, } from '../scenarios/api-constants'; -import ENUMS from '../../../packages/react-native/src/options/option-enums.json'; import KEYS from '../../../packages/react-native/src/options/option-keys.json'; -import ANDROID_MANIFEST from '../../../packages/react-native/src/options/android-options-manifest.json'; import { ON_IOS, type Run, TARGET_NAME, awaitBundles, describeDevice, listBundles, report, startRun, stopApp } from './harness'; import { beginRetainingSuite, endRetainingSuite } from './observe'; import { type DeviceLog } from './scenario'; @@ -59,18 +57,6 @@ const KNOWN_READBACK: Record<'android' | 'ios', Record> = { }; const ACCESSORS: Record = { ...ACCESSOR_KEYS.shared, ...(ON_IOS ? ACCESSOR_KEYS.ios : ACCESSOR_KEYS.android) }; -/** Android enum keys and their enum, from the SDK's option manifest. */ -const ANDROID_ENUM_OF: Record = Object.fromEntries( - (ANDROID_MANIFEST.options as Array<{ key: string; type: string; enum?: { name: string } }>) - .filter(o => o.type === 'enum' && o.enum !== undefined) - .map(o => [o.key, o.enum!.name as keyof typeof ENUMS]), -); - -/** The ordinal Android 7.3.0 records for an enum value (constants in value order). */ -function androidOrdinal(key: string, value: number): number { - const values = Object.values(ENUMS[ANDROID_ENUM_OF[key]!] as Record).sort((a, b) => a - b); - return values.indexOf(value); -} /** Every ` chunk ... values={...}` part of a run, merged. */ function chunks(log: DeviceLog, tag: string, nonce: string, from: number): Record { @@ -157,11 +143,7 @@ describeDevice(`every launch option read back on ${TARGET_NAME}`, () => { for (const [key, value] of Object.entries(OWN_SET)) { const candidates = [key, key.replace(/\./g, ':'), key.replace('com.bugsee.option.', '')]; const found = candidates.find(candidate => candidate in recorded); - let expected: unknown = value; - if (!ON_IOS && ANDROID_ENUM_OF[key] !== undefined) { - // Bug 10 (bugsee-android#216): Android records the ordinal. - expected = androidOrdinal(key, value as number); - } + const expected: unknown = value; if (found === undefined || !same(recorded[found], expected)) { misses.push({ key, expected, recorded: found === undefined ? '(absent)' : recorded[found] }); } diff --git a/examples/bare/e2e/report-dialog-keys.test.ts b/examples/bare/e2e/report-dialog-keys.test.ts index d2b9319f..02f947ae 100644 --- a/examples/bare/e2e/report-dialog-keys.test.ts +++ b/examples/bare/e2e/report-dialog-keys.test.ts @@ -54,10 +54,9 @@ function blend(top: string, under: string, alpha: number): string { * bugsee-cocoa#201. */ const KNOWN_NOT_PAINTED: Record<'android' | 'ios', Record> = { - // Android 7.3.0: nothing in the library reads Report::ActionBarColor (only - // the constant exists); the bar stays the theme's near-black while - // ActionBarTextColor paints its text (WOD_LX1, 2026-10-07). Filed: bugsee-android#217. - android: { actionBarColor: 'Android 7.3.0 never reads Report::ActionBarColor (bugsee-android#217)' }, + // Android 7.3.0 never read Report::ActionBarColor (bugsee-android#217); + // 7.3.1 paints the dialog bar with it (#224, WOD_LX1). + android: {}, ios: { closeButtonColor: 'iOS beta5 never applies reportCloseButtonColor (bugsee-cocoa#201)', sendButtonColor: 'iOS beta5 never applies reportSendButtonColor (bugsee-cocoa#201)', diff --git a/examples/bare/e2e/sdk-breadcrumbs.test.ts b/examples/bare/e2e/sdk-breadcrumbs.test.ts index f5faae4b..36166acf 100644 --- a/examples/bare/e2e/sdk-breadcrumbs.test.ts +++ b/examples/bare/e2e/sdk-breadcrumbs.test.ts @@ -58,13 +58,11 @@ describeDevice(`SDK breadcrumbs and a plain upload's video on ${TARGET_NAME}`, ( afterAll(() => endRetainingSuite(log)); /** - * Android 7.3.0 records no breadcrumb of its own with capture.breadcrumbs - * on -- none at launch, none for the app going to the background and back - * (Home, then the activity) -- in every run on the WOD_LX1 (2026-10-07), - * while its producers (BreadcrumbApp app.lifecycle, BreadcrumbUI) exist. - * iOS records ui.screen and ui.lifecycle crumbs. Filed: bugsee-android#219. + * Android 7.3.0 recorded no breadcrumb of its own with capture.breadcrumbs + * on (bugsee-android#219); 7.3.1 does again (#228, WOD_LX1). iOS records + * ui.screen and ui.lifecycle crumbs. */ - (ON_ANDROID ? it.failing : it)(`[FLOW-15] the report carries breadcrumbs the SDK wrote itself, next to the app's own${ON_ANDROID ? ' [known: Android 7.3.0 records no SDK breadcrumbs (bugsee-android#219)]' : ''}`, () => { + it(`[FLOW-15] the report carries breadcrumbs the SDK wrote itself, next to the app's own`, () => { const crumbs = captureEvents(bundle, 'breadcrumbs'); const own = crumbs.filter(c => JSON.stringify(c).includes(`api-own-crumb ${run.scenario.nonce}`)); const sdk = crumbs.filter(c => !JSON.stringify(c).includes('api-own-crumb'));