Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/plans/2026-09-16-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions examples/bare/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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'
}
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 28 additions & 10 deletions examples/bare/scripts/write-credentials.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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=… \
Expand All @@ -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
Expand Down Expand Up @@ -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');
70 changes: 66 additions & 4 deletions scripts/__tests__/write-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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' },
Expand All @@ -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);
});
});
Loading