diff --git a/.craft.yml b/.craft.yml index 85179f1c..e3db425c 100644 --- a/.craft.yml +++ b/.craft.yml @@ -9,6 +9,7 @@ requireNames: - /^sentry-rollup-plugin-.*\.tgz$/ - /^sentry-vite-plugin-.*\.tgz$/ - /^sentry-webpack-plugin-.*\.tgz$/ + - /^sentry-component-annotate-plugin-.*\.tgz$/ targets: - name: github includeNames: /^sentry-.*.tgz$/ diff --git a/packages/component-annotate-plugin/.babelrc.json b/packages/component-annotate-plugin/.babelrc.json new file mode 100644 index 00000000..0c20c06e --- /dev/null +++ b/packages/component-annotate-plugin/.babelrc.json @@ -0,0 +1,3 @@ +{ + "presets": ["@babel/env", "@babel/typescript"] +} diff --git a/packages/component-annotate-plugin/.eslintrc.js b/packages/component-annotate-plugin/.eslintrc.js new file mode 100644 index 00000000..42b35d84 --- /dev/null +++ b/packages/component-annotate-plugin/.eslintrc.js @@ -0,0 +1,20 @@ +const jestPackageJson = require("jest/package.json"); + +/** @type {import('eslint').ESLint.Options} */ +module.exports = { + root: true, + extends: ["@sentry-internal/eslint-config/jest", "@sentry-internal/eslint-config/base"], + ignorePatterns: [".eslintrc.js", "dist", "jest.config.js", "rollup.config.js"], + parserOptions: { + tsconfigRootDir: __dirname, + project: ["./src/tsconfig.json", "./test/tsconfig.json"], + }, + env: { + node: true, + }, + settings: { + jest: { + version: jestPackageJson.version, + }, + }, +}; diff --git a/packages/component-annotate-plugin/.gitignore b/packages/component-annotate-plugin/.gitignore new file mode 100644 index 00000000..36d3a9c3 --- /dev/null +++ b/packages/component-annotate-plugin/.gitignore @@ -0,0 +1,2 @@ +dist +.DS_Store diff --git a/packages/component-annotate-plugin/LICENSE b/packages/component-annotate-plugin/LICENSE new file mode 100644 index 00000000..042360af --- /dev/null +++ b/packages/component-annotate-plugin/LICENSE @@ -0,0 +1,29 @@ +# MIT License + +Copyright (c) 2024, Sentry +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/component-annotate-plugin/README.md b/packages/component-annotate-plugin/README.md new file mode 100644 index 00000000..562b656c --- /dev/null +++ b/packages/component-annotate-plugin/README.md @@ -0,0 +1,88 @@ +

+ + Sentry + +

