diff --git a/CHANGELOG.md b/CHANGELOG.md index 404e0d82..232d7051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- feat(react-native): Add support for Expo projects (#505) + ## 3.24.1 - fix(nextjs): Add trailing comma to `sentryUrl` option in `withSentryConfig` template (#601) diff --git a/src/react-native/expo-env-file.ts b/src/react-native/expo-env-file.ts new file mode 100644 index 00000000..c16f71be --- /dev/null +++ b/src/react-native/expo-env-file.ts @@ -0,0 +1,55 @@ +// @ts-ignore - clack is ESM and TS complains about that. It works though +import * as clack from '@clack/prompts'; +import chalk from 'chalk'; +import fs from 'fs'; +import * as Sentry from '@sentry/node'; +import { RNCliSetupConfigContent } from './react-native-wizard'; +import { addToGitignore } from './git'; + +const EXPO_ENV_LOCAL_FILE = '.env.local'; + +export async function addExpoEnvLocal( + options: RNCliSetupConfigContent, +): Promise { + const newContent = `#DO NOT COMMIT THIS\nSENTRY_AUTH_TOKEN=${options.authToken}\n`; + + const added = await addToGitignore(EXPO_ENV_LOCAL_FILE); + if (added) { + clack.log.success( + `Added ${chalk.cyan(EXPO_ENV_LOCAL_FILE)} to .gitignore.`, + ); + } else { + clack.log.error( + `Could not add ${chalk.cyan( + EXPO_ENV_LOCAL_FILE, + )} to .gitignore, please add it to not commit your auth key.`, + ); + } + + if (!fs.existsSync(EXPO_ENV_LOCAL_FILE)) { + try { + await fs.promises.writeFile(EXPO_ENV_LOCAL_FILE, newContent); + Sentry.setTag('expo-env-local', 'written'); + clack.log.success(`Written ${chalk.cyan(EXPO_ENV_LOCAL_FILE)}.`); + return true; + } catch (error) { + Sentry.setTag('expo-env-local', 'write-error'); + clack.log.error(`Unable to write ${chalk.cyan(EXPO_ENV_LOCAL_FILE)}.`); + return false; + } + } + + Sentry.setTag('expo-env-local', 'exists'); + clack.log.info(`Updating existing ${chalk.cyan(EXPO_ENV_LOCAL_FILE)}.`); + + try { + await fs.promises.appendFile(EXPO_ENV_LOCAL_FILE, newContent); + Sentry.setTag('expo-env-local', 'updated'); + clack.log.success(`Updated ${chalk.cyan(EXPO_ENV_LOCAL_FILE)}.`); + return true; + } catch (error) { + Sentry.setTag('expo-env-local', 'update-error'); + clack.log.error(`Unable to update ${chalk.cyan(EXPO_ENV_LOCAL_FILE)}.`); + return false; + } +} diff --git a/src/react-native/expo-metro.ts b/src/react-native/expo-metro.ts new file mode 100644 index 00000000..c9194162 --- /dev/null +++ b/src/react-native/expo-metro.ts @@ -0,0 +1,212 @@ +import * as fs from 'fs'; +// @ts-ignore - clack is ESM and TS complains about that. It works though +import * as clack from '@clack/prompts'; +// @ts-ignore - magicast is ESM and TS complains about that. It works though +import { ProxifiedModule } from 'magicast'; +import chalk from 'chalk'; +import * as Sentry from '@sentry/node'; + +import { getLastRequireIndex, hasSentryContent } from '../utils/ast-utils'; +import { + makeCodeSnippet, + showCopyPasteInstructions, +} from '../utils/clack-utils'; + +import { metroConfigPath, parseMetroConfig, writeMetroConfig } from './metro'; + +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; + +const b = recast.types.builders; + +export async function addSentryToExpoMetroConfig() { + if (!fs.existsSync(metroConfigPath)) { + const success = await createSentryExpoMetroConfig(); + if (!success) { + Sentry.setTag('expo-metro-config', 'create-new-error'); + return await showInstructions(); + } + Sentry.setTag('expo-metro-config', 'created-new'); + return undefined; + } + + const mod = await parseMetroConfig(); + + let didPatch = false; + try { + didPatch = patchMetroInMemory(mod); + } catch (e) { + // noop + } + if (!didPatch) { + Sentry.setTag('expo-metro-config', 'patch-error'); + clack.log.error( + `Could not patch ${chalk.cyan( + metroConfigPath, + )} with Sentry configuration.`, + ); + return await showInstructions(); + } + + const saved = await writeMetroConfig(mod); + if (saved) { + Sentry.setTag('expo-metro-config', 'patch-saved'); + clack.log.success( + chalk.green(`${chalk.cyan(metroConfigPath)} changes saved.`), + ); + } else { + Sentry.setTag('expo-metro-config', 'patch-save-error'); + clack.log.warn( + `Could not save changes to ${chalk.cyan( + metroConfigPath, + )}, please follow the manual steps.`, + ); + return await showInstructions(); + } +} + +export function patchMetroInMemory(mod: ProxifiedModule): boolean { + const ast = mod.$ast as t.Program; + + if (hasSentryContent(ast)) { + clack.log.warn( + `The ${chalk.cyan( + metroConfigPath, + )} file already has Sentry configuration.`, + ); + return false; + } + + let didReplaceDefaultConfigCall = false; + + recast.visit(ast, { + visitVariableDeclaration(path) { + const { node } = path; + + if ( + // path is require("expo/metro-config") + // and only getDefaultConfig is being destructured + // then remove the entire declaration + node.declarations.length > 0 && + node.declarations[0].type === 'VariableDeclarator' && + node.declarations[0].init && + node.declarations[0].init.type === 'CallExpression' && + node.declarations[0].init.callee && + node.declarations[0].init.callee.type === 'Identifier' && + node.declarations[0].init.callee.name === 'require' && + node.declarations[0].init.arguments[0].type === 'StringLiteral' && + node.declarations[0].init.arguments[0].value === 'expo/metro-config' && + node.declarations[0].id.type === 'ObjectPattern' && + node.declarations[0].id.properties.length === 1 && + node.declarations[0].id.properties[0].type === 'ObjectProperty' && + node.declarations[0].id.properties[0].key.type === 'Identifier' && + node.declarations[0].id.properties[0].key.name === 'getDefaultConfig' + ) { + path.prune(); + return false; + } + + this.traverse(path); + }, + + visitCallExpression(path) { + const { node } = path; + if ( + // path is getDefaultConfig + // then rename it to getSentryExpoConfig + node.callee.type === 'Identifier' && + node.callee.name === 'getDefaultConfig' + ) { + node.callee.name = 'getSentryExpoConfig'; + didReplaceDefaultConfigCall = true; + return false; + } + + this.traverse(path); + }, + }); + + if (!didReplaceDefaultConfigCall) { + clack.log.warn( + `Could not find \`getDefaultConfig\` in ${chalk.cyan(metroConfigPath)}.`, + ); + return false; + } + + addSentryExpoConfigRequire(ast); + + return true; +} + +export function addSentryExpoConfigRequire(program: t.Program) { + const lastRequireIndex = getLastRequireIndex(program); + const sentryExpoConfigRequire = createSentryExpoConfigRequire(); + program.body.splice(lastRequireIndex + 1, 0, sentryExpoConfigRequire); +} + +/** + * Creates const { getSentryExpoConfig } = require("@sentry/react-native/metro"); + */ +function createSentryExpoConfigRequire() { + return b.variableDeclaration('const', [ + b.variableDeclarator( + b.objectPattern([ + b.objectProperty.from({ + key: b.identifier('getSentryExpoConfig'), + value: b.identifier('getSentryExpoConfig'), + shorthand: true, + }), + ]), + b.callExpression(b.identifier('require'), [ + b.literal('@sentry/react-native/metro'), + ]), + ), + ]); +} + +async function createSentryExpoMetroConfig(): Promise { + const snippet = `const { getSentryExpoConfig } = require("@sentry/react-native/metro"); + +const config = getSentryExpoConfig(__dirname); + +module.exports = config; +`; + try { + await fs.promises.writeFile(metroConfigPath, snippet); + } catch (e) { + clack.log.error( + `Could not create ${chalk.cyan( + metroConfigPath, + )} with Sentry configuration.`, + ); + return false; + } + clack.log.success( + `Created ${chalk.cyan(metroConfigPath)} with Sentry configuration.`, + ); + return true; +} + +function showInstructions() { + return showCopyPasteInstructions( + metroConfigPath, + getMetroWithSentryExpoConfigSnippet(true), + ); +} + +function getMetroWithSentryExpoConfigSnippet(colors: boolean): string { + return makeCodeSnippet(colors, (unchanged, plus, minus) => + unchanged(`${minus( + `// const { getDefaultConfig } = require("expo/metro-config");`, + )} +${plus( + `const { getSentryExpoConfig } = require("@sentry/react-native/metro");`, +)} + +${minus(`// const config = getDefaultConfig(__dirname);`)} +${plus(`const config = getSentryExpoConfig(__dirname);`)} + +module.exports = config;`), + ); +} diff --git a/src/react-native/expo.ts b/src/react-native/expo.ts new file mode 100644 index 00000000..da88f429 --- /dev/null +++ b/src/react-native/expo.ts @@ -0,0 +1,175 @@ +// @ts-ignore - clack is ESM and TS complains about that. It works though +import * as clack from '@clack/prompts'; +import chalk from 'chalk'; +import * as fs from 'fs'; +import { EOL } from 'os'; + +import { isPlainObject } from '@sentry/utils'; +import * as Sentry from '@sentry/node'; +import { + makeCodeSnippet, + showCopyPasteInstructions, +} from '../utils/clack-utils'; +import { RNCliSetupConfigContent } from './react-native-wizard'; +import { traceStep } from '../telemetry'; + +export const SENTRY_EXPO_PLUGIN_NAME = '@sentry/react-native/expo'; +export const DEPRECATED_SENTRY_EXPO_PLUGIN_NAME = 'sentry-expo'; + +export const SENTRY_PLUGIN_FUNCTION_NAME = 'withSentry'; + +const APP_CONFIG_JSON = `app.json`; + +export interface AppConfigJson { + expo?: { + plugins?: Array<[string, undefined | Record]>; + }; +} + +export function printSentryExpoMigrationOutro(): void { + clack.outro( + `Deprecated ${chalk.cyan( + 'sentry-expo', + )} package installed in your dependencies. Please follow the migration guide at ${chalk.cyan( + 'https://docs.sentry.io/platforms/react-native/migration/sentry-expo/', + )}`, + ); +} + +/** + * Finds app.json in the project root and add Sentry Expo `withSentry` plugin. + */ +export async function patchExpoAppConfig(options: RNCliSetupConfigContent) { + function showInstructions() { + return showCopyPasteInstructions( + APP_CONFIG_JSON, + getSentryAppConfigJsonCodeSnippet(options), + 'This ensures auto upload of source maps during native app build.', + ); + } + + const appConfigJsonExists = fs.existsSync(APP_CONFIG_JSON); + + Sentry.setTag( + 'app-config-file-status', + appConfigJsonExists ? 'found' : 'not-found', + ); + if (!appConfigJsonExists) { + return await showInstructions(); + } + + const patched = await patchAppConfigJson(APP_CONFIG_JSON, options); + if (!patched) { + return await showInstructions(); + } +} + +async function patchAppConfigJson( + path: string, + options: RNCliSetupConfigContent, +): Promise { + const appConfigContent = ( + await fs.promises.readFile(path, { encoding: 'utf-8' }) + ).toString(); + const patchedContent = traceStep('app-config-json-patch', () => + addWithSentryToAppConfigJson(appConfigContent, options), + ); + if (patchedContent === null) { + return false; + } + + try { + await fs.promises.writeFile(path, patchedContent); + } catch (error) { + Sentry.setTag('app-config-file-status', 'json-write-error'); + clack.log.error(`Unable to write ${chalk.cyan('app.config.json')}.`); + return false; + } + Sentry.setTag('app-config-file-status', 'json-write-success'); + clack.log.success( + `Added Sentry Expo plugin to ${chalk.cyan('app.config.json')}.`, + ); + return true; +} + +export function addWithSentryToAppConfigJson( + appConfigContent: string, + options: RNCliSetupConfigContent, +): string | null { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const parsedAppConfig: AppConfigJson = JSON.parse(appConfigContent); + const includesWithSentry = + appConfigContent.includes(SENTRY_EXPO_PLUGIN_NAME) || + appConfigContent.includes(DEPRECATED_SENTRY_EXPO_PLUGIN_NAME); + + if (includesWithSentry) { + Sentry.setTag('app-config-file-status', 'already-patched'); + clack.log.warn( + `Your ${chalk.cyan( + 'app.config.json', + )} already includes the Sentry Expo plugin.`, + ); + return null; + } + + if ( + parsedAppConfig.expo !== undefined && + !isPlainObject(parsedAppConfig.expo) + ) { + Sentry.setTag('app-config-file-status', 'invalid-json'); + return null; + } + if ( + parsedAppConfig.expo && + parsedAppConfig.expo.plugins !== undefined && + !Array.isArray(parsedAppConfig.expo.plugins) + ) { + Sentry.setTag('app-config-file-status', 'invalid-json'); + return null; + } + + parsedAppConfig.expo = parsedAppConfig.expo ?? {}; + parsedAppConfig.expo.plugins = parsedAppConfig.expo.plugins ?? []; + parsedAppConfig.expo.plugins.push([ + SENTRY_EXPO_PLUGIN_NAME, + { + url: options.url, + project: options.project, + organization: options.org, + }, + ]); + + return JSON.stringify(parsedAppConfig, null, 2) + EOL; + } catch (error) { + Sentry.setTag('app-config-file-status', 'invalid-json'); + clack.log.error( + `Unable to parse your ${chalk.cyan( + 'app.config.json', + )}. Make sure it has a valid format!`, + ); + return null; + } +} + +export function getSentryAppConfigJsonCodeSnippet({ + url, + project, + org, +}: Omit) { + return makeCodeSnippet(true, (unchanged, plus, _minus) => { + return unchanged(`{ + "name": "my app", + "plugins": [ + ${plus(`[ + "@sentry/react-native/expo", + { + "url": "${url}", + "project": "${project}", + "organization": "${org}" + } + ]`)} + ], +}`); + }); +} diff --git a/src/react-native/git.ts b/src/react-native/git.ts new file mode 100644 index 00000000..9a04b715 --- /dev/null +++ b/src/react-native/git.ts @@ -0,0 +1,25 @@ +import * as fs from 'fs'; + +const GITIGNORE_FILENAME = '.gitignore'; + +export async function addToGitignore(filepath: string): Promise { + /** + * Don't check whether the given file is ignored because: + * 1. It's tricky to check it without git. + * 2. Git might not be installed or accessible. + * 3. It's convenient to use a module to interact with git, but it would + * increase the size x2 approximately. Docs say to run the Wizard without + * installing it, and duplicating the size would slow the set-up down. + * 4. The Wizard is meant to be run once. + * 5. A message is logged informing users it's been added to the gitignore. + * 6. It will be added to the gitignore as many times as it runs - not a big + * deal. + * 7. It's straightforward to remove it from the gitignore. + */ + try { + await fs.promises.appendFile(GITIGNORE_FILENAME, `\n${filepath}\n`); + return true; + } catch { + return false; + } +} diff --git a/src/react-native/javascript.ts b/src/react-native/javascript.ts index 065bda6c..33917799 100644 --- a/src/react-native/javascript.ts +++ b/src/react-native/javascript.ts @@ -1,4 +1,71 @@ -import { makeCodeSnippet } from '../utils/clack-utils'; +/* eslint-disable max-lines */ +// @ts-ignore - clack is ESM and TS complains about that. It works though +import clack from '@clack/prompts'; +import chalk from 'chalk'; +import * as path from 'path'; +import * as process from 'process'; +import * as fs from 'fs'; +import * as Sentry from '@sentry/node'; + +import { traceStep } from '../telemetry'; +import { + makeCodeSnippet, + showCopyPasteInstructions, +} from '../utils/clack-utils'; +import { getFirstMatchedPath } from './glob'; +import { RN_SDK_PACKAGE } from './react-native-wizard'; + +export async function addSentryInit({ dsn }: { dsn: string }) { + const prefixGlob = '{.,./src,./app}'; + const suffixGlob = '@(j|t|cj|mj)s?(x)'; + const universalGlob = `@(App|_layout).${suffixGlob}`; + const jsFileGlob = `${prefixGlob}/+(${universalGlob})`; + const jsPath = traceStep('find-app-js-file', () => + getFirstMatchedPath(jsFileGlob), + ); + Sentry.setTag('app-js-file-status', jsPath ? 'found' : 'not-found'); + if (!jsPath) { + clack.log.warn( + `Could not find main App file. Place the following code snippet close to the Apps Root component.`, + ); + await showCopyPasteInstructions( + 'App.js or _layout.tsx', + getSentryInitColoredCodeSnippet(dsn), + 'This ensures the Sentry SDK is ready to capture errors.', + ); + return; + } + const jsRelativePath = path.relative(process.cwd(), jsPath); + + const js = fs.readFileSync(jsPath, 'utf-8'); + const includesSentry = doesJsCodeIncludeSdkSentryImport(js, { + sdkPackageName: RN_SDK_PACKAGE, + }); + if (includesSentry) { + Sentry.setTag('app-js-file-status', 'already-includes-sentry'); + clack.log.warn( + `${chalk.cyan( + jsRelativePath, + )} already includes Sentry. We wont't add it again.`, + ); + return; + } + + traceStep('add-sentry-init', () => { + const newContent = addSentryInitWithSdkImport(js, { dsn }); + + clack.log.success( + `Added ${chalk.cyan('Sentry.init')} to ${chalk.cyan(jsRelativePath)}.`, + ); + + fs.writeFileSync(jsPath, newContent, 'utf-8'); + }); + + Sentry.setTag('app-js-file-status', 'added-sentry-init'); + clack.log.success( + chalk.green(`${chalk.cyan(jsRelativePath)} changes saved.`), + ); +} export function addSentryInitWithSdkImport( js: string, diff --git a/src/react-native/metro.ts b/src/react-native/metro.ts index af865976..706ffae9 100644 --- a/src/react-native/metro.ts +++ b/src/react-native/metro.ts @@ -23,7 +23,7 @@ import chalk from 'chalk'; const b = recast.types.builders; -const metroConfigPath = 'metro.config.js'; +export const metroConfigPath = 'metro.config.js'; export async function patchMetroWithSentryConfig() { const mod = await parseMetroConfig(); @@ -259,7 +259,7 @@ export function removeSentryRequire(program: t.Program): boolean { return removeRequire(program, '@sentry'); } -async function parseMetroConfig(): Promise { +export async function parseMetroConfig(): Promise { const metroConfigContent = ( await fs.promises.readFile(metroConfigPath) ).toString(); @@ -267,7 +267,7 @@ async function parseMetroConfig(): Promise { return parseModule(metroConfigContent); } -async function writeMetroConfig(mod: ProxifiedModule): Promise { +export async function writeMetroConfig(mod: ProxifiedModule): Promise { try { await writeFile(mod.$ast, metroConfigPath); } catch (e) { diff --git a/src/react-native/react-native-wizard.ts b/src/react-native/react-native-wizard.ts index 4d3d5dce..9d8a2fa1 100644 --- a/src/react-native/react-native-wizard.ts +++ b/src/react-native/react-native-wizard.ts @@ -3,8 +3,7 @@ import clack from '@clack/prompts'; import chalk from 'chalk'; import * as fs from 'fs'; -import * as path from 'path'; -import * as process from 'process'; + import { CliSetupConfigContent, abortIfCancelled, @@ -17,7 +16,6 @@ import { installPackage, printWelcome, propertiesCliSetupConfig, - showCopyPasteInstructions, } from '../utils/clack-utils'; import { getPackageVersion, hasPackageInstalled } from '../utils/package-json'; import { podInstall } from '../apple/cocoapod'; @@ -41,11 +39,7 @@ import { import { runReactNativeUninstall } from './uninstall'; import { APP_BUILD_GRADLE, XCODE_PROJECT, getFirstMatchedPath } from './glob'; import { ReactNativeWizardOptions } from './options'; -import { - addSentryInitWithSdkImport, - doesJsCodeIncludeSdkSentryImport, - getSentryInitColoredCodeSnippet, -} from './javascript'; +import { addSentryInit } from './javascript'; import { traceStep, withTelemetry } from '../telemetry'; import * as Sentry from '@sentry/node'; import { fulfillsVersionRange } from '../utils/semver'; @@ -54,6 +48,9 @@ import { patchMetroConfigWithSentrySerializer, patchMetroWithSentryConfig, } from './metro'; +import { patchExpoAppConfig, printSentryExpoMigrationOutro } from './expo'; +import { addSentryToExpoMetroConfig } from './expo-metro'; +import { addExpoEnvLocal } from './expo-env-file'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const xcode = require('xcode'); @@ -64,14 +61,24 @@ export const RN_PACKAGE = 'react-native'; export const RN_HUMAN_NAME = 'React Native'; export const SUPPORTED_RN_RANGE = '>=0.69.0'; +export const SUPPORTED_EXPO_RANGE = '>=50.0.0'; -// The following SDK version ship with bundled Xcode scripts -// which simplifies the Xcode Build Phases setup. +/** + * The following SDK version ship with bundled Xcode scripts + * which simplifies the Xcode Build Phases setup. + */ export const SDK_XCODE_SCRIPTS_SUPPORTED_SDK_RANGE = '>=5.11.0'; -// The following SDK version ship with Sentry Metro plugin +/** + * The following SDK version ship with Sentry Metro plugin + */ export const SDK_SENTRY_METRO_PLUGIN_SUPPORTED_SDK_RANGE = '>=5.11.0'; +/** + * The following SDK version ship with bundled Expo plugin + */ +export const SDK_EXPO_SUPPORTED_SDK_RANGE = `>=5.16.0`; + // The following SDK version shipped `withSentryConfig` export const SDK_SENTRY_METRO_WITH_SENTRY_CONFIG_SUPPORTED_SDK_RANGE = '>=5.17.0'; @@ -110,6 +117,13 @@ export async function runReactNativeWizardWithTelemetry( await confirmContinueIfNoOrDirtyGitRepo(); const packageJson = await getPackageDotJson(); + const hasInstalled = (dep: string) => hasPackageInstalled(dep, packageJson); + + if (hasInstalled('sentry-expo')) { + Sentry.setTag('has-sentry-expo-installed', true); + printSentryExpoMigrationOutro(); + return; + } await ensurePackageIsInstalled(packageJson, RN_PACKAGE, RN_HUMAN_NAME); @@ -120,6 +134,38 @@ export async function runReactNativeWizardWithTelemetry( packageVersion: rnVersion, packageId: RN_PACKAGE, acceptableVersions: SUPPORTED_RN_RANGE, + note: `Please upgrade to ${SUPPORTED_RN_RANGE} if you wish to use the Sentry Wizard. +Or setup using ${chalk.cyan( + 'https://docs.sentry.io/platforms/react-native/manual-setup/manual-setup/', + )}`, + }); + } + + await installPackage({ + packageName: RN_SDK_PACKAGE, + alreadyInstalled: hasPackageInstalled(RN_SDK_PACKAGE, packageJson), + }); + const sdkVersion = getPackageVersion( + RN_SDK_PACKAGE, + await getPackageDotJson(), + ); + + const expoVersion = getPackageVersion('expo', packageJson); + const isExpo = !!expoVersion; + if (expoVersion && sdkVersion) { + await confirmContinueIfPackageVersionNotSupported({ + packageName: 'Sentry React Native SDK', + packageVersion: sdkVersion, + packageId: RN_SDK_PACKAGE, + acceptableVersions: SDK_EXPO_SUPPORTED_SDK_RANGE, + note: `Please upgrade to ${SDK_EXPO_SUPPORTED_SDK_RANGE} to continue with the wizard in this Expo project.`, + }); + await confirmContinueIfPackageVersionNotSupported({ + packageName: 'Expo SDK', + packageVersion: expoVersion, + packageId: 'expo', + acceptableVersions: SUPPORTED_EXPO_RANGE, + note: `Please upgrade to ${SUPPORTED_EXPO_RANGE} to continue with the wizard in this Expo project.`, }); } @@ -135,22 +181,24 @@ export async function runReactNativeWizardWithTelemetry( url: sentryUrl, }; - await installPackage({ - packageName: RN_SDK_PACKAGE, - alreadyInstalled: hasPackageInstalled(RN_SDK_PACKAGE, packageJson), - }); - const sdkVersion = getPackageVersion( - RN_SDK_PACKAGE, - await getPackageDotJson(), - ); - - await traceStep('patch-js', () => + await traceStep('patch-app-js', () => addSentryInit({ dsn: selectedProject.keys[0].dsn.public }), ); - await traceStep('patch-metro-config', () => - addSentryToMetroConfig({ sdkVersion }), - ); + if (isExpo) { + await traceStep('patch-expo-app-config', () => + patchExpoAppConfig(cliConfig), + ); + await traceStep('add-expo-env-local', () => addExpoEnvLocal(cliConfig)); + } + + if (isExpo) { + await traceStep('patch-metro-config', addSentryToExpoMetroConfig); + } else { + await traceStep('patch-metro-config', () => + addSentryToMetroConfig({ sdkVersion }), + ); + } if (fs.existsSync('ios')) { Sentry.setTag('patch-ios', true); @@ -217,58 +265,6 @@ function addSentryToMetroConfig({ } } -async function addSentryInit({ dsn }: { dsn: string }) { - const prefixGlob = '{.,./src}'; - const suffixGlob = '@(j|t|cj|mj)s?(x)'; - const universalGlob = `App.${suffixGlob}`; - const jsFileGlob = `${prefixGlob}/+(${universalGlob})`; - const jsPath = traceStep('find-app-js-file', () => - getFirstMatchedPath(jsFileGlob), - ); - Sentry.setTag('app-js-file-status', jsPath ? 'found' : 'not-found'); - if (!jsPath) { - clack.log.warn( - `Could not find main App file using ${chalk.cyan(jsFileGlob)}.`, - ); - await showCopyPasteInstructions( - 'App.js', - getSentryInitColoredCodeSnippet(dsn), - 'This ensures the Sentry SDK is ready to capture errors.', - ); - return; - } - const jsRelativePath = path.relative(process.cwd(), jsPath); - - const js = fs.readFileSync(jsPath, 'utf-8'); - const includesSentry = doesJsCodeIncludeSdkSentryImport(js, { - sdkPackageName: RN_SDK_PACKAGE, - }); - if (includesSentry) { - Sentry.setTag('app-js-file-status', 'already-includes-sentry'); - clack.log.warn( - `${chalk.cyan( - jsRelativePath, - )} already includes Sentry. We wont't add it again.`, - ); - return; - } - - traceStep('add-sentry-init', () => { - const newContent = addSentryInitWithSdkImport(js, { dsn }); - - clack.log.success( - `Added ${chalk.cyan('Sentry.init')} to ${chalk.cyan(jsRelativePath)}.`, - ); - - fs.writeFileSync(jsPath, newContent, 'utf-8'); - }); - - Sentry.setTag('app-js-file-status', 'added-sentry-init'); - clack.log.success( - chalk.green(`${chalk.cyan(jsRelativePath)} changes saved.`), - ); -} - async function confirmFirstSentryException( url: string, orgSlug: string, diff --git a/src/react-native/xcode.ts b/src/react-native/xcode.ts index 662e1dd6..7b724724 100644 --- a/src/react-native/xcode.ts +++ b/src/react-native/xcode.ts @@ -128,11 +128,27 @@ export function doesBundlePhaseIncludeSentry(buildPhase: BuildPhase) { export function addSentryWithBundledScriptsToBundleShellScript( script: string, ): string { - return script.replace( - '$REACT_NATIVE_XCODE', - // eslint-disable-next-line no-useless-escape - '\\"/bin/sh ../node_modules/@sentry/react-native/scripts/sentry-xcode.sh $REACT_NATIVE_XCODE\\"', - ); + const isLikelyPlainReactNativeScript = script.includes('$REACT_NATIVE_XCODE'); + if (isLikelyPlainReactNativeScript) { + return script.replace( + '$REACT_NATIVE_XCODE', + // eslint-disable-next-line no-useless-escape + '\\"/bin/sh ../node_modules/@sentry/react-native/scripts/sentry-xcode.sh $REACT_NATIVE_XCODE\\"', + ); + } + + const isLikelyExpoScript = script.includes('expo'); + if (isLikelyExpoScript) { + const SENTRY_REACT_NATIVE_XCODE_PATH = + "`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'\"`"; + return script.replace( + /^.*?(packager|scripts)\/react-native-xcode\.sh\s*(\\'\\\\")?/m, + // eslint-disable-next-line no-useless-escape + (match: string) => `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_PATH} ${match}`, + ); + } + + return script; } export function addSentryWithCliToBundleShellScript(script: string): string { diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index 8be9a505..35f605a6 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/clack-utils.ts @@ -295,11 +295,13 @@ export async function confirmContinueIfPackageVersionNotSupported({ packageName, packageVersion, acceptableVersions, + note, }: { packageId: string; packageName: string; packageVersion: string; acceptableVersions: string; + note?: string; }): Promise { return traceStep(`check-package-version`, async () => { Sentry.setTag(`${packageName.toLowerCase()}-version`, packageVersion); @@ -321,10 +323,8 @@ export async function confirmContinueIfPackageVersionNotSupported({ ); clack.note( - `Please upgrade to ${acceptableVersions} if you wish to use the Sentry Wizard. -Or setup using ${chalk.cyan( - 'https://docs.sentry.io/platforms/react-native/manual-setup/manual-setup/', - )}`, + note ?? + `Please upgrade to ${acceptableVersions} if you wish to use the Sentry Wizard.`, ); const continueWithUnsupportedVersion = await abortIfCancelled( clack.confirm({ diff --git a/test/react-native/expo-metro.test.ts b/test/react-native/expo-metro.test.ts new file mode 100644 index 00000000..b032740a --- /dev/null +++ b/test/react-native/expo-metro.test.ts @@ -0,0 +1,81 @@ +// @ts-ignore - magicast is ESM and TS complains about that. It works though +import { generateCode, parseModule } from 'magicast'; +import { patchMetroInMemory } from '../../src/react-native/expo-metro'; + +describe('expo-metro config', () => { + test('patches minimal expo config', () => { + const mod = parseModule(` +const { getDefaultConfig } = require("expo/metro-config"); + +/** @type {import('expo/metro-config').MetroConfig} */ +const config = getDefaultConfig(__dirname); + +config.resolver.assetExts.push( + // Adds support for .db files for SQLite databases + 'db' +); + +module.exports = config; + `); + + const result = patchMetroInMemory(mod); + expect(result).toBe(true); + expect(generateCode(mod.$ast).code).toBe( + ` +const { + getSentryExpoConfig +} = require("@sentry/react-native/metro"); + +/** @type {import('expo/metro-config').MetroConfig} */ +const config = getSentryExpoConfig(__dirname); + +config.resolver.assetExts.push( + // Adds support for .db files for SQLite databases + 'db' +); + +module.exports = config; +`.trim(), + ); + }); + + test('keeps expo metro config if other imports are present', () => { + const mod = parseModule(` +const { getDefaultConfig, otherExport } = require("expo/metro-config"); + +const config = getDefaultConfig(__dirname); + +module.exports = config; + `); + + const result = patchMetroInMemory(mod); + expect(result).toBe(true); + expect(generateCode(mod.$ast).code).toBe( + ` +const { getDefaultConfig, otherExport } = require("expo/metro-config"); + +const { + getSentryExpoConfig +} = require("@sentry/react-native/metro"); + +const config = getSentryExpoConfig(__dirname); + +module.exports = config; +`.trim(), + ); + }); + + test('does not modify when sentry already present', () => { + const mod = parseModule(` +const { getSentryExpoConfig } = require("@sentry/react-native/metro"); +`); + + const result = patchMetroInMemory(mod); + expect(result).toBe(false); + expect(generateCode(mod.$ast).code).toBe( + ` +const { getSentryExpoConfig } = require("@sentry/react-native/metro"); +`.trim(), + ); + }); +}); diff --git a/test/react-native/expo.test.ts b/test/react-native/expo.test.ts new file mode 100644 index 00000000..28666d1b --- /dev/null +++ b/test/react-native/expo.test.ts @@ -0,0 +1,86 @@ +import { addWithSentryToAppConfigJson } from '../../src/react-native/expo'; +import { RNCliSetupConfigContent } from '../../src/react-native/react-native-wizard'; + +describe('expo', () => { + const MOCK_CONFIG: RNCliSetupConfigContent = { + url: 'https://sentry.mock/', + org: 'sentry-mock', + project: 'project-mock', + authToken: 'authToken-mock', + }; + + describe('addWithSentryToAppConfigJson', () => { + test('do not add if sentry-expo present', () => { + const appConfigJson = `{ + "expo": { + "plugins": ["sentry-expo"] + } + }`; + expect( + addWithSentryToAppConfigJson(appConfigJson, MOCK_CONFIG), + ).toBeNull(); + }); + + test('do not add if sentry-react-native/expo present', () => { + const appConfigJson = `{ + "expo": { + "plugins": ["@sentry/react-native/expo"] + } + }`; + expect( + addWithSentryToAppConfigJson(appConfigJson, MOCK_CONFIG), + ).toBeNull(); + }); + + test.each([ + [ + `{ + "expo": { + "plugins": "should be an array, but it is not" + } + }`, + ], + [ + `{ + "expo": ["should be an object, but it is not"] + }`, + ], + ])('do not add if plugins is not an array', (appConfigJson) => { + expect( + addWithSentryToAppConfigJson(appConfigJson, MOCK_CONFIG), + ).toBeNull(); + }); + + test.each([ + [ + `{ + "expo": { + "plugins": [] + } + }`, + ], + [`{}`], + [ + `{ + "expo": {} + }`, + ], + ])('add sentry react native expo plugin configuration', (appConfigJson) => { + const result = addWithSentryToAppConfigJson(appConfigJson, MOCK_CONFIG); + expect(JSON.parse(result ?? '{}')).toStrictEqual({ + expo: { + plugins: [ + [ + '@sentry/react-native/expo', + { + url: 'https://sentry.mock/', + organization: 'sentry-mock', + project: 'project-mock', + }, + ], + ], + }, + }); + }); + }); +}); diff --git a/test/react-native/xcode.test.ts b/test/react-native/xcode.test.ts index de6d2639..507afc20 100644 --- a/test/react-native/xcode.test.ts +++ b/test/react-native/xcode.test.ts @@ -57,6 +57,96 @@ REACT_NATIVE_XCODE="../node_modules/react-native/scripts/react-native-xcode.sh" expectedOutput, ); }); + + it('adds sentry cli to expo bundle build phase', () => { + const input = ` +if [[ -f "$PODS_ROOT/../.xcode.env" ]]; then + source "$PODS_ROOT/../.xcode.env" +fi +if [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then + source "$PODS_ROOT/../.xcode.env.local" +fi + +# The project root by default is one level up from the ios directory +export PROJECT_ROOT="$PROJECT_DIR"/.. + +if [[ "$CONFIGURATION" = *Debug* ]]; then + export SKIP_BUNDLING=1 +fi +if [[ -z "$ENTRY_FILE" ]]; then + # Set the entry JS file using the bundler's entry resolution. + export ENTRY_FILE="$("$NODE_BINARY" -e "require('expo/scripts/resolveAppEntry')" "$PROJECT_ROOT" ios absolute | tail -n 1)" +fi + +if [[ -z "$CLI_PATH" ]]; then + # Use Expo CLI + export CLI_PATH="$("$NODE_BINARY" --print "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })")" +fi +if [[ -z "$BUNDLE_COMMAND" ]]; then + # Default Expo CLI command for bundling + export BUNDLE_COMMAND="export:embed" +fi + +# Source .xcode.env.updates if it exists to allow +# SKIP_BUNDLING to be unset if needed +if [[ -f "$PODS_ROOT/../.xcode.env.updates" ]]; then + source "$PODS_ROOT/../.xcode.env.updates" +fi +# Source local changes to allow overrides +# if needed +if [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then + source "$PODS_ROOT/../.xcode.env.local" +fi + +\`"$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'"\` +`; + + const expectedOutput = ` +if [[ -f "$PODS_ROOT/../.xcode.env" ]]; then + source "$PODS_ROOT/../.xcode.env" +fi +if [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then + source "$PODS_ROOT/../.xcode.env.local" +fi + +# The project root by default is one level up from the ios directory +export PROJECT_ROOT="$PROJECT_DIR"/.. + +if [[ "$CONFIGURATION" = *Debug* ]]; then + export SKIP_BUNDLING=1 +fi +if [[ -z "$ENTRY_FILE" ]]; then + # Set the entry JS file using the bundler's entry resolution. + export ENTRY_FILE="$("$NODE_BINARY" -e "require('expo/scripts/resolveAppEntry')" "$PROJECT_ROOT" ios absolute | tail -n 1)" +fi + +if [[ -z "$CLI_PATH" ]]; then + # Use Expo CLI + export CLI_PATH="$("$NODE_BINARY" --print "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })")" +fi +if [[ -z "$BUNDLE_COMMAND" ]]; then + # Default Expo CLI command for bundling + export BUNDLE_COMMAND="export:embed" +fi + +# Source .xcode.env.updates if it exists to allow +# SKIP_BUNDLING to be unset if needed +if [[ -f "$PODS_ROOT/../.xcode.env.updates" ]]; then + source "$PODS_ROOT/../.xcode.env.updates" +fi +# Source local changes to allow overrides +# if needed +if [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then + source "$PODS_ROOT/../.xcode.env.local" +fi + +/bin/sh \`"$NODE_BINARY" --print "require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'"\` \`"$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'"\` +`; + + expect(addSentryWithBundledScriptsToBundleShellScript(input)).toBe( + expectedOutput, + ); + }); }); describe('removeSentryFromBundleShellScript', () => {