From 9eaa08f31c5507dc9f79da188d633cb146857528 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 7 May 2026 15:57:32 +0200 Subject: [PATCH] feat(babel): Auto-inject sentry-label from static text children Add opt-in `autoInjectSentryLabel` option to the Babel component annotate plugin. When enabled, the plugin extracts static text from JSX children (up to 3 levels deep) and injects a `sentry-label` attribute on the root element. This gives React Native apps meaningful touch breadcrumb labels without manual annotation. Closes getsentry/sentry-react-native#6098 Co-Authored-By: Claude Opus 4.6 --- .../src/index.ts | 223 ++++ .../test/sentry-label.test.ts | 1008 +++++++++++++++++ 2 files changed, 1231 insertions(+) create mode 100644 packages/babel-plugin-component-annotate/test/sentry-label.test.ts diff --git a/packages/babel-plugin-component-annotate/src/index.ts b/packages/babel-plugin-component-annotate/src/index.ts index 8cb7495f..c5ef538b 100644 --- a/packages/babel-plugin-component-annotate/src/index.ts +++ b/packages/babel-plugin-component-annotate/src/index.ts @@ -45,10 +45,17 @@ const nativeComponentName = "dataSentryComponent"; const nativeElementName = "dataSentryElement"; const nativeSourceFileName = "dataSentrySourceFile"; +const SENTRY_LABEL_ATTRIBUTE = "sentry-label"; +const MAX_LABEL_LENGTH = 64; +const DEFAULT_TEXT_COMPONENT_NAMES = ["Text", "text"]; +const MAX_TEXT_SEARCH_DEPTH = 3; + interface AnnotationOpts { native?: boolean; "annotate-fragments"?: boolean; ignoredComponents?: string[]; + autoInjectSentryLabel?: boolean; + textComponentNames?: string[]; } interface FragmentContext { @@ -79,6 +86,10 @@ interface JSXProcessingContext { ignoredComponents: string[]; /** Fragment context for identifying React fragments */ fragmentContext?: FragmentContext; + /** Whether to auto-inject sentry-label from static text children */ + autoInjectSentryLabel: boolean; + /** Component names whose JSXText children are considered text content */ + textComponentNames: string[]; } export { experimentalComponentNameAnnotatePlugin } from "./experimental"; @@ -170,6 +181,8 @@ function createJSXProcessingContext( attributeNames: attributeNamesFromState(state), ignoredComponents: state.opts.ignoredComponents ?? [], fragmentContext: state.sentryFragmentContext, + autoInjectSentryLabel: state.opts.autoInjectSentryLabel === true, + textComponentNames: state.opts.textComponentNames ?? DEFAULT_TEXT_COMPONENT_NAMES, }; } @@ -261,6 +274,7 @@ function processJSX( // Use provided componentName or fall back to context componentName const currentComponentName = componentName ?? context.componentName; + const isRootElement = componentName === undefined; // NOTE: I don't know of a case where `openingElement` would have more than one item, // but it's safer to always iterate @@ -305,6 +319,10 @@ function processJSX( processJSX(context, child, ""); } }); + + if (isRootElement && context.autoInjectSentryLabel) { + maybeInjectSentryLabel(context, jsxNode); + } } /** @@ -658,4 +676,209 @@ function getJSXMemberExpressionObjectName( return UNKNOWN_ELEMENT_NAME; } +/** + * Extracts static text content from JSX children, searching up to a depth limit. + * Collects text from JSXText nodes of the root element and from recognized + * text components (e.g. ). Non-text custom components are traversed + * but their own JSXText is not collected. + * + * Returns null when dynamic content is found anywhere in the subtree, + * signaling that the entire label should be skipped. + */ +function extractStaticTextFromChildren( + t: typeof Babel.types, + node: Babel.types.JSXElement | Babel.types.JSXFragment, + textComponentNames: string[], + depth: number, + isRoot: boolean +): string[] | null { + if (depth <= 0) { + return []; + } + + const texts: string[] = []; + + for (const child of node.children) { + if (t.isJSXText(child)) { + if (isRoot) { + const trimmed = child.value.replace(/\s+/g, " ").trim(); + if (trimmed) { + texts.push(trimmed); + } + } + } else if (t.isJSXElement(child)) { + const childName = getElementName(t, child.openingElement); + + if (textComponentNames.includes(childName)) { + const innerTexts = extractTextFromTextComponent(t, child, textComponentNames); + if (innerTexts === null) { + return null; + } + texts.push(...innerTexts); + } else { + const result = extractStaticTextFromChildren( + t, + child, + textComponentNames, + depth - 1, + false + ); + if (result === null) { + return null; + } + texts.push(...result); + } + } else if (t.isJSXFragment(child)) { + const result = extractStaticTextFromChildren(t, child, textComponentNames, depth, isRoot); + if (result === null) { + return null; + } + texts.push(...result); + } else if (t.isJSXExpressionContainer(child)) { + if (!t.isJSXEmptyExpression(child.expression)) { + return null; + } + } else if (t.isJSXSpreadChild(child)) { + return null; + } + } + + return texts; +} + +/** + * Recursively extracts static text from within a recognized text component. + * Handles nested text components (e.g. Hello world) + * which is the standard React Native pattern for inline styling. + * + * Returns null when any dynamic content is found, signaling bail-out. + */ +function extractTextFromTextComponent( + t: typeof Babel.types, + node: Babel.types.JSXElement | Babel.types.JSXFragment, + textComponentNames: string[] +): string[] | null { + const texts: string[] = []; + + for (const child of node.children) { + if (t.isJSXText(child)) { + const trimmed = child.value.replace(/\s+/g, " ").trim(); + if (trimmed) { + texts.push(trimmed); + } + } else if (t.isJSXExpressionContainer(child)) { + if (!t.isJSXEmptyExpression(child.expression)) { + return null; + } + } else if (t.isJSXElement(child)) { + const childName = getElementName(t, child.openingElement); + if (textComponentNames.includes(childName)) { + const innerTexts = extractTextFromTextComponent(t, child, textComponentNames); + if (innerTexts === null) { + return null; + } + texts.push(...innerTexts); + } else { + const innerTexts = extractTextFromTextComponent(t, child, textComponentNames); + if (innerTexts === null) { + return null; + } + } + } else if (t.isJSXFragment(child)) { + const innerTexts = extractTextFromTextComponent(t, child, textComponentNames); + if (innerTexts === null) { + return null; + } + texts.push(...innerTexts); + } else if (t.isJSXSpreadChild(child)) { + return null; + } + } + + return texts; +} + +function getElementName( + t: typeof Babel.types, + openingElement: Babel.types.JSXOpeningElement +): string { + const name = openingElement.name; + if (t.isJSXIdentifier(name)) { + return name.name; + } + if (t.isJSXMemberExpression(name)) { + return `${getJSXMemberExpressionObjectName(t, name.object)}.${name.property.name}`; + } + return ""; +} + +/** + * Injects a sentry-label attribute on the root JSX element of a component if + * static text content can be extracted from its children. + * + * When the root is a JSX fragment, the first JSXElement child is used as the + * target for both text extraction and attribute injection (since fragments + * cannot carry attributes). + */ +function maybeInjectSentryLabel(context: JSXProcessingContext, jsxNode: Babel.NodePath): void { + const { t, textComponentNames, ignoredComponents, componentName } = context; + const node = jsxNode.node; + + let targetElement: Babel.types.JSXElement; + + if (t.isJSXElement(node)) { + targetElement = node; + } else if (t.isJSXFragment(node)) { + const firstChild = node.children.find((c): c is Babel.types.JSXElement => t.isJSXElement(c)); + if (!firstChild) { + return; + } + targetElement = firstChild; + } else { + return; + } + + const targetElementName = getElementName(t, targetElement.openingElement); + + if ( + ignoredComponents.some((ignored) => ignored === componentName || ignored === targetElementName) + ) { + return; + } + + if ( + targetElement.openingElement.attributes.some( + (attr) => t.isJSXAttribute(attr) && attr.name.name === SENTRY_LABEL_ATTRIBUTE + ) + ) { + return; + } + + const texts = extractStaticTextFromChildren( + t, + targetElement, + textComponentNames, + MAX_TEXT_SEARCH_DEPTH, + true + ); + + if (texts === null) { + return; + } + + let label = texts.join(" ").replace(/\s+/g, " ").trim(); + + if (!label) { + return; + } + + if (label.length > MAX_LABEL_LENGTH) { + label = label.substring(0, MAX_LABEL_LENGTH - 3) + "..."; + } + + targetElement.openingElement.attributes.push( + t.jSXAttribute(t.jSXIdentifier(SENTRY_LABEL_ATTRIBUTE), t.stringLiteral(label)) + ); +} + const UNKNOWN_ELEMENT_NAME = "unknown"; diff --git a/packages/babel-plugin-component-annotate/test/sentry-label.test.ts b/packages/babel-plugin-component-annotate/test/sentry-label.test.ts new file mode 100644 index 00000000..7d6eedfb --- /dev/null +++ b/packages/babel-plugin-component-annotate/test/sentry-label.test.ts @@ -0,0 +1,1008 @@ +import { describe, it, expect } from "vitest"; +import { transform, BabelFileResult } from "@babel/core"; +import plugin from "../src/index"; + +function transformWith(code: string, opts: Record = {}): BabelFileResult | null { + return transform(code, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, { autoInjectSentryLabel: true, ...opts }]], + }); +} + +function transformWithout( + code: string, + opts: Record = {} +): BabelFileResult | null { + return transform(code, { + filename: "/filename-test.js", + configFile: false, + presets: ["@babel/preset-react"], + plugins: [[plugin, opts]], + }); +} + +describe("autoInjectSentryLabel", () => { + describe("opt-in behavior", () => { + it("does not inject sentry-label when autoInjectSentryLabel is not set", () => { + const result = transformWithout(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("does not inject sentry-label when autoInjectSentryLabel is false", () => { + const result = transformWithout( + ` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello + + ); + } + `, + { autoInjectSentryLabel: false } + ); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("injects sentry-label when autoInjectSentryLabel is true", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Hello"'); + }); + }); + + describe("basic static text extraction", () => { + it("extracts text from a Text child", () => { + const result = transformWith(` + import React from 'react'; + import { Text, TouchableOpacity } from 'react-native'; + + export default function SaveButton() { + return ( + + Save workout + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Save workout"'); + }); + + it("extracts text from a nested Text within a View", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View, TouchableOpacity } from 'react-native'; + + export default function Card() { + return ( + + + Details + + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Details"'); + }); + + it("works with arrow function components", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + const MyButton = () => ( + + Press me + + ); + `); + expect(result?.code).toContain('"sentry-label": "Press me"'); + }); + + it("works with class components", () => { + const result = transformWith(` + import React, { Component } from 'react'; + import { Text, View } from 'react-native'; + + class MyButton extends Component { + render() { + return ( + + Click here + + ); + } + } + `); + expect(result?.code).toContain('"sentry-label": "Click here"'); + }); + }); + + describe("multiple text children", () => { + it("joins text from multiple Text children with space", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function AddToCart() { + return ( + + Add + to cart + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Add to cart"'); + }); + + it("joins text from multiple nested Text children", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View, TouchableOpacity } from 'react-native'; + + export default function Header() { + return ( + + + Welcome + back + + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Welcome back"'); + }); + }); + + describe("skip conditions", () => { + it("skips when sentry-label already exists", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Auto text + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Custom label"'); + expect(result?.code).not.toContain('"sentry-label": "Auto text"'); + }); + + it("skips dynamic expression children", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + {variable} + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("skips when Text child has a function call expression", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + {t('key')} + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("skips when Text child has a template literal", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + {\`hello \${name}\`} + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("skips when text is empty or whitespace only", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("skips when no Text children exist", () => { + const result = transformWith(` + import React from 'react'; + import { View, Image } from 'react-native'; + + export default function MyComponent() { + return ( + + + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("skips when expression container is at root level", () => { + const result = transformWith(` + import React from 'react'; + import { View } from 'react-native'; + + export default function MyComponent() { + return ( + + {someContent} + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + }); + + describe("truncation", () => { + it("truncates text longer than 64 characters with ...", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + This is an extremely long text that definitely exceeds the sixty-four character limit + + ); + } + `); + const match = result?.code?.match(/"sentry-label": "([^"]+)"/); + expect(match).toBeTruthy(); + const label = match?.[1] ?? ""; + expect(label.length).toBe(64); + expect(label.endsWith("...")).toBe(true); + expect(label).toBe("This is an extremely long text that definitely exceeds the si..."); + }); + + it("does not truncate text at exactly 64 characters", () => { + // 64 chars exactly + const text64 = "A".repeat(64); + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + ${text64} + + ); + } + `); + expect(result?.code).toContain(`"sentry-label": "${text64}"`); + }); + }); + + describe("depth limit", () => { + it("extracts text at depth 1 (direct child)", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Direct child + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Direct child"'); + }); + + it("extracts text at depth 2 (nested in one wrapper)", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + + Nested once + + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Nested once"'); + }); + + it("extracts text at depth 3 (nested in two wrappers)", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + + + Nested twice + + + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Nested twice"'); + }); + + it("does not extract text beyond depth limit", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + + + + Too deep + + + + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("does not count fragments toward depth limit", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + + + <> + Still found + + + + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Still found"'); + }); + }); + + describe("text component names", () => { + it("recognizes lowercase text component", () => { + const result = transformWith(` + import React from 'react'; + + export default function MyComponent() { + return ( + + Hello + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Hello"'); + }); + + it("supports custom text component names via option", () => { + const result = transformWith( + ` + import React from 'react'; + + export default function MyComponent() { + return ( + + + + ); + } + `, + { textComponentNames: ["Label", "Text"] } + ); + expect(result?.code).toContain('"sentry-label": "Custom text"'); + }); + + it("does not extract from non-text components by default", () => { + const result = transformWith(` + import React from 'react'; + import { View, Button } from 'react-native'; + + export default function MyComponent() { + return ( + + + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + }); + + describe("nested text components (RN inline styling)", () => { + it("extracts text from nested Text inside Text", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello world + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Hello world"'); + }); + + it("extracts text from deeply nested inline Text", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Press Save now to continue + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Press Save now to continue"'); + }); + + it("bails out when nested Text contains dynamic content", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello {name} + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("skips non-text elements inside Text without bailing out", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello world + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Hello world"'); + }); + + it("extracts text from fragment children inside Text", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello <>World more + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Hello World more"'); + }); + + it("handles Text wrapping only a non-text element", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + hello + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + }); + + describe("web compatibility", () => { + it("uses hyphenated sentry-label attribute", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello + + ); + } + `); + expect(result?.code).toContain('"sentry-label"'); + expect(result?.code).not.toContain("sentryLabel"); + }); + + it("uses sentry-label in native mode too", () => { + const result = transformWith( + ` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello + + ); + } + `, + { native: true } + ); + expect(result?.code).toContain('"sentry-label": "Hello"'); + }); + }); + + describe("fragment handling", () => { + it("injects on first element child when root is a fragment", () => { + const result = transformWith(` + import React from 'react'; + import { Text, TouchableOpacity } from 'react-native'; + + export default function MyComponent() { + return ( + <> + + Hello + + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Hello"'); + }); + + it("extracts text only from the target element, not sibling fragment children", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + <> + A + B + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "A"'); + expect(result?.code).not.toContain('"sentry-label": "A B"'); + }); + + it("skips root fragment when it has no element children", () => { + const result = transformWith(` + import React from 'react'; + + export default function MyComponent() { + return ( + <> + Just text + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("skips root fragment when first child already has sentry-label", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + <> + + Auto text + + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Manual"'); + expect(result?.code).not.toContain('"sentry-label": "Auto text"'); + }); + + it("traverses through fragment children to find text", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + <> + Fragment text + + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Fragment text"'); + }); + }); + + describe("edge cases", () => { + it("trims whitespace from extracted text", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello world + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Hello world"'); + }); + + it("normalizes double spaces when joining text from multiple components", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello + world + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Hello world"'); + expect(result?.code).not.toContain("Hello world"); + }); + + it("collapses internal whitespace", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello world + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Hello world"'); + }); + + it("still adds other sentry attributes alongside sentry-label", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello + + ); + } + `); + expect(result?.code).toContain("data-sentry-component"); + expect(result?.code).toContain("data-sentry-source-file"); + expect(result?.code).toContain('"sentry-label": "Hello"'); + }); + + it("handles mixed static and dynamic children — skips all when dynamic present", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Static + {dynamicContent} + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("respects ignoredComponents — does not inject sentry-label", () => { + const result = transformWith( + ` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function IgnoredComp() { + return ( + + Should not label + + ); + } + `, + { ignoredComponents: ["IgnoredComp"] } + ); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("respects ignoredComponents matching the element name", () => { + const result = transformWith( + ` + import React from 'react'; + import { Text } from 'react-native'; + import { CustomCard } from './components'; + + export default function MyComponent() { + return ( + + Card text + + ); + } + `, + { ignoredComponents: ["CustomCard"] } + ); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("extracts text from JSXText inside a fragment child of root", () => { + const result = transformWith(` + import React from 'react'; + + export default function MyComponent() { + return ; + } + `); + expect(result?.code).toContain('"sentry-label": "Click me"'); + }); + + it("bails out when non-text element inside Text contains dynamic content", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + Hello {name} world + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("handles direct JSXText on the root element", () => { + const result = transformWith(` + import React from 'react'; + + export default function MyComponent() { + return ; + } + `); + expect(result?.code).toContain('"sentry-label": "Click me"'); + }); + + it("bails out entirely when dynamic content is nested inside a non-text wrapper", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return ( + + + {dynamic} + + Static + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("does not match member-expression text components against simple name", () => { + const result = transformWith(` + import React from 'react'; + import { View } from 'react-native'; + import MyLib from 'my-lib'; + + export default function MyComponent() { + return ( + + Not matched + + ); + } + `); + expect(result?.code).not.toContain("sentry-label"); + }); + + it("matches member-expression text components when configured", () => { + const result = transformWith( + ` + import React from 'react'; + import { View } from 'react-native'; + import MyLib from 'my-lib'; + + export default function MyComponent() { + return ( + + Matched + + ); + } + `, + { textComponentNames: ["Text", "MyLib.Text"] } + ); + expect(result?.code).toContain('"sentry-label": "Matched"'); + }); + }); + + describe("multiple components in one file", () => { + it("injects independent labels on each component", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + function SaveButton() { + return ( + + Save + + ); + } + + function CancelButton() { + return ( + + Cancel + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Save"'); + expect(result?.code).toContain('"sentry-label": "Cancel"'); + }); + + it("only injects on components that have text, not on others", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View, Image } from 'react-native'; + + function IconButton() { + return ( + + + + ); + } + + function TextButton() { + return ( + + Click + + ); + } + `); + expect(result?.code).toContain('"sentry-label": "Click"'); + const matches = result?.code?.match(/"sentry-label"/g); + expect(matches?.length).toBe(1); + }); + }); + + describe("ternary returns", () => { + it("injects labels on both branches of a ternary", () => { + const result = transformWith(` + import React from 'react'; + import { Text, View } from 'react-native'; + + export default function MyComponent() { + return condition + ? Yes + : No; + } + `); + expect(result?.code).toContain('"sentry-label": "Yes"'); + expect(result?.code).toContain('"sentry-label": "No"'); + }); + }); +});