diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b688ba123..8a9eec2451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Add `disableAutoUpload` option to Expo plugin to disable source map and debug symbol uploads ([#6195](https://github.com/getsentry/sentry-react-native/pull/6195)) - Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192)) ## 8.12.0 diff --git a/packages/core/plugin/src/withSentry.ts b/packages/core/plugin/src/withSentry.ts index 67d8af3579..9db2b5352c 100644 --- a/packages/core/plugin/src/withSentry.ts +++ b/packages/core/plugin/src/withSentry.ts @@ -18,6 +18,7 @@ interface PluginProps { authToken?: string; url?: string; useNativeInit?: boolean; + disableAutoUpload?: boolean; options?: Record; experimental_android?: SentryAndroidGradlePluginOptions; } @@ -67,7 +68,11 @@ const withSentryPlugin: ConfigPlugin = (config, props) => { } if (sentryProperties !== null) { try { - cfg = withSentryAndroid(cfg, { sentryProperties, useNativeInit: props?.useNativeInit }); + cfg = withSentryAndroid(cfg, { + sentryProperties, + useNativeInit: props?.useNativeInit, + disableAutoUpload: props?.disableAutoUpload, + }); } catch (e) { warnOnce(`There was a problem with configuring your native Android project: ${e}`); } @@ -80,7 +85,11 @@ const withSentryPlugin: ConfigPlugin = (config, props) => { } } try { - cfg = withSentryIOS(cfg, { sentryProperties, useNativeInit: props?.useNativeInit }); + cfg = withSentryIOS(cfg, { + sentryProperties, + useNativeInit: props?.useNativeInit, + disableAutoUpload: props?.disableAutoUpload, + }); } catch (e) { warnOnce(`There was a problem with configuring your native iOS project: ${e}`); } diff --git a/packages/core/plugin/src/withSentryAndroid.ts b/packages/core/plugin/src/withSentryAndroid.ts index a9276d4c3c..b5dfc6f949 100644 --- a/packages/core/plugin/src/withSentryAndroid.ts +++ b/packages/core/plugin/src/withSentryAndroid.ts @@ -7,13 +7,14 @@ import * as path from 'path'; import { warnOnce } from './logger'; import { writeSentryPropertiesTo } from './utils'; -export const withSentryAndroid: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = ( - config, - { sentryProperties, useNativeInit = false }, -) => { +export const withSentryAndroid: ConfigPlugin<{ + sentryProperties: string; + useNativeInit: boolean | undefined; + disableAutoUpload: boolean | undefined; +}> = (config, { sentryProperties, useNativeInit = false, disableAutoUpload = false }) => { const appBuildGradleCfg = withAppBuildGradle(config, config => { if (config.modResults.language === 'groovy') { - config.modResults.contents = modifyAppBuildGradle(config.modResults.contents); + config.modResults.contents = modifyAppBuildGradle(config.modResults.contents, disableAutoUpload); } else { throw new Error('Cannot configure Sentry in the app gradle because the build.gradle is not groovy'); } @@ -39,8 +40,17 @@ const resolveSentryReactNativePackageJsonPath = * Writes to projectDirectory/android/app/build.gradle, * adding the relevant @sentry/react-native script. */ -export function modifyAppBuildGradle(buildGradle: string): string { +export function modifyAppBuildGradle(buildGradle: string, disableAutoUpload: boolean = false): string { if (buildGradle.includes('sentry.gradle')) { + if (disableAutoUpload && !buildGradle.includes('shouldSentryAutoUploadGeneral')) { + return buildGradle.replace( + /^(apply from:.*sentry\.gradle.*)$/m, + `$1\nproject.ext.shouldSentryAutoUploadGeneral = { -> return false }`, + ); + } + if (!disableAutoUpload && buildGradle.includes('shouldSentryAutoUploadGeneral')) { + return buildGradle.replace(/\nproject\.ext\.shouldSentryAutoUploadGeneral = \{ -> return false \}\n?/, '\n'); + } return buildGradle; } @@ -56,8 +66,11 @@ export function modifyAppBuildGradle(buildGradle: string): string { } const applyFrom = `apply from: new File(${resolveSentryReactNativePackageJsonPath}, "sentry.gradle")`; + const disableUploadOverride = disableAutoUpload + ? `\nproject.ext.shouldSentryAutoUploadGeneral = { -> return false }` + : ''; - return buildGradle.replace(pattern, match => `${applyFrom}\n\n${match}`); + return buildGradle.replace(pattern, match => `${applyFrom}${disableUploadOverride}\n\n${match}`); } export function modifyMainApplication(config: ExpoConfig): ExpoConfig { diff --git a/packages/core/plugin/src/withSentryIOS.ts b/packages/core/plugin/src/withSentryIOS.ts index 3cd6a2aa60..6acb2b135c 100644 --- a/packages/core/plugin/src/withSentryIOS.ts +++ b/packages/core/plugin/src/withSentryIOS.ts @@ -14,11 +14,13 @@ const SENTRY_REACT_NATIVE_XCODE_PATH = "`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'\"`"; const SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH = "`${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`"; +const SENTRY_DISABLE_AUTO_UPLOAD_EXPORT = 'export SENTRY_DISABLE_AUTO_UPLOAD=true'; -export const withSentryIOS: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = ( - config, - { sentryProperties, useNativeInit = false }, -) => { +export const withSentryIOS: ConfigPlugin<{ + sentryProperties: string; + useNativeInit: boolean | undefined; + disableAutoUpload: boolean | undefined; +}> = (config, { sentryProperties, useNativeInit = false, disableAutoUpload = false }) => { const xcodeProjectCfg = withXcodeProject(config, config => { const xcodeProject: XcodeProject = config.modResults; @@ -27,17 +29,24 @@ export const withSentryIOS: ConfigPlugin<{ sentryProperties: string; useNativeIn 'PBXShellScriptBuildPhase', ); if (!sentryBuildPhase) { + const debugFilesScript = disableAutoUpload + ? `${SENTRY_DISABLE_AUTO_UPLOAD_EXPORT}\n/bin/sh ${SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH}` + : `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH}`; xcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, { shellPath: '/bin/sh', - shellScript: `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH}`, + shellScript: debugFilesScript, }); + } else if (disableAutoUpload) { + addDisableAutoUploadToExistingScript(sentryBuildPhase); + } else { + removeDisableAutoUploadFromExistingScript(sentryBuildPhase); } const bundleReactNativePhase = xcodeProject.pbxItemByComment( 'Bundle React Native code and images', 'PBXShellScriptBuildPhase', ); - modifyExistingXcodeBuildScript(bundleReactNativePhase); + modifyExistingXcodeBuildScript(bundleReactNativePhase, disableAutoUpload); return config; }); @@ -53,7 +62,7 @@ export const withSentryIOS: ConfigPlugin<{ sentryProperties: string; useNativeIn ]); }; -export function modifyExistingXcodeBuildScript(script: BuildPhase): void { +export function modifyExistingXcodeBuildScript(script: BuildPhase, disableAutoUpload: boolean = false): void { if (!script.shellScript.match(/(packager|scripts)\/react-native-xcode\.sh\b/)) { warnOnce( `'react-native-xcode.sh' not found in 'Bundle React Native code and images'. @@ -63,7 +72,11 @@ Please open a bug report at https://github.com/getsentry/sentry-react-native`, } if (script.shellScript.includes('sentry-xcode.sh')) { - warnOnce("The latest 'sentry-xcode.sh' script already exists in 'Bundle React Native code and images'."); + if (disableAutoUpload) { + addDisableAutoUploadToExistingScript(script); + } else { + removeDisableAutoUploadFromExistingScript(script); + } return; } @@ -77,16 +90,52 @@ Run npx expo prebuild --clean`, } const code = JSON.parse(script.shellScript); - script.shellScript = JSON.stringify(addSentryWithBundledScriptsToBundleShellScript(code)); + script.shellScript = JSON.stringify(addSentryWithBundledScriptsToBundleShellScript(code, disableAutoUpload)); } -export function addSentryWithBundledScriptsToBundleShellScript(script: string): string { +export function addSentryWithBundledScriptsToBundleShellScript( + script: string, + disableAutoUpload: boolean = false, +): string { + const disableAutoUploadExport = disableAutoUpload ? `${SENTRY_DISABLE_AUTO_UPLOAD_EXPORT}\n` : ''; return script.replace( /^.*?(packager|scripts)\/react-native-xcode\.sh\s*(\\'\\\\")?/m, - (match: string) => `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_PATH} ${match}`, + (match: string) => `${disableAutoUploadExport}/bin/sh ${SENTRY_REACT_NATIVE_XCODE_PATH} ${match}`, ); } +export function addDisableAutoUploadToExistingScript(script: BuildPhase): void { + if (script.shellScript.includes('SENTRY_DISABLE_AUTO_UPLOAD')) { + return; + } + try { + const code = JSON.parse(script.shellScript); + script.shellScript = JSON.stringify(insertExportAfterDelimiter(code)); + } catch { + script.shellScript = `${SENTRY_DISABLE_AUTO_UPLOAD_EXPORT}\n${script.shellScript}`; + } +} + +function insertExportAfterDelimiter(script: string): string { + if (script.startsWith('"')) { + const rest = script.slice(1).replace(/^\n/, ''); + return `"\n${SENTRY_DISABLE_AUTO_UPLOAD_EXPORT}\n${rest}`; + } + return `${SENTRY_DISABLE_AUTO_UPLOAD_EXPORT}\n${script}`; +} + +export function removeDisableAutoUploadFromExistingScript(script: BuildPhase): void { + if (!script.shellScript.includes('SENTRY_DISABLE_AUTO_UPLOAD')) { + return; + } + try { + const code = JSON.parse(script.shellScript); + script.shellScript = JSON.stringify(code.replace(/^export SENTRY_DISABLE_AUTO_UPLOAD=true\n?/m, '')); + } catch { + script.shellScript = script.shellScript.replace(/^export SENTRY_DISABLE_AUTO_UPLOAD=true\n?/m, ''); + } +} + export function modifyAppDelegate(config: ExpoConfig): ExpoConfig { return withAppDelegate(config, async config => { if (!config.modResults?.path) { diff --git a/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts b/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts index 0dcc9b33d6..a7d24c159a 100644 --- a/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts +++ b/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts @@ -51,4 +51,51 @@ describe('Configures Android native project correctly', () => { modifyAppBuildGradle(buildGradleWithOutReactGradleScript); expect(warnOnce).toHaveBeenCalled(); }); + + it('Adds shouldSentryAutoUploadGeneral override when disableAutoUpload is true', () => { + const result = modifyAppBuildGradle(buildGradleWithOutSentry, true); + expect(result).toContain('project.ext.shouldSentryAutoUploadGeneral = { -> return false }'); + expect(result).toContain('sentry.gradle'); + }); + + it('Does not add shouldSentryAutoUploadGeneral override when disableAutoUpload is false', () => { + const result = modifyAppBuildGradle(buildGradleWithOutSentry, false); + expect(result).not.toContain('shouldSentryAutoUploadGeneral'); + }); + + it('Adds override to already-configured build.gradle on re-prebuild', () => { + const result = modifyAppBuildGradle(buildGradleWithSentry, true); + expect(result).toContain('project.ext.shouldSentryAutoUploadGeneral = { -> return false }'); + }); + + it('Does not duplicate override if already present', () => { + const gradleWithOverride = ` +apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") +project.ext.shouldSentryAutoUploadGeneral = { -> return false } + +android { +} +`; + const result = modifyAppBuildGradle(gradleWithOverride, true); + expect(result).toBe(gradleWithOverride); + }); + + it('Removes override when toggling disableAutoUpload back to false', () => { + const gradleWithOverride = ` +apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") +project.ext.shouldSentryAutoUploadGeneral = { -> return false } + +android { +} +`; + const result = modifyAppBuildGradle(gradleWithOverride, false); + expect(result).not.toContain('shouldSentryAutoUploadGeneral'); + expect(result).toContain('sentry.gradle'); + expect(result).toContain('android {'); + }); + + it('No-ops when toggling to false and override is not present', () => { + const result = modifyAppBuildGradle(buildGradleWithSentry, false); + expect(result).toBe(buildGradleWithSentry); + }); }); diff --git a/packages/core/test/expo-plugin/modifyXcodeProject.test.ts b/packages/core/test/expo-plugin/modifyXcodeProject.test.ts index 1cdf1760c8..59e095e2e8 100644 --- a/packages/core/test/expo-plugin/modifyXcodeProject.test.ts +++ b/packages/core/test/expo-plugin/modifyXcodeProject.test.ts @@ -1,5 +1,10 @@ import { warnOnce } from '../../plugin/src/logger'; -import { modifyExistingXcodeBuildScript } from '../../plugin/src/withSentryIOS'; +import { + addDisableAutoUploadToExistingScript, + addSentryWithBundledScriptsToBundleShellScript, + modifyExistingXcodeBuildScript, + removeDisableAutoUploadFromExistingScript, +} from '../../plugin/src/withSentryIOS'; jest.mock('../../plugin/src/logger'); @@ -77,6 +82,207 @@ describe('Configures iOS native project correctly', () => { }); }); +describe('disableAutoUpload option for Bundle React Native code phase', () => { + let consoleWarnMock: jest.SpyInstance; + + beforeEach(() => { + consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + consoleWarnMock.mockRestore(); + }); + + it('Prepends export on its own line when disableAutoUpload is true', () => { + const script = Object.assign({}, buildScriptWithoutSentry); + modifyExistingXcodeBuildScript(script, true); + const parsed = JSON.parse(script.shellScript); + expect(parsed).toContain('export SENTRY_DISABLE_AUTO_UPLOAD=true'); + expect(parsed).toContain('sentry-xcode.sh'); + expect(parsed).toMatch(/^"/); + expect(parsed).toMatch(/"$/); + }); + + it('Does not prepend export when disableAutoUpload is false', () => { + const script = Object.assign({}, buildScriptWithoutSentry); + modifyExistingXcodeBuildScript(script, false); + const parsed = JSON.parse(script.shellScript); + expect(parsed).not.toContain('SENTRY_DISABLE_AUTO_UPLOAD'); + }); + + it('Injects export into already-configured bundle script on re-prebuild', () => { + const script = Object.assign({}, buildScriptWithSentry); + modifyExistingXcodeBuildScript(script, true); + const parsed = JSON.parse(script.shellScript); + expect(parsed).toMatch(/^"\nexport SENTRY_DISABLE_AUTO_UPLOAD=true\n/); + expect(parsed).toContain('sentry-xcode.sh'); + }); + + it('Does not duplicate export if already present in bundle script', () => { + const scriptWithExport = { + shellScript: JSON.stringify(`" +export SENTRY_DISABLE_AUTO_UPLOAD=true +export NODE_BINARY=node +/bin/sh \`"$NODE_BINARY" --print "require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'"\` ../node_modules/react-native/scripts/react-native-xcode.sh +"`), + }; + const before = scriptWithExport.shellScript; + modifyExistingXcodeBuildScript(scriptWithExport, true); + expect(scriptWithExport.shellScript).toBe(before); + }); + + it('Does not modify already-configured script when disableAutoUpload is false', () => { + const script = Object.assign({}, buildScriptWithSentry); + const before = script.shellScript; + modifyExistingXcodeBuildScript(script, false); + expect(script.shellScript).toBe(before); + }); + + it('addSentryWithBundledScriptsToBundleShellScript uses real newline, not literal backslash-n', () => { + const input = `" +export NODE_BINARY=node +../node_modules/react-native/scripts/react-native-xcode.sh +"`; + const result = addSentryWithBundledScriptsToBundleShellScript(input, true); + expect(result).toContain('export SENTRY_DISABLE_AUTO_UPLOAD=true\n/bin/sh'); + expect(result).not.toContain('true\\n'); + }); + + it('Produces a valid shell script after JSON round-trip', () => { + const script = Object.assign({}, buildScriptWithoutSentry); + modifyExistingXcodeBuildScript(script, true); + const parsed = JSON.parse(script.shellScript); + const lines = parsed.split('\n'); + expect(lines).toContainEqual('export SENTRY_DISABLE_AUTO_UPLOAD=true'); + }); + + it('Fresh-prebuild and re-prebuild both place export inside delimiters', () => { + const freshScript = Object.assign({}, buildScriptWithoutSentry); + modifyExistingXcodeBuildScript(freshScript, true); + const freshParsed = JSON.parse(freshScript.shellScript); + + const rePrebuildScript = Object.assign({}, buildScriptWithSentry); + modifyExistingXcodeBuildScript(rePrebuildScript, true); + const rePrebuildParsed = JSON.parse(rePrebuildScript.shellScript); + + for (const parsed of [freshParsed, rePrebuildParsed]) { + expect(parsed).toMatch(/^"/); + expect(parsed).toMatch(/"$/); + expect(parsed).toContain('export SENTRY_DISABLE_AUTO_UPLOAD=true'); + expect(parsed).toContain('sentry-xcode.sh'); + } + }); +}); + +describe('addDisableAutoUploadToExistingScript', () => { + const debugFilesShellScript = + "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`"; + + it('injects export into JSON-encoded shellScript', () => { + const script = { shellScript: JSON.stringify(debugFilesShellScript) }; + addDisableAutoUploadToExistingScript(script); + const parsed = JSON.parse(script.shellScript); + expect(parsed).toMatch(/^export SENTRY_DISABLE_AUTO_UPLOAD=true\n/); + expect(parsed).toContain('sentry-xcode-debug-files.sh'); + }); + + it('injects export into non-JSON-encoded shellScript via fallback', () => { + const script = { shellScript: debugFilesShellScript }; + addDisableAutoUploadToExistingScript(script); + expect(script.shellScript).toMatch(/^export SENTRY_DISABLE_AUTO_UPLOAD=true\n/); + expect(script.shellScript).toContain('sentry-xcode-debug-files.sh'); + }); + + it('does not duplicate export if already present in JSON-encoded script', () => { + const script = { + shellScript: JSON.stringify(`export SENTRY_DISABLE_AUTO_UPLOAD=true\n${debugFilesShellScript}`), + }; + const before = script.shellScript; + addDisableAutoUploadToExistingScript(script); + expect(script.shellScript).toBe(before); + }); + + it('does not duplicate export if already present in raw script', () => { + const script = { + shellScript: `export SENTRY_DISABLE_AUTO_UPLOAD=true\n${debugFilesShellScript}`, + }; + const before = script.shellScript; + addDisableAutoUploadToExistingScript(script); + expect(script.shellScript).toBe(before); + }); + + it('works on the bundle phase script format (JSON-encoded with inner quotes)', () => { + const script = Object.assign({}, buildScriptWithSentry); + addDisableAutoUploadToExistingScript(script); + const parsed = JSON.parse(script.shellScript); + expect(parsed).toMatch(/^"\nexport SENTRY_DISABLE_AUTO_UPLOAD=true\n/); + expect(parsed).toContain('sentry-xcode.sh'); + }); +}); + +describe('removeDisableAutoUploadFromExistingScript', () => { + const debugFilesShellScript = + "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`"; + + it('removes export from JSON-encoded shellScript', () => { + const script = { + shellScript: JSON.stringify(`export SENTRY_DISABLE_AUTO_UPLOAD=true\n${debugFilesShellScript}`), + }; + removeDisableAutoUploadFromExistingScript(script); + const parsed = JSON.parse(script.shellScript); + expect(parsed).not.toContain('SENTRY_DISABLE_AUTO_UPLOAD'); + expect(parsed).toContain('sentry-xcode-debug-files.sh'); + }); + + it('removes export from non-JSON-encoded shellScript', () => { + const script = { + shellScript: `export SENTRY_DISABLE_AUTO_UPLOAD=true\n${debugFilesShellScript}`, + }; + removeDisableAutoUploadFromExistingScript(script); + expect(script.shellScript).not.toContain('SENTRY_DISABLE_AUTO_UPLOAD'); + expect(script.shellScript).toContain('sentry-xcode-debug-files.sh'); + }); + + it('removes export from bundle phase script (JSON-encoded with inner quotes)', () => { + const script = Object.assign({}, buildScriptWithSentry); + addDisableAutoUploadToExistingScript(script); + expect(script.shellScript).toContain('SENTRY_DISABLE_AUTO_UPLOAD'); + removeDisableAutoUploadFromExistingScript(script); + const parsed = JSON.parse(script.shellScript); + expect(parsed).not.toContain('SENTRY_DISABLE_AUTO_UPLOAD'); + expect(parsed).toContain('sentry-xcode.sh'); + }); + + it('no-ops when export is not present', () => { + const script = { shellScript: JSON.stringify(debugFilesShellScript) }; + const before = script.shellScript; + removeDisableAutoUploadFromExistingScript(script); + expect(script.shellScript).toBe(before); + }); +}); + +describe('disableAutoUpload toggle: re-prebuild with false removes prior injection', () => { + let consoleWarnMock: jest.SpyInstance; + + beforeEach(() => { + consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + consoleWarnMock.mockRestore(); + }); + + it('removes export from bundle script when toggling disableAutoUpload back to false', () => { + const script = Object.assign({}, buildScriptWithSentry); + modifyExistingXcodeBuildScript(script, true); + expect(JSON.parse(script.shellScript)).toContain('SENTRY_DISABLE_AUTO_UPLOAD'); + + modifyExistingXcodeBuildScript(script, false); + expect(JSON.parse(script.shellScript)).not.toContain('SENTRY_DISABLE_AUTO_UPLOAD'); + expect(JSON.parse(script.shellScript)).toContain('sentry-xcode.sh'); + }); +}); + describe('Upload Debug Symbols to Sentry build phase', () => { let mockXcodeProject: any; let addBuildPhaseSpy: jest.Mock;