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/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). } } } 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/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/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-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/feedback.test.ts b/examples/bare/e2e/feedback.test.ts index 1c4a6aa2..85a8d2fb 100644 --- a/examples/bare/e2e/feedback.test.ts +++ b/examples/bare/e2e/feedback.test.ts @@ -44,10 +44,12 @@ 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 (#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 = ON_ANDROID ? it.failing : it; @@ -103,8 +105,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/launch-options.test.ts b/examples/bare/e2e/launch-options.test.ts index 0e9531b6..01717db7 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'); }, }, @@ -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); + }, + ); }); 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/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')); 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/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/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/README.md b/packages/react-native/README.md index 5fe39595..fd890a80 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -208,13 +208,15 @@ 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 } ``` `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/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/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 = ( 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..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 = { @@ -8,8 +11,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'); @@ -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..234419ac --- /dev/null +++ b/scripts/__tests__/performance-capture.test.ts @@ -0,0 +1,154 @@ +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: 'a111111111111111', name: 'txn-start' }, [ + str('bugsee.operation', 'txn-renamed'), + str('bugsee.span.status', 'ERROR'), + str('bugsee.description', 'txn-desc'), +]); +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: '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'), + 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).toStrictEqual({ + name: 'txn-start', + operation: 'txn-renamed', + status: 'ERROR', + spans: [ + { + spanId: 'a111111111111111', + operation: 'txn-renamed', + status: 'ERROR', + description: 'txn-desc', + attributes: { 'bugsee.operation': 'txn-renamed', 'bugsee.span.status': 'ERROR', 'bugsee.description': 'txn-desc' }, + }, + { + 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: 'c333333333333333', + parentSpanId: 'b222222222222222', + operation: 'http.client', + status: 'OK', + // url.full comes before bugsee.description. + description: 'https://127.0.0.1:9/x', + attributes: { + 'bugsee.operation': 'http.client', + 'bugsee.span.status': 'OK', + 'url.full': 'https://127.0.0.1:9/x', + 'bugsee.description': 'not this', + }, + }, + ], + }); + expect(other).toStrictEqual({ + 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.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, 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: '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]]); + }); + + 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: {} })).toStrictEqual([]); + expect(otlpValue({ kvlistValue: { values: [{ key: 'k', value: { boolValue: true } }] } })).toEqual({ k: true }); + expect(otlpValue({ kvlistValue: {} })).toStrictEqual({}); + }); + + 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, + }; + }); +}