+ +# Sentry Component Annotate Plugin (Beta) + +[![npm version](https://img.shields.io/npm/v/@sentry/component-annotate-plugin.svg)](https://www.npmjs.com/package/@sentry/component-annotate-plugin) +[![npm dm](https://img.shields.io/npm/dm/@sentry/component-annotate-plugin.svg)](https://www.npmjs.com/package/@sentry/component-annotate-plugin) +[![npm dt](https://img.shields.io/npm/dt/@sentry/component-annotate-plugin.svg)](https://www.npmjs.com/package/@component-annotate-plugin) + +This plugin is currently in beta. Please help us improve by [reporting any issues or giving us feedback](https://github.com/getsentry/sentry-javascript-bundler-plugins/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc). + +A Babel plugin that automatically annotates your output DOM with their respective frontend component names. +This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. +Please note that your Sentry JavaScript SDK version must be at least `7.91.0` to take advantage of these features. +Currently, this plugin only works with React, and will exclusively parse `.jsx` and `.tsx` files. + +### Note + +This plugin comes included in Sentry's bundler plugins, alongside many other features to improve your Sentry workflow. +It can be downloaded individually, but it is recommended that you install the bundler plugins for your respective bundler, and enable this feature through the config object. + +Check out the supported bundler plugin packages for installation instructions: + +- [Rollup](https://www.npmjs.com/package/@sentry/rollup-plugin) +- [Vite](https://www.npmjs.com/package/@sentry/vite-plugin) +- [Webpack](https://www.npmjs.com/package/@sentry/webpack-plugin) +- esbuild: Not currently supported + +## Installation + +Using npm: + +```bash +npm install @sentry/component-annotate-plugin --save-dev +``` + +Using yarn: + +```bash +yarn add @sentry/component-annotate-plugin --dev +``` + +Using pnpm: + +```bash +pnpm install @sentry/component-annotate-plugin --dev +``` + +## Example + +```js +// babel.config.js + +{ + // ... other config above ... + + plugins: [ + // Put this plugin before any other plugins you have that transform JSX code + ['@sentry/component-annotate-plugin'] + ], +} +``` + +Or alternatively, configure the plugin by directly importing it: + +```js +// babel.config.js + +import {componentNameAnnotatePlugin} from '@sentry/component-annotate-plugin'; + +{ + // ... other config above ... + + plugins: [ + // Put this plugin before any other plugins you have that transform JSX code + [componentNameAnnotatePlugin] + ], +} +``` + +## More information + +- [Sentry Documentation](https://docs.sentry.io/quickstart/) +- [Sentry Discord](https://discord.gg/Ww9hbqr) +- [Sentry Stackoverflow](http://stackoverflow.com/questions/tagged/sentry) diff --git a/packages/component-annotate-plugin/jest.config.js b/packages/component-annotate-plugin/jest.config.js new file mode 100644 index 00000000..160178bb --- /dev/null +++ b/packages/component-annotate-plugin/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + testEnvironment: "node", + transform: { + "^.+\\.(t|j)sx?$": ["@swc/jest"], + }, +}; diff --git a/packages/component-annotate-plugin/package.json b/packages/component-annotate-plugin/package.json new file mode 100644 index 00000000..a995e46a --- /dev/null +++ b/packages/component-annotate-plugin/package.json @@ -0,0 +1,81 @@ +{ + "name": "@sentry/component-annotate-plugin", + "version": "2.10.3", + "description": "A Babel plugin that annotates frontend components with additional data to enrich the experience in Sentry", + "repository": "git://github.com/getsentry/sentry-javascript-bundler-plugins.git", + "homepage": "https://github.com/getsentry/sentry-javascript-bundler-plugins/tree/main/packages/component-annotate-plugin", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "Sentry", + "React", + "bundler", + "plugin", + "babel", + "component", + "annotate" + ], + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.js", + "types": "./dist/types/index.d.ts" + } + }, + "main": "dist/cjs/index.js", + "module": "dist/esm/index.mjs", + "types": "dist/types/index.d.ts", + "scripts": { + "build": "rimraf ./out && run-p build:rollup build:types", + "build:watch": "run-p build:rollup:watch build:types:watch", + "build:rollup": "rollup --config rollup.config.js", + "build:rollup:watch": "rollup --config rollup.config.js --watch --no-watch.clearScreen", + "build:types": "tsc --project types.tsconfig.json", + "build:types:watch": "tsc --project types.tsconfig.json --watch --preserveWatchOutput", + "build:npm": "npm pack", + "check:types": "run-p check:types:src check:types:test", + "check:types:src": "tsc --project ./src/tsconfig.json --noEmit", + "check:types:test": "tsc --project ./test/tsconfig.json --noEmit", + "clean": "run-s clean:build", + "clean:all": "run-p clean clean:deps", + "clean:build": "rimraf ./dist *.tgz", + "clean:deps": "rimraf node_modules", + "test": "jest", + "lint": "eslint ./src ./test", + "prepack": "ts-node ./src/prepack.ts" + }, + "dependencies": {}, + "devDependencies": { + "@babel/core": "7.18.5", + "@babel/preset-env": "7.18.2", + "@babel/preset-react": "^7.23.3", + "@babel/preset-typescript": "7.17.12", + "@rollup/plugin-babel": "5.3.1", + "@rollup/plugin-node-resolve": "13.3.0", + "@sentry-internal/eslint-config": "2.10.3", + "@sentry-internal/sentry-bundler-plugin-tsconfig": "2.10.3", + "@swc/core": "^1.2.205", + "@swc/jest": "^0.2.21", + "@types/jest": "^28.1.3", + "@types/node": "^18.6.3", + "@types/uuid": "^9.0.1", + "eslint": "^8.18.0", + "jest": "^28.1.1", + "rimraf": "^3.0.2", + "rollup": "2.75.7", + "ts-node": "^10.9.1", + "typescript": "^4.7.4" + }, + "volta": { + "extends": "../../package.json" + }, + "engines": { + "node": ">= 14" + } +} diff --git a/packages/component-annotate-plugin/rollup.config.js b/packages/component-annotate-plugin/rollup.config.js new file mode 100644 index 00000000..95b11585 --- /dev/null +++ b/packages/component-annotate-plugin/rollup.config.js @@ -0,0 +1,42 @@ +import resolve from "@rollup/plugin-node-resolve"; +import babel from "@rollup/plugin-babel"; +import packageJson from "./package.json"; +import modulePackage from "module"; + +const input = ["src/index.ts"]; + +const extensions = [".ts"]; + +export default { + input, + external: [...Object.keys(packageJson.dependencies), ...modulePackage.builtinModules], + onwarn: (warning) => { + throw new Error(warning.message); // Warnings are usually high-consequence for us so let's throw to catch them + }, + plugins: [ + resolve({ + extensions, + rootDir: "./src", + preferBuiltins: true, + }), + babel({ + extensions, + babelHelpers: "bundled", + include: ["src/**/*"], + }), + ], + output: [ + { + file: packageJson.module, + format: "esm", + exports: "named", + sourcemap: true, + }, + { + file: packageJson.main, + format: "cjs", + exports: "named", + sourcemap: true, + }, + ], +}; diff --git a/packages/component-annotate-plugin/src/constants.ts b/packages/component-annotate-plugin/src/constants.ts new file mode 100644 index 00000000..51eac57d --- /dev/null +++ b/packages/component-annotate-plugin/src/constants.ts @@ -0,0 +1,146 @@ +/** + * MIT License + * + * Copyright (c) 2020 Engineering at FullStory + * + * 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 const KNOWN_INCOMPATIBLE_PLUGINS = [ + // This module might be causing an issue preventing clicks. For safety, we won't run on this module. + "react-native-testfairy", + // This module checks for unexpected property keys and throws an exception. + "@react-navigation", +]; + +export const DEFAULT_IGNORED_ELEMENTS = [ + "a", + "abbr", + "address", + "area", + "article", + "aside", + "audio", + "b", + "base", + "bdi", + "bdo", + "blockquote", + "body", + "br", + "button", + "canvas", + "caption", + "cite", + "code", + "col", + "colgroup", + "data", + "datalist", + "dd", + "del", + "details", + "dfn", + "dialog", + "div", + "dl", + "dt", + "em", + "embed", + "fieldset", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "head", + "header", + "hgroup", + "hr", + "html", + "i", + "iframe", + "img", + "input", + "ins", + "kbd", + "keygen", + "label", + "legend", + "li", + "link", + "main", + "map", + "mark", + "menu", + "menuitem", + "meter", + "nav", + "noscript", + "object", + "ol", + "optgroup", + "option", + "output", + "p", + "param", + "pre", + "progress", + "q", + "rb", + "rp", + "rt", + "rtc", + "ruby", + "s", + "samp", + "script", + "section", + "select", + "small", + "source", + "span", + "strong", + "style", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "template", + "textarea", + "tfoot", + "th", + "thead", + "time", + "title", + "tr", + "track", + "u", + "ul", + "var", + "video", + "wbr", +]; diff --git a/packages/component-annotate-plugin/src/index.ts b/packages/component-annotate-plugin/src/index.ts new file mode 100644 index 00000000..894155c0 --- /dev/null +++ b/packages/component-annotate-plugin/src/index.ts @@ -0,0 +1,517 @@ +/** + * MIT License + * + * Copyright (c) 2020 Engineering at FullStory + * + * 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. + * + */ + +/** + * The following code is based on the FullStory Babel plugin, but has been modified to work + * with Sentry products: + * + * - Added `sentry` to data properties, i.e `data-sentry-component` + * - Converted to TypeScript + * - Code cleanups + */ + +import type * as Babel from "@babel/core"; +import type { PluginObj, PluginPass } from "@babel/core"; + +import { DEFAULT_IGNORED_ELEMENTS, KNOWN_INCOMPATIBLE_PLUGINS } from "./constants"; + +const webComponentName = "data-sentry-component"; +const webElementName = "data-sentry-element"; +const webSourceFileName = "data-sentry-source-file"; + +const nativeComponentName = "dataSentryComponent"; +const nativeElementName = "dataSentryElement"; +const nativeSourceFileName = "dataSentrySourceFile"; + +interface AnnotationOpts { + native?: boolean; + "annotate-fragments"?: boolean; + ignoreComponents?: IgnoredComponent[]; +} + +interface AnnotationPluginPass extends PluginPass { + opts: AnnotationOpts; +} + +type IgnoredComponent = [file: string, component: string, element: string]; + +type AnnotationPlugin = PluginObj; + +export function componentNameAnnotatePlugin({ types: t }: typeof Babel): AnnotationPlugin { + return { + visitor: { + FunctionDeclaration(path, state) { + if (!path.node.id || !path.node.id.name) { + return; + } + if (isKnownIncompatiblePluginFromState(state)) { + return; + } + + functionBodyPushAttributes( + state.opts["annotate-fragments"] === true, + t, + path, + path.node.id.name, + sourceFileNameFromState(state), + attributeNamesFromState(state), + state.opts.ignoreComponents ?? [] + ); + }, + ArrowFunctionExpression(path, state) { + // We're expecting a `VariableDeclarator` like `const MyComponent =` + const parent = path.parent; + if ( + !parent || + !("id" in parent) || + !parent.id || + !("name" in parent.id) || + !parent.id.name + ) { + return; + } + + if (isKnownIncompatiblePluginFromState(state)) { + return; + } + + functionBodyPushAttributes( + state.opts["annotate-fragments"] === true, + t, + path, + parent.id.name, + sourceFileNameFromState(state), + attributeNamesFromState(state), + state.opts.ignoreComponents ?? [] + ); + }, + ClassDeclaration(path, state) { + const name = path.get("id"); + const properties = path.get("body").get("body"); + const render = properties.find((prop) => { + return prop.isClassMethod() && prop.get("key").isIdentifier({ name: "render" }); + }); + + if (!render || !render.traverse || isKnownIncompatiblePluginFromState(state)) { + return; + } + + const ignoredComponents = state.opts.ignoreComponents ?? []; + + render.traverse({ + ReturnStatement(returnStatement) { + const arg = returnStatement.get("argument"); + + if (!arg.isJSXElement() && !arg.isJSXFragment()) { + return; + } + + processJSX( + state.opts["annotate-fragments"] === true, + t, + arg, + name.node && name.node.name, + sourceFileNameFromState(state), + attributeNamesFromState(state), + ignoredComponents + ); + }, + }); + }, + }, + }; +} + +function functionBodyPushAttributes( + annotateFragments: boolean, + t: typeof Babel.types, + path: Babel.NodePath, + componentName: string, + sourceFileName: string | undefined, + attributeNames: string[], + ignoredComponents: IgnoredComponent[] +) { + let jsxNode: Babel.NodePath; + + const functionBody = path.get("body").get("body"); + + if ( + !("length" in functionBody) && + functionBody.parent && + (functionBody.parent.type === "JSXElement" || functionBody.parent.type === "JSXFragment") + ) { + const maybeJsxNode = functionBody.find((c) => { + return c.type === "JSXElement" || c.type === "JSXFragment"; + }); + + if (!maybeJsxNode) { + return; + } + + jsxNode = maybeJsxNode; + } else { + const returnStatement = functionBody.find((c) => { + return c.type === "ReturnStatement"; + }); + if (!returnStatement) { + return; + } + + const arg = returnStatement.get("argument"); + if (!arg) { + return; + } + + if (Array.isArray(arg)) { + return; + } + + if (!arg.isJSXFragment() && !arg.isJSXElement()) { + return; + } + + jsxNode = arg; + } + + if (!jsxNode) { + return; + } + + processJSX( + annotateFragments, + t, + jsxNode, + componentName, + sourceFileName, + attributeNames, + ignoredComponents + ); +} + +function processJSX( + annotateFragments: boolean, + t: typeof Babel.types, + jsxNode: Babel.NodePath, + componentName: string | null, + sourceFileName: string | undefined, + attributeNames: string[], + ignoredComponents: IgnoredComponent[] +) { + if (!jsxNode) { + return; + } + + // NOTE: I don't know of a case where `openingElement` would have more than one item, + // but it's safer to always iterate + const paths = jsxNode.get("openingElement"); + const openingElements = Array.isArray(paths) ? paths : [paths]; + + openingElements.forEach((openingElement) => { + applyAttributes( + t, + openingElement as Babel.NodePath, + componentName, + sourceFileName, + attributeNames, + ignoredComponents + ); + }); + + let children = jsxNode.get("children"); + // TODO: See why `Array.isArray` doesn't have correct behaviour here + if (children && !("length" in children)) { + // A single child was found, maybe a bit of static text + children = [children]; + } + + let shouldSetComponentName = annotateFragments; + + children.forEach((child) => { + // Happens for some node types like plain text + if (!child.node) { + return; + } + + // Children don't receive the data-component attribute so we pass null for componentName unless it's the first child of a Fragment with a node and `annotateFragments` is true + const openingElement = child.get("openingElement"); + // TODO: Improve this. We never expect to have multiple opening elements + // but if it's possible, this should work + if (Array.isArray(openingElement)) { + return; + } + + if (shouldSetComponentName && openingElement && openingElement.node) { + shouldSetComponentName = false; + processJSX( + annotateFragments, + t, + child, + componentName, + sourceFileName, + attributeNames, + ignoredComponents + ); + } else { + processJSX( + annotateFragments, + t, + child, + null, + sourceFileName, + attributeNames, + ignoredComponents + ); + } + }); +} + +function applyAttributes( + t: typeof Babel.types, + openingElement: Babel.NodePath, + componentName: string | null, + sourceFileName: string | undefined, + attributeNames: string[], + ignoredComponents: IgnoredComponent[] +) { + const [componentAttributeName, elementAttributeName, sourceFileAttributeName] = attributeNames; + + if (isReactFragment(t, openingElement)) { + return; + } + // e.g., Raw JSX text like the `A` in `

