diff --git a/.gitignore b/.gitignore index 818f97beb2..87a34b65ec 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,6 @@ yalc.lock # E2E tests test/react-native/versions + +# Created by Sentry Metro Plugin +.sentry/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 072dfa21a9..b6d284a577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased + +### Features + +- Add `annotateReactComponents` option to `@sentry/react-native/metro` ([#3916](https://github.com/getsentry/sentry-react-native/pull/3916)) + + ```js + // For Expo + const { getSentryExpoConfig } = require("@sentry/react-native/metro"); + const config = getSentryExpoConfig(__dirname, { annotateReactComponents: true }); + + // For RN + const { getDefaultConfig } = require('@react-native/metro-config'); + const { withSentryConfig } = require('@sentry/react-native/metro'); + module.exports = withSentryConfig(getDefaultConfig(__dirname), { annotateReactComponents: true }); + ``` + ## 5.25.0-alpha.2 ### Features @@ -483,7 +500,7 @@ see [the Expo guide](https://docs.sentry.io/platforms/react-native/manual-setup/ const { getSentryExpoConfig } = require("@sentry/react-native/metro"); // const config = getDefaultConfig(__dirname); - const config = getSentryExpoConfig(config, {}); + const config = getSentryExpoConfig(__dirname); ``` - New `npx sentry-expo-upload-sourcemaps` for simple EAS Update (`npx expo export`) source maps upload ([#3491](https://github.com/getsentry/sentry-react-native/pull/3491), [#3510](https://github.com/getsentry/sentry-react-native/pull/3510), [#3515](https://github.com/getsentry/sentry-react-native/pull/3515), [#3507](https://github.com/getsentry/sentry-react-native/pull/3507)) diff --git a/package.json b/package.json index 3e7a67ddd0..4530b13d5a 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "react-native": ">=0.65.0" }, "dependencies": { + "@sentry/babel-plugin-component-annotate": "2.20.1", "@sentry/browser": "7.117.0", "@sentry/cli": "2.31.2", "@sentry/core": "7.117.0", diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index 810f56797b..094ea7cba8 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -9,12 +9,15 @@ import { HttpClient } from '@sentry/integrations'; import { SENTRY_INTERNAL_DSN } from '../utils/dsn'; import * as Sentry from '@sentry/react-native'; import { isExpoGo } from '../utils/isExpoGo'; +import { LogBox } from 'react-native'; export { // Catch any errors thrown by the Layout component. ErrorBoundary, } from 'expo-router'; +LogBox.ignoreAllLogs(); + // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); diff --git a/samples/expo/babel.config.js b/samples/expo/babel.config.js index 7a13872315..3a495f8f7c 100644 --- a/samples/expo/babel.config.js +++ b/samples/expo/babel.config.js @@ -1,5 +1,3 @@ -const componentAnnotatePlugin = require('@sentry/babel-plugin-component-annotate'); - module.exports = function (api) { api.cache(false); return { @@ -13,7 +11,6 @@ module.exports = function (api) { }, }, ], - componentAnnotatePlugin, ], }; }; diff --git a/samples/expo/metro.config.js b/samples/expo/metro.config.js index b79a919dd8..d8f5aa467a 100644 --- a/samples/expo/metro.config.js +++ b/samples/expo/metro.config.js @@ -9,6 +9,7 @@ const config = getSentryExpoConfig(__dirname, { // [Web-only]: Enables CSS support in Metro. isCSSEnabled: true, getDefaultConfig, + annotateReactComponents: true, }); config.watchFolders.push(path.resolve(__dirname, '../../node_modules/@sentry')); diff --git a/samples/react-native/babel.config.js b/samples/react-native/babel.config.js index 06dc90ec75..8c8fb9c1a6 100644 --- a/samples/react-native/babel.config.js +++ b/samples/react-native/babel.config.js @@ -1,5 +1,3 @@ -const componentAnnotatePlugin = require('@sentry/babel-plugin-component-annotate'); - module.exports = { presets: ['module:@react-native/babel-preset'], plugins: [ @@ -12,6 +10,5 @@ module.exports = { }, ], 'react-native-reanimated/plugin', - componentAnnotatePlugin, ], }; diff --git a/samples/react-native/metro.config.js b/samples/react-native/metro.config.js index 10d30549bc..9e1e8c7f14 100644 --- a/samples/react-native/metro.config.js +++ b/samples/react-native/metro.config.js @@ -60,4 +60,6 @@ const config = { }; const m = mergeConfig(getDefaultConfig(__dirname), config); -module.exports = withSentryConfig(m); +module.exports = withSentryConfig(m, { + annotateReactComponents: true, +}); diff --git a/samples/react-native/src/Screens/TrackerScreen.tsx b/samples/react-native/src/Screens/TrackerScreen.tsx index 22ee696ddb..7716fff65c 100644 --- a/samples/react-native/src/Screens/TrackerScreen.tsx +++ b/samples/react-native/src/Screens/TrackerScreen.tsx @@ -73,9 +73,7 @@ const TrackerScreen = () => { return ( - - Global COVID19 Cases - + {cases ? ( <> @@ -111,6 +109,12 @@ const TrackerScreen = () => { ); }; +const TrackerTitle = () => ( + + Global COVID19 Cases + +); + export default Sentry.withProfiler(TrackerScreen); const Statistic = (props: { diff --git a/src/js/tools/enableLogger.ts b/src/js/tools/enableLogger.ts new file mode 100644 index 0000000000..a5d36ade2a --- /dev/null +++ b/src/js/tools/enableLogger.ts @@ -0,0 +1,10 @@ +import { logger } from '@sentry/utils'; + +/** + * Enables debug logger when SENTRY_LOG_LEVEL=debug. + */ +export function enableLogger(): void { + if (process.env.SENTRY_LOG_LEVEL === 'debug') { + logger.enable(); + } +} diff --git a/src/js/tools/metroconfig.ts b/src/js/tools/metroconfig.ts index 8f40922016..6e58544757 100644 --- a/src/js/tools/metroconfig.ts +++ b/src/js/tools/metroconfig.ts @@ -1,24 +1,51 @@ +import { logger } from '@sentry/utils'; import type { MetroConfig, MixedOutput, Module, ReadOnlyGraph } from 'metro'; +import * as process from 'process'; import { env } from 'process'; +import { enableLogger } from './enableLogger'; +import { cleanDefaultBabelTransformerPath, saveDefaultBabelTransformerPath } from './sentryBabelTransformerUtils'; import { createSentryMetroSerializer, unstable_beforeAssetSerializationPlugin } from './sentryMetroSerializer'; import type { DefaultConfigOptions } from './vendor/expo/expoconfig'; export * from './sentryMetroSerializer'; +enableLogger(); + +export interface SentryMetroConfigOptions { + /** + * Annotates React components with Sentry data. + * @default false + */ + annotateReactComponents?: boolean; +} + +export interface SentryExpoConfigOptions { + /** + * Pass a custom `getDefaultConfig` function to override the default Expo configuration getter. + */ + getDefaultConfig?: typeof getSentryExpoConfig; +} + /** * Adds Sentry to the Metro config. * * Adds Debug ID to the output bundle and source maps. * Collapses Sentry frames from the stack trace view in LogBox. */ -export function withSentryConfig(config: MetroConfig): MetroConfig { +export function withSentryConfig( + config: MetroConfig, + { annotateReactComponents = false }: SentryMetroConfigOptions = {}, +): MetroConfig { setSentryMetroDevServerEnvFlag(); let newConfig = config; newConfig = withSentryDebugId(newConfig); newConfig = withSentryFramesCollapsed(newConfig); + if (annotateReactComponents) { + newConfig = withSentryBabelTransformer(newConfig); + } return newConfig; } @@ -28,7 +55,7 @@ export function withSentryConfig(config: MetroConfig): MetroConfig { */ export function getSentryExpoConfig( projectRoot: string, - options: DefaultConfigOptions & { getDefaultConfig?: typeof getSentryExpoConfig } = {}, + options: DefaultConfigOptions & SentryExpoConfigOptions & SentryMetroConfigOptions = {}, ): MetroConfig { setSentryMetroDevServerEnvFlag(); @@ -41,7 +68,12 @@ export function getSentryExpoConfig( ], }); - return withSentryFramesCollapsed(config); + let newConfig = withSentryFramesCollapsed(config); + if (options.annotateReactComponents) { + newConfig = withSentryBabelTransformer(newConfig); + } + + return newConfig; } function loadExpoMetroConfigModule(): { @@ -64,6 +96,38 @@ function loadExpoMetroConfigModule(): { } } +/** + * Adds Sentry Babel transformer to the Metro config. + */ +export function withSentryBabelTransformer(config: MetroConfig): MetroConfig { + const defaultBabelTransformerPath = config.transformer && config.transformer.babelTransformerPath; + logger.debug('Default Babel transformer path from `config.transformer`:', defaultBabelTransformerPath); + + if (!defaultBabelTransformerPath) { + // This has to be console.warn because the options is enabled but won't be used + // eslint-disable-next-line no-console + console.warn('`transformer.babelTransformerPath` is undefined.'); + // eslint-disable-next-line no-console + console.warn('Sentry Babel transformer cannot be used. Not adding it...'); + return config; + } + + if (defaultBabelTransformerPath) { + saveDefaultBabelTransformerPath(defaultBabelTransformerPath); + process.on('exit', () => { + cleanDefaultBabelTransformerPath(); + }); + } + + return { + ...config, + transformer: { + ...config.transformer, + babelTransformerPath: require.resolve('./sentryBabelTransformer'), + }, + }; +} + type MetroCustomSerializer = Required['serializer']>['customSerializer'] | undefined; function withSentryDebugId(config: MetroConfig): MetroConfig { diff --git a/src/js/tools/sentryBabelTransformer.ts b/src/js/tools/sentryBabelTransformer.ts new file mode 100644 index 0000000000..e1833fab72 --- /dev/null +++ b/src/js/tools/sentryBabelTransformer.ts @@ -0,0 +1,43 @@ +import componentAnnotatePlugin from '@sentry/babel-plugin-component-annotate'; + +import { enableLogger } from './enableLogger'; +import { loadDefaultBabelTransformer } from './sentryBabelTransformerUtils'; +import type { BabelTransformer, BabelTransformerArgs } from './vendor/metro/metroBabelTransformer'; + +enableLogger(); + +/** + * Creates a Babel transformer with Sentry component annotation plugin. + */ +function createSentryBabelTransformer(): BabelTransformer { + const defaultTransformer = loadDefaultBabelTransformer(); + + // Using spread operator to avoid any conflicts with the default transformer + const transform: BabelTransformer['transform'] = (...args) => { + const transformerArgs = args[0]; + + addSentryComponentAnnotatePlugin(transformerArgs); + + return defaultTransformer.transform(...args); + }; + + return { + ...defaultTransformer, + transform, + }; +} + +function addSentryComponentAnnotatePlugin(args: BabelTransformerArgs | undefined): void { + if (!args || typeof args.filename !== 'string' || !Array.isArray(args.plugins)) { + return undefined; + } + + if (!args.filename.includes('node_modules')) { + args.plugins.push(componentAnnotatePlugin); + } +} + +const sentryBabelTransformer = createSentryBabelTransformer(); +// With TS set to `commonjs` this will be translated to `module.exports = sentryBabelTransformer;` +// which will be correctly picked up by Metro +export = sentryBabelTransformer; diff --git a/src/js/tools/sentryBabelTransformerUtils.ts b/src/js/tools/sentryBabelTransformerUtils.ts new file mode 100644 index 0000000000..dd04d2f67b --- /dev/null +++ b/src/js/tools/sentryBabelTransformerUtils.ts @@ -0,0 +1,65 @@ +import { logger } from '@sentry/utils'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as process from 'process'; + +import type { BabelTransformer } from './vendor/metro/metroBabelTransformer'; + +/** + * Saves default Babel transformer path to the project root. + */ +export function saveDefaultBabelTransformerPath(defaultBabelTransformerPath: string): void { + try { + fs.mkdirSync(path.join(process.cwd(), '.sentry'), { recursive: true }); + fs.writeFileSync(getDefaultBabelTransformerPath(), defaultBabelTransformerPath); + logger.debug('Saved default Babel transformer path'); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[Sentry] Failed to save default Babel transformer path:', e); + } +} + +/** + * Reads default Babel transformer path from the project root. + */ +export function readDefaultBabelTransformerPath(): string | undefined { + try { + return fs.readFileSync(getDefaultBabelTransformerPath()).toString(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[Sentry] Failed to read default Babel transformer path:', e); + } + return undefined; +} + +/** + * Cleans default Babel transformer path from the project root. + */ +export function cleanDefaultBabelTransformerPath(): void { + try { + fs.unlinkSync(getDefaultBabelTransformerPath()); + logger.debug('Cleaned default Babel transformer path'); + } catch (e) { + // We don't want to fail the build if we can't clean the file + // eslint-disable-next-line no-console + console.error('[Sentry] Failed to clean default Babel transformer path:', e); + } +} + +function getDefaultBabelTransformerPath(): string { + return path.join(process.cwd(), '.sentry/.defaultBabelTransformerPath'); +} + +/** + * Loads default Babel transformer from `@react-native/metro-config` -> `@react-native/metro-babel-transformer`. + */ +export function loadDefaultBabelTransformer(): BabelTransformer { + const defaultBabelTransformerPath = readDefaultBabelTransformerPath(); + if (!defaultBabelTransformerPath) { + throw new Error('Default Babel Transformer Path not found in `.sentry` directory.'); + } + + logger.debug(`Loading default Babel transformer from ${defaultBabelTransformerPath}`); + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require(defaultBabelTransformerPath); +} diff --git a/src/js/tools/vendor/metro/metroBabelTransformer.ts b/src/js/tools/vendor/metro/metroBabelTransformer.ts new file mode 100644 index 0000000000..62b5616943 --- /dev/null +++ b/src/js/tools/vendor/metro/metroBabelTransformer.ts @@ -0,0 +1,64 @@ +// Vendored / modified from @facebook/metro + +// https://github.com/facebook/metro/blob/9b295e5f7ecd9cb6332a199bf9cdc1bd8fddf6d9/packages/metro-babel-transformer/types/index.d.ts + +// MIT License + +// Copyright (c) Meta Platforms, Inc. and affiliates. + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +export interface CustomTransformOptions { + [key: string]: unknown; +} + +export type TransformProfile = 'default' | 'hermes-stable' | 'hermes-canary'; + +export interface BabelTransformerOptions { + readonly customTransformOptions?: CustomTransformOptions; + readonly dev: boolean; + readonly enableBabelRCLookup?: boolean; + readonly enableBabelRuntime: boolean | string; + readonly extendsBabelConfigPath?: string; + readonly experimentalImportSupport?: boolean; + readonly hermesParser?: boolean; + readonly hot: boolean; + readonly minify: boolean; + readonly unstable_disableES6Transforms?: boolean; + readonly platform: string | null; + readonly projectRoot: string; + readonly publicPath: string; + readonly unstable_transformProfile?: TransformProfile; + readonly globalPrefix: string; +} + +export interface BabelTransformerArgs { + readonly filename: string; + readonly options: BabelTransformerOptions; + readonly plugins?: unknown; + readonly src: string; +} + +export interface BabelTransformer { + transform: (args: BabelTransformerArgs) => { + ast: unknown; + metadata: unknown; + }; + getCacheKey?: () => string; +} diff --git a/test/react-native/rn.patch.metro.config.js b/test/react-native/rn.patch.metro.config.js index 05cb2b4fe1..f4354c2153 100755 --- a/test/react-native/rn.patch.metro.config.js +++ b/test/react-native/rn.patch.metro.config.js @@ -20,6 +20,8 @@ const importSerializer = "const { withSentryConfig } = require('@sentry/react-na let config = fs.readFileSync(configFilePath, 'utf8').split('\n'); +const sentryOptions = '{ annotateReactComponents: true }'; + const isPatched = config.includes(importSerializer); if (!isPatched) { config = [importSerializer, ...config]; @@ -35,11 +37,18 @@ if (!isPatched) { lineParsed[1] = lineParsed[1].slice(0, -1); } - lineParsed[1] = `= withSentryConfig(${lineParsed[1]}${endsWithSemicolon ? ');' : ''}`; + lineParsed[1] = `= withSentryConfig(${lineParsed[1]}${endsWithSemicolon ? `, ${sentryOptions});` : ''}`; config[moduleExportsLineIndex] = lineParsed.join(''); if (endOfModuleExportsIndex !== -1) { - config[endOfModuleExportsIndex] = '});'; + config[endOfModuleExportsIndex] = `}, ${sentryOptions});`; + } + + // RN Before 0.72 does not include default config in the metro.config.js + // We have to specify babelTransformerPath manually + const transformerIndex = config.findIndex(line => line.includes('transformer: {')); + if (transformerIndex !== -1) { + config[transformerIndex] = `transformer: { babelTransformerPath: require.resolve('metro-babel-transformer'),`; } fs.writeFileSync(configFilePath, config.join('\n'), 'utf8'); diff --git a/test/tools/fixtures/mockBabelTransformer.js b/test/tools/fixtures/mockBabelTransformer.js new file mode 100644 index 0000000000..17628495a5 --- /dev/null +++ b/test/tools/fixtures/mockBabelTransformer.js @@ -0,0 +1,4 @@ +module.exports = { + transform: jest.fn(), + getCacheKey: jest.fn(), +}; diff --git a/test/tools/metroconfig.test.ts b/test/tools/metroconfig.test.ts index 63312c8816..a0ee9533ff 100644 --- a/test/tools/metroconfig.test.ts +++ b/test/tools/metroconfig.test.ts @@ -1,33 +1,112 @@ +jest.mock('fs', () => { + return { + mkdirSync: jest.fn(), + writeFileSync: jest.fn(), + unlinkSync: jest.fn(), + }; +}); + +import * as fs from 'fs'; import type { MetroConfig } from 'metro'; +import * as path from 'path'; +import * as process from 'process'; -import { withSentryFramesCollapsed } from '../../src/js/tools/metroconfig'; +import { withSentryBabelTransformer, withSentryFramesCollapsed } from '../../src/js/tools/metroconfig'; type MetroFrame = Parameters['symbolicator']>['customizeFrame']>[0]; -describe('withSentryFramesCollapsed', () => { - test('adds customizeFrames if undefined ', () => { - const config = withSentryFramesCollapsed({}); - expect(config.symbolicator?.customizeFrame).toBeDefined(); +describe('metroconfig', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - test('wraps existing customizeFrames', async () => { - const originalCustomizeFrame = jest.fn(); - const config = withSentryFramesCollapsed({ symbolicator: { customizeFrame: originalCustomizeFrame } }); + describe('withSentryFramesCollapsed', () => { + test('adds customizeFrames if undefined ', () => { + const config = withSentryFramesCollapsed({}); + expect(config.symbolicator?.customizeFrame).toBeDefined(); + }); + + test('wraps existing customizeFrames', async () => { + const originalCustomizeFrame = jest.fn(); + const config = withSentryFramesCollapsed({ symbolicator: { customizeFrame: originalCustomizeFrame } }); + + const customizeFrame = config.symbolicator?.customizeFrame; + await customizeFrame?.(createMockSentryInstrumentMetroFrame()); - const customizeFrame = config.symbolicator?.customizeFrame; - await customizeFrame?.(createMockSentryInstrumentMetroFrame()); + expect(config.symbolicator?.customizeFrame).not.toBe(originalCustomizeFrame); + expect(originalCustomizeFrame).toHaveBeenCalledTimes(1); + }); - expect(config.symbolicator?.customizeFrame).not.toBe(originalCustomizeFrame); - expect(originalCustomizeFrame).toHaveBeenCalledTimes(1); + test('collapses sentry instrument frames', async () => { + const config = withSentryFramesCollapsed({}); + + const customizeFrame = config.symbolicator?.customizeFrame; + const customizedFrame = await customizeFrame?.(createMockSentryInstrumentMetroFrame()); + + expect(customizedFrame?.collapse).toBe(true); + }); }); - test('collapses sentry instrument frames', async () => { - const config = withSentryFramesCollapsed({}); + describe('withSentryBabelTransformer', () => { + test.each([[{}], [{ transformer: {} }], [{ transformer: { hermesParser: true } }]])( + "does not add babel transformer none is set in the config object '%o'", + input => { + expect(withSentryBabelTransformer(JSON.parse(JSON.stringify(input)))).toEqual(input); + }, + ); + + test.each([ + [{ transformer: { babelTransformerPath: 'babelTransformerPath' }, projectRoot: 'project/root' }], + [{ transformer: { babelTransformerPath: 'babelTransformerPath' } }], + ])('save default babel transformer path to a file', () => { + const defaultBabelTransformerPath = '/default/babel/transformer'; + + withSentryBabelTransformer({ + transformer: { + babelTransformerPath: defaultBabelTransformerPath, + }, + projectRoot: 'project/root', + }); + + expect(fs.mkdirSync).toHaveBeenCalledWith(path.join(process.cwd(), '.sentry'), { recursive: true }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(process.cwd(), '.sentry/.defaultBabelTransformerPath'), + defaultBabelTransformerPath, + ); + }); + + test('clean default babel transformer path file on exit', () => { + const processOnSpy: jest.SpyInstance = jest.spyOn(process, 'on'); + + const defaultBabelTransformerPath = 'defaultBabelTransformerPath'; + + withSentryBabelTransformer({ + transformer: { + babelTransformerPath: defaultBabelTransformerPath, + }, + projectRoot: 'project/root', + }); + + const actualExitHandler: () => void | undefined = processOnSpy.mock.calls[0][1]; + actualExitHandler?.(); + + expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(fs.unlinkSync).toHaveBeenCalledWith(path.join(process.cwd(), '.sentry/.defaultBabelTransformerPath')); + }); + + test('return config with sentry babel transformer path', () => { + const defaultBabelTransformerPath = 'defaultBabelTransformerPath'; - const customizeFrame = config.symbolicator?.customizeFrame; - const customizedFrame = await customizeFrame?.(createMockSentryInstrumentMetroFrame()); + const config = withSentryBabelTransformer({ + transformer: { + babelTransformerPath: defaultBabelTransformerPath, + }, + }); - expect(customizedFrame?.collapse).toBe(true); + expect(config.transformer?.babelTransformerPath).toBe( + require.resolve('../../src/js/tools/sentryBabelTransformer'), + ); + }); }); }); diff --git a/test/tools/sentryBabelTransformer.test.ts b/test/tools/sentryBabelTransformer.test.ts new file mode 100644 index 0000000000..3c888d1195 --- /dev/null +++ b/test/tools/sentryBabelTransformer.test.ts @@ -0,0 +1,87 @@ +jest.mock('fs', () => { + return { + readFileSync: jest.fn(), + }; +}); + +import * as fs from 'fs'; + +// needs to be defined before sentryBabelTransformer is imported +// the transformer is created on import (side effect) +(fs.readFileSync as jest.Mock).mockReturnValue(require.resolve('./fixtures/mockBabelTransformer.js')); + +import * as SentryBabelTransformer from '../../src/js/tools/sentryBabelTransformer'; +import type { BabelTransformerArgs } from '../../src/js/tools/vendor/metro/metroBabelTransformer'; + +const MockDefaultBabelTransformer: { + transform: jest.Mock; + getCacheKey: jest.Mock; +} = require('./fixtures/mockBabelTransformer'); + +describe('SentryBabelTransformer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('getCacheKey calls the original transformer', () => { + SentryBabelTransformer.getCacheKey?.(); + + expect(SentryBabelTransformer.getCacheKey).toBeDefined(); + expect(MockDefaultBabelTransformer.getCacheKey).toHaveBeenCalledTimes(1); + }); + + test('transform calls the original transformer with the annotation plugin', () => { + SentryBabelTransformer.transform?.({ + filename: '/project/file', + options: { + projectRoot: 'project/root', + }, + plugins: [jest.fn()], + } as BabelTransformerArgs); + + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledWith({ + filename: '/project/file', + options: { + projectRoot: 'project/root', + }, + plugins: [expect.any(Function), expect.any(Function)], + }); + expect(MockDefaultBabelTransformer.transform.mock.calls[0][0]['plugins'][1].name).toEqual( + 'componentNameAnnotatePlugin', + ); + }); + + test('transform adds plugin', () => { + SentryBabelTransformer.transform?.({ + filename: '/project/file', + options: { + projectRoot: 'project/root', + }, + plugins: [], + } as BabelTransformerArgs); + }); + + test.each([ + [ + { + filename: 'node_modules/file', + plugins: [jest.fn()], + } as BabelTransformerArgs, + ], + [ + { + filename: 'project/node_modules/file', + plugins: [jest.fn()], + } as BabelTransformerArgs, + ], + ])('transform does not add plugin if filename includes node_modules', input => { + SentryBabelTransformer.transform?.(input); + + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledWith({ + filename: input.filename, + plugins: expect.not.arrayContaining([expect.objectContaining({ name: 'componentNameAnnotatePlugin' })]), + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index d36f2dd66b..92775e219d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3804,6 +3804,11 @@ resolved "https://registry.yarnpkg.com/@sentry-internal/typescript/-/typescript-7.117.0.tgz#bd43fc07a222e98861e6ab8a85ddd60e7399cd47" integrity sha512-SylReCEo1FiTuir6XiZuV+sWBOBERDL0C3YmdHhczOh0aeu50FUja7uJfoXMx0LTEwaUAXq62dWUvb9WetluOQ== +"@sentry/babel-plugin-component-annotate@2.20.1": + version "2.20.1" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.20.1.tgz#204c63ed006a048f48f633876e1b8bacf87a9722" + integrity sha512-4mhEwYTK00bIb5Y9UWIELVUfru587Vaeg0DQGswv4aIRHIiMKLyNqCEejaaybQ/fNChIZOKmvyqXk430YVd7Qg== + "@sentry/browser@7.117.0": version "7.117.0" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.117.0.tgz#3030073f360974dadcf5a5f2e1542497b3be2482"