From 907fcec34face23eb6a9e506da29dcb7f2d68fdd Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 3 Oct 2026 00:06:49 +0500 Subject: [PATCH 1/4] Task 13.3: default NDK symbol upload on for React Native. Every RN app ships Hermes and libreactnative.so it did not write, so the generated root bugsee.properties sets plugin.ndk.enabled beside the unprefixed app_token. The wrapper already exposes bugsee-android-ndk as an api dependency, so the example app does not declare it again. Co-authored-by: Cursor --- .../plans/2026-09-16-implementation-plan.md | 2 +- examples/bare/scripts/write-credentials.mjs | 10 ++++++-- scripts/__tests__/write-credentials.test.ts | 23 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/design/plans/2026-09-16-implementation-plan.md b/docs/design/plans/2026-09-16-implementation-plan.md index af3350d2..85b76e74 100644 --- a/docs/design/plans/2026-09-16-implementation-plan.md +++ b/docs/design/plans/2026-09-16-implementation-plan.md @@ -4065,7 +4065,7 @@ The phase with the most native↔JS round-tripping, hence the most device testin - [x] **13.1** Source maps: `bugsee-cli sourcemaps inject` then `debug-files upload --type sourcemaps`. Injection must happen on the **composed** Hermes map, after `compose-source-maps.js`, or the debug ID lands on a map nothing consults. - [ ] **13.2** dSYM upload as an Xcode **scheme post-action** running `bugsee-cli xcode post-action`. A build phase also works but needs `BUGSEE_BUILD_INFO_ALL_ACTIONS=1`, and Xcode 15+ defaults `ENABLE_USER_SCRIPT_SANDBOXING` to `YES`, which blocks it. -- [ ] **13.3** Android mapping and NDK symbols — the Gradle plugin's job once applied. Write `android/bugsee.properties` with the **unprefixed** `app_token=` key (not `plugin.appToken`) at the *root* project, and default `plugin.ndk.enabled=true` for RN, since every RN app ships Hermes and `libreactnative.so` it did not write. +- [x] **13.3** Android mapping and NDK symbols — the Gradle plugin's job once applied. Write `android/bugsee.properties` with the **unprefixed** `app_token=` key (not `plugin.appToken`) at the *root* project, and default `plugin.ndk.enabled=true` for RN, since every RN app ships Hermes and `libreactnative.so` it did not write. - [ ] **13.4** Expo config plugin, with a `prebuild --clean` test — the `.xcscheme` edit is the most fragile part and Expo has no helper for it. - [ ] **13.5** End-to-end: a release build whose JS stack symbolicates in the dashboard. This is the only test that proves the whole chain. - [ ] Review gate. diff --git a/examples/bare/scripts/write-credentials.mjs b/examples/bare/scripts/write-credentials.mjs index 71bb4095..9b66d707 100644 --- a/examples/bare/scripts/write-credentials.mjs +++ b/examples/bare/scripts/write-credentials.mjs @@ -8,8 +8,13 @@ * credentials.json read by App.tsx at runtime, for Bugsee.launch() * android/bugsee.properties read by the Bugsee Gradle plugin at build time * - * The Gradle plugin's key is the unprefixed `app_token`, and the file belongs - * to the *root* Gradle project (examples/bare/android), not to :app. + * The Gradle plugin's token key is the unprefixed `app_token` (not + * `plugin.appToken`), and the file belongs to the *root* Gradle project + * (examples/bare/android), not to :app. NDK symbol upload is opt-in in the + * SDK and on by default here: every React Native app ships Hermes and + * libreactnative.so it did not write, so the file also sets + * `plugin.ndk.enabled=true`. Native crash detection is the separate + * `com.bugsee:bugsee-android-ndk` artifact; this flag only enables upload. * * Usage: * BUGSEE_TOKEN_IOS=… BUGSEE_TOKEN_ANDROID=… BUGSEE_ENDPOINT=… \ @@ -73,6 +78,7 @@ writeFileSync( [ '# Generated by scripts/write-credentials.mjs. Never commit this file.', `app_token=${androidToken}`, + 'plugin.ndk.enabled=true', '', ].join('\n'), ); diff --git a/scripts/__tests__/write-credentials.test.ts b/scripts/__tests__/write-credentials.test.ts index c401880d..cdfd3aa2 100644 --- a/scripts/__tests__/write-credentials.test.ts +++ b/scripts/__tests__/write-credentials.test.ts @@ -62,4 +62,27 @@ describe('write-credentials and the e2e scenario file', () => { rmSync(root, { recursive: true, force: true }); } }); + + it('writes app_token and enables NDK symbol upload for React Native', () => { + const root = mkdtempSync(join(tmpdir(), 'write-credentials-')); + const androidToken = 'fake-android-token'; + try { + mkdirSync(join(root, 'scripts')); + copyFileSync(SCRIPT, join(root, 'scripts', 'write-credentials.mjs')); + const output = execFileSync(process.execPath, [join(root, 'scripts', 'write-credentials.mjs')], { + env: { ...process.env, BUGSEE_TOKEN_IOS: '', BUGSEE_TOKEN_ANDROID: androidToken }, + encoding: 'utf8', + }); + const props = readFileSync(join(root, 'android', 'bugsee.properties'), 'utf8'); + const lines = props.split('\n'); + const tokenLine = lines.find((line) => line.startsWith('app_token=')); + expect(tokenLine !== undefined).toBe(true); + expect(tokenLine === `app_token=${androidToken}`).toBe(true); + expect(lines.includes('plugin.ndk.enabled=true')).toBe(true); + expect(props.includes('plugin.appToken')).toBe(false); + expect(output.includes(androidToken)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); From 877cefa1f51bed00c32909bb6a8b43d71e5f8a06 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 3 Oct 2026 00:24:26 +0500 Subject: [PATCH 2/4] Task 13.3: emit native symbols and pin the NDK artifact. AGP's debugSymbolLevel defaults to none, so the plugin's native upload skips. SYMBOL_TABLE lets AGP emit symbols for code this app builds; pre-stripped Hermes and libreactnative.so stay unsymbolicated. A set endpoint is written as plugin.endpoint, and :app declares bugsee-android-ndk at the native-versions pin so the plugin does not substitute its version range. Co-authored-by: Cursor --- examples/bare/android/app/build.gradle | 22 ++++++++++ examples/bare/scripts/write-credentials.mjs | 30 +++++++------ scripts/__tests__/write-credentials.test.ts | 47 ++++++++++++++++----- 3 files changed, 76 insertions(+), 23 deletions(-) diff --git a/examples/bare/android/app/build.gradle b/examples/bare/android/app/build.gradle index 7f4c4a76..e5b092ad 100644 --- a/examples/bare/android/app/build.gradle +++ b/examples/bare/android/app/build.gradle @@ -101,6 +101,13 @@ 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' + } } release { // Caution! In production, you need to generate your own keystore file. @@ -114,6 +121,12 @@ 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' + } } } } @@ -179,10 +192,19 @@ afterEvaluate { } } +// Same file the root project parses to check the Gradle plugin pin. +def nativeVersions = new groovy.json.JsonSlurper().parse(rootProject.file("../../../native-versions.json")) + dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") + // plugin.ndk.enabled makes the plugin add bugsee-android-ndk on its own + // version range, because the wrapper's api dependency is not a direct + // dependency of :app. Declare it here at native-versions.json's + // android.sdk, the same pin the wrapper uses. + implementation "com.bugsee:bugsee-android-ndk:${nativeVersions.android.sdk}" + if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") } else { diff --git a/examples/bare/scripts/write-credentials.mjs b/examples/bare/scripts/write-credentials.mjs index 9b66d707..40b26723 100644 --- a/examples/bare/scripts/write-credentials.mjs +++ b/examples/bare/scripts/write-credentials.mjs @@ -11,10 +11,13 @@ * The Gradle plugin's token key is the unprefixed `app_token` (not * `plugin.appToken`), and the file belongs to the *root* Gradle project * (examples/bare/android), not to :app. NDK symbol upload is opt-in in the - * SDK and on by default here: every React Native app ships Hermes and - * libreactnative.so it did not write, so the file also sets - * `plugin.ndk.enabled=true`. Native crash detection is the separate - * `com.bugsee:bugsee-android-ndk` artifact; this flag only enables upload. + * SDK and on by default here (`plugin.ndk.enabled=true`), because every + * React Native app ships native libraries it did not write. That flag also + * makes the plugin add `bugsee-android-ndk` on a version range unless :app + * declares the artifact itself, which this app does at the pinned SDK + * version. A non-empty BUGSEE_ENDPOINT is written as `plugin.endpoint` + * with no `/v2` suffix; an empty one is omitted so the plugin default + * stands. * * Usage: * BUGSEE_TOKEN_IOS=… BUGSEE_TOKEN_ANDROID=… BUGSEE_ENDPOINT=… \ @@ -73,14 +76,15 @@ writeFileSync( // The Gradle plugin reads this at configure time. It has to exist even for a // build that will not upload anything, or the plugin fails the build. mkdirSync(join(appRoot, 'android'), { recursive: true }); -writeFileSync( - join(appRoot, 'android', 'bugsee.properties'), - [ - '# Generated by scripts/write-credentials.mjs. Never commit this file.', - `app_token=${androidToken}`, - 'plugin.ndk.enabled=true', - '', - ].join('\n'), -); +const properties = [ + '# Generated by scripts/write-credentials.mjs. Never commit this file.', + `app_token=${androidToken}`, + 'plugin.ndk.enabled=true', +]; +if (endpoint) { + properties.push(`plugin.endpoint=${endpoint}`); +} +properties.push(''); +writeFileSync(join(appRoot, 'android', 'bugsee.properties'), properties.join('\n')); console.log('write-credentials: wrote credentials.json and android/bugsee.properties'); diff --git a/scripts/__tests__/write-credentials.test.ts b/scripts/__tests__/write-credentials.test.ts index cdfd3aa2..358db9a9 100644 --- a/scripts/__tests__/write-credentials.test.ts +++ b/scripts/__tests__/write-credentials.test.ts @@ -63,26 +63,53 @@ describe('write-credentials and the e2e scenario file', () => { } }); - it('writes app_token and enables NDK symbol upload for React Native', () => { + function generate(overrides: NodeJS.ProcessEnv): { output: string; props: string } { const root = mkdtempSync(join(tmpdir(), 'write-credentials-')); - const androidToken = 'fake-android-token'; try { mkdirSync(join(root, 'scripts')); copyFileSync(SCRIPT, join(root, 'scripts', 'write-credentials.mjs')); + // Drop a shell endpoint unless this case sets one, so a real + // BUGSEE_ENDPOINT cannot leak into the generated file or the output. + const env: NodeJS.ProcessEnv = { ...process.env, BUGSEE_TOKEN_IOS: '', ...overrides }; + if (!Object.hasOwn(overrides, 'BUGSEE_ENDPOINT')) { + delete env.BUGSEE_ENDPOINT; + } const output = execFileSync(process.execPath, [join(root, 'scripts', 'write-credentials.mjs')], { - env: { ...process.env, BUGSEE_TOKEN_IOS: '', BUGSEE_TOKEN_ANDROID: androidToken }, + env, encoding: 'utf8', }); const props = readFileSync(join(root, 'android', 'bugsee.properties'), 'utf8'); - const lines = props.split('\n'); - const tokenLine = lines.find((line) => line.startsWith('app_token=')); - expect(tokenLine !== undefined).toBe(true); - expect(tokenLine === `app_token=${androidToken}`).toBe(true); - expect(lines.includes('plugin.ndk.enabled=true')).toBe(true); - expect(props.includes('plugin.appToken')).toBe(false); - expect(output.includes(androidToken)).toBe(false); + return { output, props }; } finally { rmSync(root, { recursive: true, force: true }); } + } + + it('writes app_token and enables NDK symbol upload for React Native', () => { + const androidToken = 'fake-android-token'; + const { output, props } = generate({ BUGSEE_TOKEN_ANDROID: androidToken }); + const lines = props.split('\n'); + const tokenLine = lines.find((line) => line.startsWith('app_token=')); + expect(tokenLine !== undefined).toBe(true); + expect(tokenLine === `app_token=${androidToken}`).toBe(true); + expect(lines.includes('plugin.ndk.enabled=true')).toBe(true); + expect(props.includes('plugin.appToken')).toBe(false); + expect(lines.some((line) => line.startsWith('plugin.endpoint='))).toBe(false); + expect(output.includes(androidToken)).toBe(false); + }); + + it('writes plugin.endpoint when an endpoint is set, and does not append /v2', () => { + const androidToken = 'fake-android-token'; + const endpoint = 'https://endpoint.example'; + const { output, props } = generate({ + BUGSEE_TOKEN_IOS: '', + BUGSEE_TOKEN_ANDROID: androidToken, + BUGSEE_ENDPOINT: endpoint, + }); + const lines = props.split('\n'); + expect(lines.includes(`plugin.endpoint=${endpoint}`)).toBe(true); + expect(lines.some((line) => line.startsWith('plugin.endpoint=') && line.endsWith('/v2'))).toBe(false); + expect(output.includes(androidToken)).toBe(false); + expect(output.includes(endpoint)).toBe(false); }); }); From ee63ad73106333770413dce03b7d6d8c45a3bffd Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 3 Oct 2026 01:04:29 +0500 Subject: [PATCH 3/4] Task 13.3: skip NDK upload for the CI placeholder token. assembleDebug runs the native upload once symbols exist, and CI's placeholder token would otherwise hit the plugin's default host. Write plugin.ndk.enabled only for a non-empty Android token that isPlaceholderToken does not match. Co-authored-by: Cursor --- examples/bare/scripts/write-credentials.mjs | 24 ++++++++++++------- scripts/__tests__/write-credentials.test.ts | 26 ++++++++++++++++----- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/examples/bare/scripts/write-credentials.mjs b/examples/bare/scripts/write-credentials.mjs index 40b26723..b23b4eb5 100644 --- a/examples/bare/scripts/write-credentials.mjs +++ b/examples/bare/scripts/write-credentials.mjs @@ -10,14 +10,15 @@ * * The Gradle plugin's token key is the unprefixed `app_token` (not * `plugin.appToken`), and the file belongs to the *root* Gradle project - * (examples/bare/android), not to :app. NDK symbol upload is opt-in in the - * SDK and on by default here (`plugin.ndk.enabled=true`), because every - * React Native app ships native libraries it did not write. That flag also - * makes the plugin add `bugsee-android-ndk` on a version range unless :app - * declares the artifact itself, which this app does at the pinned SDK - * version. A non-empty BUGSEE_ENDPOINT is written as `plugin.endpoint` - * with no `/v2` suffix; an empty one is omitted so the plugin default - * stands. + * (examples/bare/android), not to :app. NDK symbol upload + * (`plugin.ndk.enabled=true`) is written only when BUGSEE_TOKEN_ANDROID is + * non-empty and isPlaceholderToken (endpoint.ts) is false. CI's placeholder + * token would otherwise make assembleDebug upload. Omit the key otherwise + * so the plugin default, off, stands. That flag also makes the plugin add + * `bugsee-android-ndk` on a version range unless :app declares the artifact + * itself, which this app does at the pinned SDK version. A non-empty + * BUGSEE_ENDPOINT is written as `plugin.endpoint` with no `/v2` suffix; an + * empty one is omitted so the plugin default stands. * * Usage: * BUGSEE_TOKEN_IOS=… BUGSEE_TOKEN_ANDROID=… BUGSEE_ENDPOINT=… \ @@ -26,6 +27,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isPlaceholderToken } from '../endpoint.ts'; const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -79,8 +81,12 @@ mkdirSync(join(appRoot, 'android'), { recursive: true }); const properties = [ '# Generated by scripts/write-credentials.mjs. Never commit this file.', `app_token=${androidToken}`, - 'plugin.ndk.enabled=true', ]; +// Same rule as isPlaceholderToken: a placeholder never reaches the real +// server. With the key omitted, the plugin leaves NDK upload off. +if (androidToken && !isPlaceholderToken(androidToken)) { + properties.push('plugin.ndk.enabled=true'); +} if (endpoint) { properties.push(`plugin.endpoint=${endpoint}`); } diff --git a/scripts/__tests__/write-credentials.test.ts b/scripts/__tests__/write-credentials.test.ts index 358db9a9..f792ad97 100644 --- a/scripts/__tests__/write-credentials.test.ts +++ b/scripts/__tests__/write-credentials.test.ts @@ -12,13 +12,21 @@ import { join } from 'node:path'; * by hand, not just by the e2e -- would then point the SDK at a closed port. */ const SCRIPT = join(__dirname, '..', '..', 'examples', 'bare', 'scripts', 'write-credentials.mjs'); +const ENDPOINT = join(__dirname, '..', '..', 'examples', 'bare', 'endpoint.ts'); const DEFAULT = { scenario: 'launch' }; +/** CI's BUGSEE_TOKEN_ANDROID. A placeholder, not a credential. */ +const CI_ANDROID_TOKEN = '00000000-0000-4000-8000-000000000000'; + +function stage(root: string): void { + mkdirSync(join(root, 'scripts'), { recursive: true }); + copyFileSync(SCRIPT, join(root, 'scripts', 'write-credentials.mjs')); + copyFileSync(ENDPOINT, join(root, 'endpoint.ts')); +} function run(scenario?: unknown): unknown { const root = mkdtempSync(join(tmpdir(), 'write-credentials-')); try { - mkdirSync(join(root, 'scripts')); - copyFileSync(SCRIPT, join(root, 'scripts', 'write-credentials.mjs')); + stage(root); const file = join(root, 'e2e-scenario.json'); if (scenario !== undefined) { writeFileSync(file, `${JSON.stringify(scenario)}\n`); @@ -50,8 +58,7 @@ describe('write-credentials and the e2e scenario file', () => { it('resets a file that is not valid JSON', () => { const root = mkdtempSync(join(tmpdir(), 'write-credentials-')); try { - mkdirSync(join(root, 'scripts')); - copyFileSync(SCRIPT, join(root, 'scripts', 'write-credentials.mjs')); + stage(root); writeFileSync(join(root, 'e2e-scenario.json'), '{"scenario":'); execFileSync(process.execPath, [join(root, 'scripts', 'write-credentials.mjs')], { env: { ...process.env, BUGSEE_TOKEN_IOS: 'ios-token' }, @@ -66,8 +73,7 @@ describe('write-credentials and the e2e scenario file', () => { function generate(overrides: NodeJS.ProcessEnv): { output: string; props: string } { const root = mkdtempSync(join(tmpdir(), 'write-credentials-')); try { - mkdirSync(join(root, 'scripts')); - copyFileSync(SCRIPT, join(root, 'scripts', 'write-credentials.mjs')); + stage(root); // Drop a shell endpoint unless this case sets one, so a real // BUGSEE_ENDPOINT cannot leak into the generated file or the output. const env: NodeJS.ProcessEnv = { ...process.env, BUGSEE_TOKEN_IOS: '', ...overrides }; @@ -112,4 +118,12 @@ describe('write-credentials and the e2e scenario file', () => { expect(output.includes(androidToken)).toBe(false); expect(output.includes(endpoint)).toBe(false); }); + + it('does not enable NDK upload for the CI placeholder token', () => { + const { output, props } = generate({ BUGSEE_TOKEN_ANDROID: CI_ANDROID_TOKEN }); + const lines = props.split('\n'); + expect(lines.some((line) => line.startsWith('plugin.ndk.enabled'))).toBe(false); + expect(lines.some((line) => line.startsWith('app_token='))).toBe(true); + expect(output.includes(CI_ANDROID_TOKEN)).toBe(false); + }); }); From 3c1fe1ecdfac493bc315f5a758cc68e0b9b7fb29 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 3 Oct 2026 01:05:47 +0500 Subject: [PATCH 4/4] Task 13.3: keep the placeholder check in plain JavaScript. write-credentials.mjs runs under plain node, which rejects a .ts import. Copy isPlaceholderToken's regex into the script so a CI placeholder still leaves NDK upload off. Co-authored-by: Cursor --- examples/bare/scripts/write-credentials.mjs | 8 +++++--- scripts/__tests__/write-credentials.test.ts | 2 -- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/bare/scripts/write-credentials.mjs b/examples/bare/scripts/write-credentials.mjs index b23b4eb5..6b0ce934 100644 --- a/examples/bare/scripts/write-credentials.mjs +++ b/examples/bare/scripts/write-credentials.mjs @@ -27,10 +27,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { isPlaceholderToken } from '../endpoint.ts'; const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +// Same check as isPlaceholderToken in ../endpoint.ts. +function isPlaceholderToken(token) { + return /^0{12}[0-9a-f]0{3}[0-9a-f]0{15}$/i.test(token.replace(/-/g, '')); +} + // App.tsx imports e2e-scenario.json, so the bundle does not build without it. // The device e2e overwrites it per run; left alone, the app runs the plain // launch walk. Written when absent, and otherwise left alone so a scenario @@ -82,8 +86,6 @@ const properties = [ '# Generated by scripts/write-credentials.mjs. Never commit this file.', `app_token=${androidToken}`, ]; -// Same rule as isPlaceholderToken: a placeholder never reaches the real -// server. With the key omitted, the plugin leaves NDK upload off. if (androidToken && !isPlaceholderToken(androidToken)) { properties.push('plugin.ndk.enabled=true'); } diff --git a/scripts/__tests__/write-credentials.test.ts b/scripts/__tests__/write-credentials.test.ts index f792ad97..41d76993 100644 --- a/scripts/__tests__/write-credentials.test.ts +++ b/scripts/__tests__/write-credentials.test.ts @@ -12,7 +12,6 @@ import { join } from 'node:path'; * by hand, not just by the e2e -- would then point the SDK at a closed port. */ const SCRIPT = join(__dirname, '..', '..', 'examples', 'bare', 'scripts', 'write-credentials.mjs'); -const ENDPOINT = join(__dirname, '..', '..', 'examples', 'bare', 'endpoint.ts'); const DEFAULT = { scenario: 'launch' }; /** CI's BUGSEE_TOKEN_ANDROID. A placeholder, not a credential. */ const CI_ANDROID_TOKEN = '00000000-0000-4000-8000-000000000000'; @@ -20,7 +19,6 @@ const CI_ANDROID_TOKEN = '00000000-0000-4000-8000-000000000000'; function stage(root: string): void { mkdirSync(join(root, 'scripts'), { recursive: true }); copyFileSync(SCRIPT, join(root, 'scripts', 'write-credentials.mjs')); - copyFileSync(ENDPOINT, join(root, 'endpoint.ts')); } function run(scenario?: unknown): unknown {