a

` + if (!openingElement.node) { + return; + } + + if (!openingElement.node.attributes) openingElement.node.attributes = []; + const elementName = getPathName(t, openingElement); + + const isAnIgnoredComponent = ignoredComponents.some( + (ignoredComponent) => + matchesIgnoreRule(ignoredComponent[0], sourceFileName) && + matchesIgnoreRule(ignoredComponent[1], componentName) && + matchesIgnoreRule(ignoredComponent[2], elementName) + ); + + // Add a stable attribute for the element name but only for non-DOM names + let isAnIgnoredElement = false; + if ( + !isAnIgnoredComponent && + !hasAttributeWithName(openingElement, componentAttributeName) && + (componentAttributeName !== elementAttributeName || !componentName) + ) { + if (DEFAULT_IGNORED_ELEMENTS.includes(elementName)) { + isAnIgnoredElement = true; + } else { + // TODO: Is it possible to avoid this null check? + if (elementAttributeName) { + openingElement.node.attributes.push( + t.jSXAttribute(t.jSXIdentifier(elementAttributeName), t.stringLiteral(elementName)) + ); + } + } + } + + // Add a stable attribute for the component name (absent for non-root elements) + if ( + componentName && + !isAnIgnoredComponent && + !hasAttributeWithName(openingElement, componentAttributeName) + ) { + // TODO: Is it possible to avoid this null check? + if (componentAttributeName) { + openingElement.node.attributes.push( + t.jSXAttribute(t.jSXIdentifier(componentAttributeName), t.stringLiteral(componentName)) + ); + } + } + + // Add a stable attribute for the source file name (absent for non-root elements) + if ( + sourceFileName && + !isAnIgnoredComponent && + (componentName || isAnIgnoredElement === false) && + !hasAttributeWithName(openingElement, sourceFileAttributeName) + ) { + // TODO: Is it possible to avoid this null check? + if (sourceFileAttributeName) { + openingElement.node.attributes.push( + t.jSXAttribute(t.jSXIdentifier(sourceFileAttributeName), t.stringLiteral(sourceFileName)) + ); + } + } +} + +function sourceFileNameFromState(state: AnnotationPluginPass) { + const name = fullSourceFileNameFromState(state); + if (!name) { + return undefined; + } + + if (name.indexOf("/") !== -1) { + return name.split("/").pop(); + } else if (name.indexOf("\\") !== -1) { + return name.split("\\").pop(); + } else { + return name; + } +} + +function fullSourceFileNameFromState(state: AnnotationPluginPass): string | null { + // @ts-expect-error This type is incorrect in Babel, `sourceFileName` is the correct type + const name = state.file.opts.parserOpts?.sourceFileName as unknown; + + if (typeof name === "string") { + return name; + } + + return null; +} + +function isKnownIncompatiblePluginFromState(state: AnnotationPluginPass) { + const fullSourceFileName = fullSourceFileNameFromState(state); + + if (!fullSourceFileName) { + return false; + } + + return KNOWN_INCOMPATIBLE_PLUGINS.some((pluginName) => { + if ( + fullSourceFileName.includes(`/node_modules/${pluginName}/`) || + fullSourceFileName.includes(`\\node_modules\\${pluginName}\\`) + ) { + return true; + } + + return false; + }); +} + +function attributeNamesFromState(state: AnnotationPluginPass): [string, string, string] { + if (state.opts.native) { + return [nativeComponentName, nativeElementName, nativeSourceFileName]; + } + + return [webComponentName, webElementName, webSourceFileName]; +} + +function isReactFragment(t: typeof Babel.types, openingElement: Babel.NodePath): boolean { + if (openingElement.isJSXFragment()) { + return true; + } + + const elementName = getPathName(t, openingElement); + + if (elementName === "Fragment" || elementName === "React.Fragment") { + return true; + } + + // TODO: All these objects are typed as unknown, maybe an oversight in Babel types? + if ( + openingElement.node && + "name" in openingElement.node && + openingElement.node.name && + typeof openingElement.node.name === "object" && + "type" in openingElement.node.name && + openingElement.node.name.type === "JSXMemberExpression" + ) { + if (!("name" in openingElement.node)) { + return false; + } + + const nodeName = openingElement.node.name; + if (typeof nodeName !== "object" || !nodeName) { + return false; + } + + if ("object" in nodeName && "property" in nodeName) { + const nodeNameObject = nodeName.object; + const nodeNameProperty = nodeName.property; + + if (typeof nodeNameObject !== "object" || typeof nodeNameProperty !== "object") { + return false; + } + + if (!nodeNameObject || !nodeNameProperty) { + return false; + } + + const objectName = "name" in nodeNameObject && nodeNameObject.name; + const propertyName = "name" in nodeNameProperty && nodeNameProperty.name; + + if (objectName === "React" && propertyName === "Fragment") { + return true; + } + } + } + + return false; +} + +function matchesIgnoreRule(rule: string, name: string | undefined | null) { + return rule === "*" || rule === name; +} + +function hasAttributeWithName( + openingElement: Babel.NodePath, + name: string | undefined | null +): boolean { + if (!name) { + return false; + } + + return openingElement.node.attributes.some((node) => { + if (node.type === "JSXAttribute") { + return node.name.name === name; + } + + return false; + }); +} + +function getPathName(t: typeof Babel.types, path: Babel.NodePath): string { + if (!path.node) return UNKNOWN_ELEMENT_NAME; + if (!("name" in path.node)) { + return UNKNOWN_ELEMENT_NAME; + } + + const name = path.node.name; + + if (typeof name === "string") { + return name; + } + + if (t.isIdentifier(name) || t.isJSXIdentifier(name)) { + return name.name; + } + + if (t.isJSXNamespacedName(name)) { + return name.name.name; + } + + return UNKNOWN_ELEMENT_NAME; +} + +const UNKNOWN_ELEMENT_NAME = "unknown"; diff --git a/packages/component-annotate-plugin/src/tsconfig.json b/packages/component-annotate-plugin/src/tsconfig.json new file mode 100644 index 00000000..fd37e123 --- /dev/null +++ b/packages/component-annotate-plugin/src/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@sentry-internal/sentry-bundler-plugin-tsconfig/base-config.json", + "include": ["./**/*", "../package.json"], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/packages/component-annotate-plugin/test/__snapshots__/test-plugin.test.ts.snap b/packages/component-annotate-plugin/test/__snapshots__/test-plugin.test.ts.snap new file mode 100644 index 00000000..a5c615e1 --- /dev/null +++ b/packages/component-annotate-plugin/test/__snapshots__/test-plugin.test.ts.snap @@ -0,0 +1,515 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`arrow snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); +}; +export default componentName;" +`; + +exports[`arrow-anonymous-fragment snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +const componentName = () => { + return (() => /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")))(); +}; +export default componentName;" +`; + +exports[`arrow-anonymous-react-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => { + return (() => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")))(); +}; +export default componentName;" +`; + +exports[`arrow-anonymous-shorthand-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => { + return (() => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")))(); +}; +export default componentName;" +`; + +exports[`arrow-fragment snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +const componentName = () => { + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); +}; +export default componentName;" +`; + +exports[`arrow-noreturn snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" +}, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); +export default componentName;" +`; + +exports[`arrow-noreturn-annotate-fragment snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +const componentName = () => /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" +}, \\"Hello world\\")); +export default componentName;" +`; + +exports[`arrow-noreturn-annotate-fragment-no-whitespace snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +const componentName = () => /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" +}, \\"Hello world\\"), /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hola Sol\\")); +export default componentName;" +`; + +exports[`arrow-noreturn-annotate-fragment-once snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +const componentName = () => /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" +}, \\"Hello world\\"), /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hola Sol\\")); +export default componentName;" +`; + +exports[`arrow-noreturn-annotate-react-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" +}, \\"Hello world\\")); +export default componentName;" +`; + +exports[`arrow-noreturn-annotate-shorthand-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" +}, \\"Hello world\\")); +export default componentName;" +`; + +exports[`arrow-noreturn-annotate-trivial-fragment snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +const componentName = () => /*#__PURE__*/React.createElement(Fragment, null, \\"Hello world\\"); +export default componentName;" +`; + +exports[`arrow-noreturn-fragment snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +const componentName = () => /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); +export default componentName;" +`; + +exports[`arrow-noreturn-react-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); +export default componentName;" +`; + +exports[`arrow-noreturn-shorthand-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); +export default componentName;" +`; + +exports[`arrow-react-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); +}; +export default componentName;" +`; + +exports[`arrow-shorthand-fragment snapshot matches 1`] = ` +"import React from 'react'; +const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); +}; +export default componentName;" +`; + +exports[`component snapshot matches 1`] = ` +"import React, { Component } from 'react'; +class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + } +} +export default componentName;" +`; + +exports[`component-annotate-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"A\\"); + } +} +export default componentName;" +`; + +exports[`component-annotate-react-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, \\"Hello world\\")); + } +} +export default componentName;" +`; + +exports[`component-annotate-shorthand-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", { + \\"data-sentry-component\\": \\"componentName\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, \\"Hello world\\")); + } +} +export default componentName;" +`; + +exports[`component-fragment snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(Fragment, null, \\"A\\"); + } +} +export default componentName;" +`; + +exports[`component-fragment-native snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(Fragment, null, \\"A\\"); + } +} +export default componentName;" +`; + +exports[`component-react-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"A\\"); + } +} +export default componentName;" +`; + +exports[`component-shorthand-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"A\\"); + } +} +export default componentName;" +`; + +exports[`nonJSX snapshot matches 1`] = ` +"import React, { Component } from 'react'; +class TestClass extends Component { + test() { + return true; + } +} +export default TestClass;" +`; + +exports[`option-attribute snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); +}; +export default componentName;" +`; + +exports[`option-format snapshot matches 1`] = ` +"import React, { Component } from 'react'; +const componentName = () => { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); +}; +export default componentName;" +`; + +exports[`pure snapshot matches 1`] = ` +"import React from 'react'; +class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"PureComponentName\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + } +} +export default PureComponentName;" +`; + +exports[`pure-native snapshot matches 1`] = ` +"import React from 'react'; +class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(\\"div\\", { + dataSentryComponent: \\"PureComponentName\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + } +} +export default PureComponentName;" +`; + +exports[`pureComponent-fragment snapshot matches 1`] = ` +"import React, { Fragment } from 'react'; +class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + } +} +export default PureComponentName;" +`; + +exports[`pureComponent-react-fragment snapshot matches 1`] = ` +"import React from 'react'; +class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + } +} +export default PureComponentName;" +`; + +exports[`pureComponent-shorthand-fragment snapshot matches 1`] = ` +"import React from 'react'; +class PureComponentName extends React.PureComponent { + render() { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"Hello world\\")); + } +} +export default PureComponentName;" +`; + +exports[`rawfunction snapshot matches 1`] = ` +"import React, { Component } from 'react'; +function SubComponent() { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"SubComponent\\" + }, \\"Sub\\"); +} +const componentName = () => { + return /*#__PURE__*/React.createElement(\\"div\\", { + \\"data-sentry-component\\": \\"componentName\\" + }, /*#__PURE__*/React.createElement(SubComponent, { + \\"data-sentry-element\\": \\"SubComponent\\" + })); +}; +export default componentName;" +`; + +exports[`rawfunction-annotate-fragment snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +function SubComponent() { + return /*#__PURE__*/React.createElement(Fragment, null, \\"Sub\\"); +} +const componentName = () => { + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(SubComponent, { + \\"data-sentry-element\\": \\"SubComponent\\", + \\"data-sentry-component\\": \\"componentName\\" + })); +}; +export default componentName;" +`; + +exports[`rawfunction-annotate-react-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +function SubComponent() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"Sub\\"); +} +const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(SubComponent, { + \\"data-sentry-element\\": \\"SubComponent\\", + \\"data-sentry-component\\": \\"componentName\\" + })); +}; +export default componentName;" +`; + +exports[`rawfunction-annotate-shorthand-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +function SubComponent() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"Sub\\"); +} +const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(SubComponent, { + \\"data-sentry-element\\": \\"SubComponent\\", + \\"data-sentry-component\\": \\"componentName\\" + })); +}; +export default componentName;" +`; + +exports[`rawfunction-fragment snapshot matches 1`] = ` +"import React, { Component, Fragment } from 'react'; +function SubComponent() { + return /*#__PURE__*/React.createElement(Fragment, null, \\"Sub\\"); +} +const componentName = () => { + return /*#__PURE__*/React.createElement(Fragment, null, /*#__PURE__*/React.createElement(SubComponent, { + \\"data-sentry-element\\": \\"SubComponent\\" + })); +}; +export default componentName;" +`; + +exports[`rawfunction-react-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +function SubComponent() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"Sub\\"); +} +const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(SubComponent, { + \\"data-sentry-element\\": \\"SubComponent\\" + })); +}; +export default componentName;" +`; + +exports[`rawfunction-shorthand-fragment snapshot matches 1`] = ` +"import React, { Component } from 'react'; +function SubComponent() { + return /*#__PURE__*/React.createElement(React.Fragment, null, \\"Sub\\"); +} +const componentName = () => { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(SubComponent, { + \\"data-sentry-element\\": \\"SubComponent\\" + })); +}; +export default componentName;" +`; + +exports[`tags snapshot matches 1`] = ` +"import React, { Component } from 'react'; +import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; +UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; +class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } +} +class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + }, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text, + dataSentryElement: \\"TextInput\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } +} +export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"App\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, { + dataSentryElement: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(PizzaTranslator, { + dataSentryElement: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + })); +} +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } +});" +`; + +exports[`unknown-element snapshot matches 1`] = ` +"import React, { Component } from 'react'; +class componentName extends Component { + render() { + return /*#__PURE__*/React.createElement(\\"bogus\\", { + \\"data-sentry-element\\": \\"bogus\\", + \\"data-sentry-component\\": \\"componentName\\", + \\"data-sentry-source-file\\": \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(\\"h1\\", null, \\"A\\")); + } +} +export default componentName;" +`; diff --git a/packages/component-annotate-plugin/test/test-plugin.test.ts b/packages/component-annotate-plugin/test/test-plugin.test.ts new file mode 100644 index 00000000..5a9ff11c --- /dev/null +++ b/packages/component-annotate-plugin/test/test-plugin.test.ts @@ -0,0 +1,2238 @@ +/** + * MIT License + * + * Copyright (c) 2020 Engineering at FullStory + * + * 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. + * + */ + +import { transform } from "@babel/core"; +import { componentNameAnnotatePlugin as plugin } from "../src/index"; + +const BananasPizzaAppStandardInput = `import React, { Component } from 'react'; +import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + +UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = "String"; + +class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return ; + } +} + +class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { text: '' }; + } + + render() { + return + this.setState({ text })} value={this.state.text} /> + + {this.state.text.split(' ').map(word => word && '🍕').join(' ')} + + ; + } +} + +export default function App() { + return + FullStory ReactNative testing app + + + ; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } +});`; + +const BananasStandardInput = `import React, { Component } from 'react'; +import { Image } from 'react-native'; + +class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return ; + } +}`; + +it("unknown-element snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return

A

; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("component-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +class componentName extends Component { + render() { + return A; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("component-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return A; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("component-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return <>A; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("component-annotate-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return <>A; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("component-annotate-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return +

Hello world

+
; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("component-annotate-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return <> +

Hello world

+ ; + } +} + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-noreturn-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => ( + +

Hello world

+
+); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-noreturn-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => ( + <> +

Hello world

+ +); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-noreturn-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => ( + +

Hello world

+
+); + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-noreturn-annotate-trivial-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => ( + Hello world +); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-noreturn-annotate-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => ( + +

Hello world

+
+); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-noreturn-annotate-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => ( + +

Hello world

+
+); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-noreturn-annotate-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => ( + <> +

Hello world

+ +); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-noreturn-annotate-fragment-once snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => ( + +

Hello world

+

Hola Sol

+
+); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-noreturn-annotate-fragment-no-whitespace snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => ( +

Hello world

Hola Sol

+); + +export default componentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return
+

Hello world

+
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("option-attribute snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return
+

Hello world

+
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("component snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class componentName extends Component { + render() { + return
+

Hello world

+
; + } +} + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("rawfunction-annotate-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +function SubComponent() { + return Sub; +} + +const componentName = () => { + return + + ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("rawfunction-annotate-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +function SubComponent() { + return Sub; +} + +const componentName = () => { + return + + ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("rawfunction-annotate-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +function SubComponent() { + return <>Sub; +} + +const componentName = () => { + return <> + + ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [[plugin, { "annotate-fragments": true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("rawfunction-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +function SubComponent() { + return Sub; +} + +const componentName = () => { + return + + ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("rawfunction-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +function SubComponent() { + return Sub; +} + +const componentName = () => { + return + + ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("rawfunction-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +function SubComponent() { + return <>Sub; +} + +const componentName = () => { + return <> + + ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-noreturn snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => ( +
+

Hello world

+
+); + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("tags snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; +import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + +UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = "String"; + +class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return ; + } +} + +class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { text: '' }; + } + + render() { + return + this.setState({ text })} value={this.state.text} /> + + {this.state.text.split(' ').map(word => word && '🍕').join(' ')} + + ; + } +} + +export default function App() { + return + FullStory ReactNative testing app + + + ; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } +}); +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("option-format snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return
+

Hello world

+
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("pureComponent-fragment snapshot matches", () => { + const result = transform( + `import React, { Fragment } from 'react'; + +class PureComponentName extends React.PureComponent { + render() { + return +

Hello world

+
; + } +} + +export default PureComponentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("pureComponent-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React from 'react'; + +class PureComponentName extends React.PureComponent { + render() { + return <> +

Hello world

+ ; + } +} + +export default PureComponentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("pureComponent-react-fragment snapshot matches", () => { + const result = transform( + `import React from 'react'; + +class PureComponentName extends React.PureComponent { + render() { + return +

Hello world

+
; + } +} + +export default PureComponentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("rawfunction snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +function SubComponent() { + return
Sub
; +} + +const componentName = () => { + return
+ +
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => { + return +

Hello world

+
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React from 'react'; + +const componentName = () => { + return <> +

Hello world

+ ; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return +

Hello world

+
; +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("nonJSX snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +class TestClass extends Component { + test() { + return true; + } +} + +export default TestClass; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-anonymous-fragment snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +const componentName = () => { + return (() => +

Hello world

+
)(); +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-anonymous-shorthand-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return (() => <> +

Hello world

+ )(); +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("arrow-anonymous-react-fragment snapshot matches", () => { + const result = transform( + `import React, { Component } from 'react'; + +const componentName = () => { + return (() => +

Hello world

+
)(); +}; + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("pure snapshot matches", () => { + const result = transform( + `import React from 'react'; + +class PureComponentName extends React.PureComponent { + render() { + return
+

Hello world

+
; + } +} + +export default PureComponentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [plugin], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("component-fragment-native snapshot matches", () => { + const result = transform( + `import React, { Component, Fragment } from 'react'; + +class componentName extends Component { + render() { + return A; + } +} + +export default componentName; +`, + { + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("pure-native snapshot matches", () => { + const result = transform( + `import React from 'react'; + +class PureComponentName extends React.PureComponent { + render() { + return
+

Hello world

+
; + } +} + +export default PureComponentName; +`, + { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true }]], + } + ); + expect(result?.code).toMatchSnapshot(); +}); + +it("Bananas ignore components dataSentrySourceFile=nomatch dataSentryComponent=nomatch dataSentryElement=nomatch snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["nomatch.js", "nomatch", "nomatch"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + }" + `); +}); + +it("ignore components dataSentrySourceFile=* dataSentryComponent=nomatch dataSentryElement=nomatch snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["*", "nomatch", "nomatch"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + }" + `); +}); + +it("Bananas ignore components dataSentrySourceFile=nomatch dataSentryComponent=* dataSentryElement=nomatch snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["nomatch.js", "*", "nomatch"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + }" + `); +}); + +it("Bananas ignore components dataSentrySourceFile=nomatch dataSentryComponent=nomatch dataSentryElement=* snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["nomatch.js", "nomatch", "*"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + }" + `); +}); + +it("Bananas ignore components dataSentrySourceFile=* dataSentryComponent=* dataSentryElement=nomatch snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["nomatch.js", "nomatch", "nomatch"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + }" + `); +}); + +it("Bananas ignore components dataSentrySourceFile=* dataSentryComponent=nomatch dataSentryElement=* snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["nomatch.js", "nomatch", "nomatch"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + }" + `); +}); + +it("Bananas ignore components dataSentrySourceFile=nomatch dataSentryComponent=* dataSentryElement=* snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["nomatch.js", "nomatch", "nomatch"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + }" + `); +}); + +// This tests out matching only `dataSentryElement`, with * for the others +it("Bananas ignore components dataSentrySourceFile=* dataSentryComponent=* dataSentryElement=match snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["*", "*", "Image"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\" + }); + } + }" + `); +}); + +// This tests out matching only `dataSentryElement` and `dataSentryComponent`, with * for `dataSentrySourceFile` +it("Bananas ignore components dataSentrySourceFile=* dataSentryComponent=match dataSentryElement=match snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["*", "Bananas", "Image"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\" + }); + } + }" + `); +}); + +// This tests out matching on all 3 of our ignore list values +it("Bananas ignore components dataSentrySourceFile=match dataSentryComponent=match dataSentryElement=match snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [ + [plugin, { native: true, ignoreComponents: [["filename-test.js", "Bananas", "Image"]] }], + ], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\" + }); + } + }" + `); +}); + +// This tests out matching on all 3 of our ignore list values via * +it("Bananas/Pizza/App ignore components dataSentrySourceFile=* dataSentryComponent=* dataSentryElement=* snapshot matches", () => { + const result = transform(BananasPizzaAppStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["*", "*", "*"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + } + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + } + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + } + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, null), /*#__PURE__*/React.createElement(PizzaTranslator, null)); + } + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } + });" + `); +}); + +// This tests out matching on all 3 of our ignore list values +it("Bananas/Pizza/App ignore components dataSentrySourceFile=nomatch dataSentryComponent=* dataSentryElement=* snapshot matches", () => { + const result = transform(BananasPizzaAppStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true, ignoreComponents: [["nomatch.js", "*", "*"]] }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + }, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text, + dataSentryElement: \\"TextInput\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"App\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, { + dataSentryElement: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(PizzaTranslator, { + dataSentryElement: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + })); + } + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } + });" + `); +}); + +it("Bananas/Pizza/App only Bananas dataSentrySourceFile=match dataSentryComponent=match dataSentryElement=match snapshot matches", () => { + const result = transform(BananasPizzaAppStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [ + [ + plugin, + { + native: true, + ignoreComponents: [ + // Pizza + ["filename-test.js", "PizzaTranslator", "View"], + // App + ["filename-test.js", "App", "View"], + ], + }, + ], + ], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + } + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text, + dataSentryElement: \\"TextInput\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, { + dataSentryElement: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(PizzaTranslator, { + dataSentryElement: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + })); + } + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } + });" + `); +}); + +it("Bananas/Pizza/App only Pizza dataSentrySourceFile=match dataSentryComponent=match dataSentryElement=match snapshot matches", () => { + const result = transform(BananasPizzaAppStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [ + [ + plugin, + { + native: true, + ignoreComponents: [ + // Bananas + ["filename-test.js", "Bananas", "Image"], + // App + ["filename-test.js", "App", "View"], + ], + }, + ], + ], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + }, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text, + dataSentryElement: \\"TextInput\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, { + dataSentryElement: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(PizzaTranslator, { + dataSentryElement: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + })); + } + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } + });" + `); +}); + +it("Bananas/Pizza/App only App dataSentrySourceFile=match dataSentryComponent=match dataSentryElement=match snapshot matches", () => { + const result = transform(BananasPizzaAppStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [ + [ + plugin, + { + native: true, + ignoreComponents: [ + // Bananas + ["filename-test.js", "Bananas", "Image"], + // Pizza + ["filename-test.js", "PizzaTranslator", "View"], + ], + }, + ], + ], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + } + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text, + dataSentryElement: \\"TextInput\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"App\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, { + dataSentryElement: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(PizzaTranslator, { + dataSentryElement: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + })); + } + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } + });" + `); +}); + +it("Bananas/Pizza/App No Pizza Elements dataSentrySourceFile=match dataSentryComponent=match dataSentryElement=match snapshot matches", () => { + const result = transform(BananasPizzaAppStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [ + [ + plugin, + { + native: true, + ignoreComponents: [ + // Pizza Element + ["filename-test.js", null, "PizzaTranslator"], + ], + }, + ], + ], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + }, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text, + dataSentryElement: \\"TextInput\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"App\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, { + dataSentryElement: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(PizzaTranslator, null)); + } + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } + });" + `); +}); + +it("Bananas/Pizza/App No Bananas Elements dataSentrySourceFile=match dataSentryComponent=match dataSentryElement=match snapshot matches", () => { + const result = transform(BananasPizzaAppStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [ + [ + plugin, + { + native: true, + ignoreComponents: [ + // Bananas Element + ["filename-test.js", null, "Bananas"], + ], + }, + ], + ], + }); + + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + }, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text, + dataSentryElement: \\"TextInput\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"App\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, null), /*#__PURE__*/React.createElement(PizzaTranslator, { + dataSentryElement: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + })); + } + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } + });" + `); +}); + +it("Bananas/Pizza/App No Bananas/Pizza Elements dataSentrySourceFile=match dataSentryComponent=match dataSentryElement=match snapshot matches", () => { + const result = transform(BananasPizzaAppStandardInput, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [ + [ + plugin, + { + native: true, + ignoreComponents: [ + // Bananas Element + ["filename-test.js", null, "Bananas"], + // Pizza Element + ["filename-test.js", null, "PizzaTranslator"], + ], + }, + ], + ], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { StyleSheet, Text, TextInput, View, Image, UIManager } from 'react-native'; + UIManager.getViewManagerConfig('RCTView').NativeProps.fsClass = \\"String\\"; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\", + dataSentryElement: \\"Image\\", + dataSentryComponent: \\"Bananas\\", + dataSentrySourceFile: \\"filename-test.js\\" + }); + } + } + class PizzaTranslator extends Component { + constructor(props) { + super(props); + this.state = { + text: '' + }; + } + render() { + return /*#__PURE__*/React.createElement(View, { + style: { + padding: 10 + }, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"PizzaTranslator\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(TextInput, { + style: { + backgroundColor: '#000', + color: '#eee', + padding: 8 + }, + placeholder: \\"Type here to translate!\\" // not supported on iOS + , + onChangeText: text => this.setState({ + text + }), + value: this.state.text, + dataSentryElement: \\"TextInput\\", + dataSentrySourceFile: \\"filename-test.js\\" + }), /*#__PURE__*/React.createElement(Text, { + style: { + padding: 10, + fontSize: 42 + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, this.state.text.split(' ').map(word => word && '🍕').join(' '))); + } + } + export default function App() { + return /*#__PURE__*/React.createElement(View, { + style: styles.container, + dataSentryElement: \\"View\\", + dataSentryComponent: \\"App\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, /*#__PURE__*/React.createElement(Text, { + style: { + color: '#eee' + }, + dataSentryElement: \\"Text\\", + dataSentrySourceFile: \\"filename-test.js\\" + }, \\"FullStory ReactNative testing app\\"), /*#__PURE__*/React.createElement(Bananas, null), /*#__PURE__*/React.createElement(PizzaTranslator, null)); + } + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + backgroundColor: '#222', + alignItems: 'center', + justifyContent: 'center' + } + });" + `); +}); + +it("Bananas incompatible plugin @react-navigation source snapshot matches", () => { + const result = transform(BananasStandardInput, { + filename: "test/node_modules/@react-navigation/core/filename-test.js", + presets: ["@babel/preset-react"], + plugins: [[plugin, { native: true }]], + }); + expect(result?.code).toMatchInlineSnapshot(` + "import React, { Component } from 'react'; + import { Image } from 'react-native'; + class Bananas extends Component { + render() { + let pic = { + uri: 'https://upload.wikimedia.org/wikipedia/commons/d/de/Bananavarieties.jpg' + }; + return /*#__PURE__*/React.createElement(Image, { + source: pic, + style: { + width: 193, + height: 110, + marginTop: 10 + }, + fsClass: \\"test-class\\" + }); + } + }" + `); +}); diff --git a/packages/component-annotate-plugin/test/tsconfig.json b/packages/component-annotate-plugin/test/tsconfig.json new file mode 100644 index 00000000..b73d9b53 --- /dev/null +++ b/packages/component-annotate-plugin/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../src/tsconfig.json", + "include": ["../src/**/*", "./**/*"], + "compilerOptions": { + "types": ["node", "jest"] + } +} diff --git a/packages/component-annotate-plugin/types.tsconfig.json b/packages/component-annotate-plugin/types.tsconfig.json new file mode 100644 index 00000000..fb161bc4 --- /dev/null +++ b/packages/component-annotate-plugin/types.tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./src/tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "declaration": true, + "emitDeclarationOnly": true, + "declarationDir": "./dist/types" + } +} diff --git a/yarn.lock b/yarn.lock index 51d3cfb7..17504116 100644 --- a/yarn.lock +++ b/yarn.lock @@ -81,6 +81,13 @@ dependencies: "@babel/types" "^7.18.6" +"@babel/helper-annotate-as-pure@^7.22.5": + version "7.22.5" + resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz#e7f06737b197d580a01edf75d97e2c8be99d3882" + integrity sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg== + dependencies: + "@babel/types" "^7.22.5" + "@babel/helper-builder-binary-assignment-operator-visitor@^7.18.6": version "7.21.5" resolved "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.21.5.tgz#817f73b6c59726ab39f6ba18c234268a519e5abb" @@ -169,6 +176,13 @@ dependencies: "@babel/types" "^7.21.4" +"@babel/helper-module-imports@^7.22.15": + version "7.22.15" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0" + integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w== + dependencies: + "@babel/types" "^7.22.15" + "@babel/helper-module-transforms@^7.18.0", "@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11", "@babel/helper-module-transforms@^7.21.5": version "7.21.5" resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.21.5.tgz#d937c82e9af68d31ab49039136a222b17ac0b420" @@ -195,6 +209,11 @@ resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.21.5.tgz#345f2377d05a720a4e5ecfa39cbf4474a4daed56" integrity sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg== +"@babel/helper-plugin-utils@^7.22.5": + version "7.22.5" + resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz#dd7ee3735e8a313b9f7b05a773d892e88e6d7295" + integrity sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg== + "@babel/helper-remap-async-to-generator@^7.18.9": version "7.18.9" resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz#997458a0e3357080e54e1d79ec347f8a8cd28519" @@ -243,16 +262,31 @@ resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz#2b3eea65443c6bdc31c22d037c65f6d323b6b2bd" integrity sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w== +"@babel/helper-string-parser@^7.23.4": + version "7.23.4" + resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83" + integrity sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ== + "@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": version "7.19.1" resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== +"@babel/helper-validator-identifier@^7.22.20": + version "7.22.20" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" + integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== + "@babel/helper-validator-option@^7.16.7", "@babel/helper-validator-option@^7.21.0": version "7.21.0" resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz#8224c7e13ace4bafdc4004da2cf064ef42673180" integrity sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ== +"@babel/helper-validator-option@^7.22.15": + version "7.23.5" + resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz#907a3fbd4523426285365d1206c423c4c5520307" + integrity sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw== + "@babel/helper-wrap-function@^7.18.9": version "7.20.5" resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.20.5.tgz#75e2d84d499a0ab3b31c33bcfe59d6b8a45f62e3" @@ -494,6 +528,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" +"@babel/plugin-syntax-jsx@^7.23.3": + version "7.23.3" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz#8f2e4f8a9b5f9aa16067e142c1ac9cd9f810f473" + integrity sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" @@ -742,6 +783,39 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" +"@babel/plugin-transform-react-display-name@^7.23.3": + version "7.23.3" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.23.3.tgz#70529f034dd1e561045ad3c8152a267f0d7b6200" + integrity sha512-GnvhtVfA2OAtzdX58FJxU19rhoGeQzyVndw3GgtdECQvQFXPEZIOVULHVZGAYmOgmqjXpVpfocAbSjh99V/Fqw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-transform-react-jsx-development@^7.22.5": + version "7.22.5" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz#e716b6edbef972a92165cd69d92f1255f7e73e87" + integrity sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A== + dependencies: + "@babel/plugin-transform-react-jsx" "^7.22.5" + +"@babel/plugin-transform-react-jsx@^7.22.15", "@babel/plugin-transform-react-jsx@^7.22.5": + version "7.23.4" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz#393f99185110cea87184ea47bcb4a7b0c2e39312" + integrity sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.22.5" + "@babel/helper-module-imports" "^7.22.15" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-syntax-jsx" "^7.23.3" + "@babel/types" "^7.23.4" + +"@babel/plugin-transform-react-pure-annotations@^7.23.3": + version "7.23.3" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.23.3.tgz#fabedbdb8ee40edf5da96f3ecfc6958e3783b93c" + integrity sha512-qMFdSS+TUhB7Q/3HVPnEdYJDQIk57jkntAwSuz9xfSE4n+3I+vHYCli3HoHawN1Z3RfCz/y1zXA/JXjG6cVImQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.22.5" + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/plugin-transform-regenerator@^7.18.0": version "7.21.5" resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.21.5.tgz#576c62f9923f94bcb1c855adc53561fd7913724e" @@ -910,6 +984,18 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" +"@babel/preset-react@^7.23.3": + version "7.23.3" + resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.23.3.tgz#f73ca07e7590f977db07eb54dbe46538cc015709" + integrity sha512-tbkHOS9axH6Ysf2OUEqoSZ6T3Fa2SrNH6WTWSPBboxKzdxNc9qOICeLXkNG0ZEwbQ1HY8liwOce4aN/Ceyuq6w== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-validator-option" "^7.22.15" + "@babel/plugin-transform-react-display-name" "^7.23.3" + "@babel/plugin-transform-react-jsx" "^7.22.15" + "@babel/plugin-transform-react-jsx-development" "^7.22.5" + "@babel/plugin-transform-react-pure-annotations" "^7.23.3" + "@babel/preset-typescript@7.17.12": version "7.17.12" resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.17.12.tgz#40269e0a0084d56fc5731b6c40febe1c9a4a3e8c" @@ -965,6 +1051,15 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@babel/types@^7.22.15", "@babel/types@^7.22.5", "@babel/types@^7.23.4": + version "7.23.9" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz#1dd7b59a9a2b5c87f8b41e52770b5ecbf492e002" + integrity sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q== + dependencies: + "@babel/helper-string-parser" "^7.23.4" + "@babel/helper-validator-identifier" "^7.22.20" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"