diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d366965f5..b8beedc9e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984)) + ## 8.8.0 ### Features diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index af84a8f3a9..f947e4b90d 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -10,6 +10,7 @@ import type { DefaultConfigOptions } from './vendor/expo/expoconfig'; import { enableLogger } from './enableLogger'; import { withSentryMiddleware } from './metroMiddleware'; +import { checkSentryExpoNativeProject } from './sentryExpoNativeCheck'; import { setSentryBabelTransformerOptions, setSentryDefaultBabelTransformerPathEnv, @@ -114,6 +115,8 @@ export function getSentryExpoConfig( ): MetroConfig { setSentryMetroDevServerEnvFlag(); + checkSentryExpoNativeProject(projectRoot); + const getDefaultConfig = options.getDefaultConfig || loadExpoMetroConfigModule().getDefaultConfig; const config = getDefaultConfig(projectRoot, { ...options, diff --git a/packages/core/src/js/tools/sentryExpoNativeCheck.ts b/packages/core/src/js/tools/sentryExpoNativeCheck.ts new file mode 100644 index 0000000000..ca96eed16a --- /dev/null +++ b/packages/core/src/js/tools/sentryExpoNativeCheck.ts @@ -0,0 +1,89 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const PREFIX = '[@sentry/react-native/expo]'; + +/** + * Checks if prebuilt native projects (ios/android) have been configured with Sentry. + * If native directories exist but are missing Sentry configuration, warns the user + * to run `npx expo prebuild --clean`. + * + * This only runs for Expo projects (detected by the presence of `expo` in package.json dependencies). + */ +export function checkSentryExpoNativeProject(projectRoot: string): void { + try { + if (!isExpoProject(projectRoot)) { + return; + } + + const missingPlatforms: string[] = []; + + if (isIOSProjectMissingSentry(projectRoot)) { + missingPlatforms.push('iOS'); + } + + if (isAndroidProjectMissingSentry(projectRoot)) { + missingPlatforms.push('Android'); + } + + if (missingPlatforms.length > 0) { + const platforms = missingPlatforms.join(' and '); + // oxlint-disable-next-line eslint(no-console) + console.warn( + `${PREFIX} Sentry native configuration is missing from your prebuilt ${platforms} project. +Run \`npx expo prebuild --clean\` to apply the Sentry Expo Plugin changes. +Without this, source maps upload and native crash reporting may not work correctly.`, + ); + } + } catch (_) { + // Never crash Metro startup — silently ignore any errors + } +} + +function isExpoProject(projectRoot: string): boolean { + const packageJsonPath = path.resolve(projectRoot, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return !!(packageJson.dependencies?.expo || packageJson.devDependencies?.expo); +} + +function isIOSProjectMissingSentry(projectRoot: string): boolean { + const iosDir = path.resolve(projectRoot, 'ios'); + if (!fs.existsSync(iosDir)) { + return false; + } + + const pbxprojPath = findPbxprojFile(iosDir); + if (!pbxprojPath) { + return false; + } + + const pbxprojContents = fs.readFileSync(pbxprojPath, 'utf8'); + return !pbxprojContents.includes('sentry-xcode') && !pbxprojContents.includes('Upload Debug Symbols to Sentry'); +} + +function isAndroidProjectMissingSentry(projectRoot: string): boolean { + const buildGradlePath = path.resolve(projectRoot, 'android', 'app', 'build.gradle'); + if (!fs.existsSync(buildGradlePath)) { + return false; + } + + const buildGradleContents = fs.readFileSync(buildGradlePath, 'utf8'); + return !buildGradleContents.includes('sentry.gradle') && !buildGradleContents.includes('io.sentry.android.gradle'); +} + +function findPbxprojFile(iosDir: string): string | null { + const entries = fs.readdirSync(iosDir); + for (const entry of entries) { + if (entry.endsWith('.xcodeproj')) { + const pbxprojPath = path.resolve(iosDir, entry, 'project.pbxproj'); + if (fs.existsSync(pbxprojPath)) { + return pbxprojPath; + } + } + } + return null; +} diff --git a/packages/core/test/tools/sentryExpoNativeCheck.test.ts b/packages/core/test/tools/sentryExpoNativeCheck.test.ts new file mode 100644 index 0000000000..b0511f2da0 --- /dev/null +++ b/packages/core/test/tools/sentryExpoNativeCheck.test.ts @@ -0,0 +1,273 @@ +import * as fs from 'fs'; + +import { checkSentryExpoNativeProject } from '../../src/js/tools/sentryExpoNativeCheck'; + +jest.mock('fs', () => ({ + existsSync: jest.fn(), + readFileSync: jest.fn(), + readdirSync: jest.fn(), +})); + +const existsSyncMock = fs.existsSync as jest.Mock; +const readFileSyncMock = fs.readFileSync as jest.Mock; +const readdirSyncMock = fs.readdirSync as jest.Mock; + +const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + +const PACKAGE_JSON_WITH_EXPO = JSON.stringify({ + dependencies: { expo: '~52.0.0', '@sentry/react-native': '6.0.0' }, +}); + +const PACKAGE_JSON_WITH_EXPO_DEV = JSON.stringify({ + devDependencies: { expo: '~52.0.0' }, +}); + +const PACKAGE_JSON_WITHOUT_EXPO = JSON.stringify({ + dependencies: { 'react-native': '0.76.0' }, +}); + +const PBXPROJ_WITH_SENTRY_XCODE = ` + shellScript = "/bin/sh \`\\"$NODE_BINARY\\" --print \\"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'\\"\`" +`; + +const PBXPROJ_WITH_UPLOAD_DEBUG_SYMBOLS = ` + name = "Upload Debug Symbols to Sentry"; + shellScript = "/bin/sh sentry-xcode-debug-files.sh"; +`; + +const PBXPROJ_WITHOUT_SENTRY = ` + shellScript = "set -e\\n\\nWITH_ENVIRONMENT=\\"../node_modules/react-native/scripts/xcode/with-environment.sh\\"\\nREACT_NATIVE_XCODE=\\"../node_modules/react-native/scripts/react-native-xcode.sh\\"\\n\\n/bin/sh -c \\"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\\"\\n"; +`; + +const BUILD_GRADLE_WITH_SENTRY = ` +apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") + +android { +} +`; + +const BUILD_GRADLE_WITH_ANDROID_GRADLE_PLUGIN = ` +apply plugin: "io.sentry.android.gradle" + +android { +} +`; + +const BUILD_GRADLE_WITHOUT_SENTRY = ` +android { +} +`; + +describe('checkSentryExpoNativeProject', () => { + beforeEach(() => { + jest.resetAllMocks(); + consoleWarnSpy.mockImplementation(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('skips check for non-Expo projects', () => { + test('no package.json', () => { + existsSyncMock.mockReturnValue(false); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + test('package.json without expo dependency', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITHOUT_EXPO, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('no warning when native dirs do not exist', () => { + test('no ios/ or android/ directories', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITH_EXPO, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('no warning when Sentry is configured', () => { + test('iOS with sentry-xcode in pbxproj', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITH_EXPO, + '/project/ios': { dir: true, entries: ['MyApp.xcodeproj'] }, + '/project/ios/MyApp.xcodeproj/project.pbxproj': PBXPROJ_WITH_SENTRY_XCODE, + '/project/android/app/build.gradle': BUILD_GRADLE_WITH_SENTRY, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + test('iOS with Upload Debug Symbols in pbxproj', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITH_EXPO, + '/project/ios': { dir: true, entries: ['MyApp.xcodeproj'] }, + '/project/ios/MyApp.xcodeproj/project.pbxproj': PBXPROJ_WITH_UPLOAD_DEBUG_SYMBOLS, + '/project/android/app/build.gradle': BUILD_GRADLE_WITH_SENTRY, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + test('Android with sentry.gradle in build.gradle', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITH_EXPO, + '/project/android/app/build.gradle': BUILD_GRADLE_WITH_SENTRY, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + test('Android with io.sentry.android.gradle plugin in build.gradle', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITH_EXPO, + '/project/android/app/build.gradle': BUILD_GRADLE_WITH_ANDROID_GRADLE_PLUGIN, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('warns when Sentry is missing', () => { + test('iOS missing Sentry config', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITH_EXPO, + '/project/ios': { dir: true, entries: ['MyApp.xcodeproj'] }, + '/project/ios/MyApp.xcodeproj/project.pbxproj': PBXPROJ_WITHOUT_SENTRY, + '/project/android/app/build.gradle': BUILD_GRADLE_WITH_SENTRY, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('iOS')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo prebuild --clean')); + }); + + test('Android missing Sentry config', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITH_EXPO, + '/project/android/app/build.gradle': BUILD_GRADLE_WITHOUT_SENTRY, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Android')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('npx expo prebuild --clean')); + }); + + test('both platforms missing Sentry config', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITH_EXPO, + '/project/ios': { dir: true, entries: ['MyApp.xcodeproj'] }, + '/project/ios/MyApp.xcodeproj/project.pbxproj': PBXPROJ_WITHOUT_SENTRY, + '/project/android/app/build.gradle': BUILD_GRADLE_WITHOUT_SENTRY, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('iOS and Android')); + }); + }); + + describe('detects expo in devDependencies', () => { + test('warns when expo is in devDependencies and native config is missing', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITH_EXPO_DEV, + '/project/android/app/build.gradle': BUILD_GRADLE_WITHOUT_SENTRY, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('graceful error handling', () => { + test('does not crash when fs throws', () => { + existsSyncMock.mockImplementation(() => { + throw new Error('permission denied'); + }); + + expect(() => checkSentryExpoNativeProject('/project')).not.toThrow(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + test('does not crash when package.json is invalid JSON', () => { + mockFilesystem({ + '/project/package.json': 'not json{{{', + }); + + expect(() => checkSentryExpoNativeProject('/project')).not.toThrow(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + test('no warning when ios/ exists but has no xcodeproj', () => { + mockFilesystem({ + '/project/package.json': PACKAGE_JSON_WITH_EXPO, + '/project/ios': { dir: true, entries: ['Podfile', 'Pods'] }, + }); + + checkSentryExpoNativeProject('/project'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); +}); + +type FilesystemMap = Record; + +/** + * Sets up existsSync, readFileSync, and readdirSync mocks based on a virtual filesystem map. + */ +function mockFilesystem(files: FilesystemMap): void { + const resolvedFiles = new Map(); + for (const [key, value] of Object.entries(files)) { + // path.resolve normalizes the path for the current OS + resolvedFiles.set(require('path').resolve(key), value); + } + + existsSyncMock.mockImplementation((p: string) => resolvedFiles.has(require('path').resolve(p))); + + readFileSyncMock.mockImplementation((p: string) => { + const resolved = require('path').resolve(p); + const entry = resolvedFiles.get(resolved); + if (typeof entry === 'string') { + return entry; + } + throw new Error(`ENOENT: no such file or directory, open '${p}'`); + }); + + readdirSyncMock.mockImplementation((p: string) => { + const resolved = require('path').resolve(p); + const entry = resolvedFiles.get(resolved); + if (entry && typeof entry === 'object' && entry.dir) { + return entry.entries; + } + throw new Error(`ENOENT: no such file or directory, scandir '${p}'`); + }); +}