diff --git a/docs/design/plans/2026-09-16-implementation-plan.md b/docs/design/plans/2026-09-16-implementation-plan.md index 9d1320a7..f7c3a890 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. - [x] **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/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 71bb4095..6b0ce934 100644 --- a/examples/bare/scripts/write-credentials.mjs +++ b/examples/bare/scripts/write-credentials.mjs @@ -8,8 +8,17 @@ * 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 + * (`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=… \ @@ -21,6 +30,11 @@ import { fileURLToPath } from 'node:url'; 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 @@ -68,13 +82,17 @@ 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}`, - '', - ].join('\n'), -); +const properties = [ + '# Generated by scripts/write-credentials.mjs. Never commit this file.', + `app_token=${androidToken}`, +]; +if (androidToken && !isPlaceholderToken(androidToken)) { + properties.push('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 c401880d..41d76993 100644 --- a/scripts/__tests__/write-credentials.test.ts +++ b/scripts/__tests__/write-credentials.test.ts @@ -13,12 +13,18 @@ import { join } from 'node:path'; */ const SCRIPT = join(__dirname, '..', '..', 'examples', 'bare', 'scripts', 'write-credentials.mjs'); 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')); +} 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 +56,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' }, @@ -62,4 +67,61 @@ describe('write-credentials and the e2e scenario file', () => { rmSync(root, { recursive: true, force: true }); } }); + + function generate(overrides: NodeJS.ProcessEnv): { output: string; props: string } { + const root = mkdtempSync(join(tmpdir(), 'write-credentials-')); + try { + 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 }; + if (!Object.hasOwn(overrides, 'BUGSEE_ENDPOINT')) { + delete env.BUGSEE_ENDPOINT; + } + const output = execFileSync(process.execPath, [join(root, 'scripts', 'write-credentials.mjs')], { + env, + encoding: 'utf8', + }); + const props = readFileSync(join(root, 'android', 'bugsee.properties'), 'utf8'); + 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); + }); + + 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); + }); });