diff --git a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx index ae86f9950..ecd05a8cb 100644 --- a/benchmarks/src/scenario/SessionReplay/component/Svg.tsx +++ b/benchmarks/src/scenario/SessionReplay/component/Svg.tsx @@ -321,7 +321,7 @@ function DynamicOpacityCircle({ fadeLevel }: { fadeLevel: number }) { // ───────────────────────────────────────────────────────────── // GROUP F — File imports -// LocalSvgHandler reads the SVG file from disk (Fix 2, 3, 4). +// LocalSvgHandler resolves and reads the imported SVG file from disk. // ───────────────────────────────────────────────────────────── /** F1: Default import of a local .svg file */ @@ -329,8 +329,11 @@ function LocalStarImport() { return ; } -/** F2: Named import from barrel file (export { default as HeartIcon }) - * Tests Fix 6: ExportNamedDeclaration uses spec.exported.name, not spec.local.name */ +/** + * F2: Named import from a barrel file (export { default as HeartIcon } from './heart.svg'). + * The barrel scan must key this by the exported name ('HeartIcon'), not the source + * module's local name ('default') — otherwise this import is never resolved. + */ function BarrelHeartImport() { return ; } @@ -430,7 +433,7 @@ export default function SvgTestCases() { All cases in Groups A–D should appear in replay.{'\n'} Group E: known limitation — absent from replay entirely (see comment).{'\n'} - Group F: appears after buildSvgMap fixes.{'\n'} + Group F: direct and barrel-re-exported .svg imports should appear in replay.{'\n'} Group G: privacy overrides — verify masking behavior in replay.{'\n'} Group I: I1 shows circle only (checkmark removed), I2 shows circle + checkmark. @@ -510,7 +513,7 @@ export default function SvgTestCases() { -
+
diff --git a/benchmarks/src/scenario/SessionReplay/component/assets/icons.ts b/benchmarks/src/scenario/SessionReplay/component/assets/icons.ts index 5662e288f..1f2f97258 100644 --- a/benchmarks/src/scenario/SessionReplay/component/assets/icons.ts +++ b/benchmarks/src/scenario/SessionReplay/component/assets/icons.ts @@ -4,8 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ -// Barrel file used to test Fix 6: ExportNamedDeclaration must use spec.exported.name -// not spec.local.name, otherwise 'export { default as HeartIcon }' stores 'default' -// as the key in localSvgMap instead of 'HeartIcon', and is never found. +// Barrel file used to test aliased re-exports: the SVG scan must key this +// entry by the exported name ('HeartIcon'), not the source module's local +// name ('default') — otherwise 'export { default as HeartIcon }' is never found. export { default as HeartIcon } from './heart.svg'; export { default as ShieldIcon } from './shield.svg'; diff --git a/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts b/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts index c6cffe00e..76bfbcdaa 100644 --- a/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts +++ b/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts @@ -6,6 +6,7 @@ */ import { transformSync } from '@babel/core'; +import * as babelTypes from '@babel/types'; import glob from 'fast-glob'; import fs from 'fs'; import path from 'path'; @@ -296,7 +297,7 @@ function mergeSvgAssets(assetsDir: string) { * --followSymlinks Follow symbolic links during directory traversal. * Default: false (symlinks are ignored). */ -function generateSessionReplayAssets() { +export function generateSessionReplayAssets() { const cliOptions = parseCliArgs(); const { verbose } = cliOptions; @@ -370,6 +371,8 @@ function generateSessionReplayAssets() { const errors: Array<{ file: string; error: string }> = []; const reactNativeSVG = new ReactNativeSVG(rootDir, assetsPath, true); + reactNativeSVG.setApiTypes(babelTypes); + reactNativeSVG.buildSvgMap(); for (const file of files) { try { diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index 6c41836c5..0492ce43b 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -53,13 +53,17 @@ export default declare( assetsPath = getAssetsPath(); } - reactNativeSVG = options.__internal_reactNativeSVG; + // Reuse the instance across files instead of rebuilding (and + // rescanning) it per file. + reactNativeSVG = + options.__internal_reactNativeSVG ?? reactNativeSVG; if (!reactNativeSVG && assetsPath) { reactNativeSVG = new ReactNativeSVG( process.cwd(), assetsPath, options.__internal_saveSvgMapToDisk || false ); + reactNativeSVG.setApiTypes(api.types); reactNativeSVG.buildSvgMap(); } reactNativeSVG?.setApiTypes(api.types); @@ -141,7 +145,11 @@ export default declare( options ); - pluginState.reactNativeSVG?.processItem(path, name); + pluginState.reactNativeSVG?.processItem( + path, + name, + state.filename ?? '' + ); } } }; diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts index dff5a54ff..6aff31736 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts @@ -16,45 +16,40 @@ type Dependencies = { t: typeof Babel.types; path: Babel.NodePath; name: string; - localSvgMap: Record; + // Already scoped to the current file's actual import bindings (see + // ReactNativeSVG.resolveSvgImport) — null if this tag isn't a local SVG import. + svgPath: string | null; + readSvgContent: (path: string) => string; }; export class HandlerResolver { private static registry: Record; private static dependencies: Dependencies | null = null; - /** - * Registers handler factories for supported JSX element types and stores shared dependencies. - * This method must be called before invoking `create()`, as it initializes the internal registry - * with handler constructors that are parameterized with the provided Babel context and configuration. - * - * @param dependencies - Shared Babel-related dependencies and contextual information, - * including `types`, the current JSX `path`, tag `name`, and the `localSvgMap`. - */ static configure(dependencies: Dependencies) { this.dependencies = dependencies; - const { t, path, name, localSvgMap } = dependencies; + const { t, path, name, svgPath, readSvgContent } = dependencies; HandlerResolver.registry = { RNSvgHandler: () => new RNSvgHandler(t, path, name), // UriSvgHandler: () => new UriSvgHandler(t, path, name), LocalSvgHandler: () => - new LocalSvgHandler(t, path, name, localSvgMap) + new LocalSvgHandler( + t, + path, + name, + svgPath as string, + readSvgContent + ) }; } - /** - * Resolves and returns the appropriate handler instance based on the JSX tag name. - * @throws Error if `configure()` has not been called prior to invocation. - * - * @returns The resolved handler instance or `null` if no match exists. - */ static create() { if (!this.dependencies) { throw new Error('HandlerResolver must be configured before use.'); } - const { name, localSvgMap } = this.dependencies; + const { name, svgPath } = this.dependencies; switch (name) { case 'Svg': { @@ -66,7 +61,7 @@ export class HandlerResolver { // } default: { - return localSvgMap[name] + return svgPath ? HandlerResolver.registry.LocalSvgHandler() : null; } diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts index 0504e0a10..0499e72a9 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts @@ -5,7 +5,6 @@ */ import type * as Babel from '@babel/core'; -import fs from 'fs'; import { getNodeName } from '../../../utils'; import { handleSvgDimensions } from '../processing/attributes'; @@ -13,40 +12,29 @@ import { handleSvgDimensions } from '../processing/attributes'; import type { SvgHandler } from './SvgHandler'; /** - * Internal handler that inlines locally imported SVG components into - * JSX output during Babel transformation. - * - * The `LocalSvgHandler` resolves SVG imports from disk, caches their raw - * contents, and extracts relevant dimension attributes (e.g., width, height) - * from the JSX element for use in the generated SVG markup. + * Inlines locally imported SVG components into JSX output during Babel + * transformation. */ export class LocalSvgHandler implements SvgHandler { constructor( private types: typeof Babel.types, private path: Babel.NodePath, private name: string, - private localSvgMap: Record + private svgPath: string, + private readSvgContent: (path: string) => string ) { // no-op } - /** - * Retrieves and returns the contents of a local SVG file corresponding to the JSXElement tag name. - * If the file hasn't been read yet, it reads the SVG content from disk and caches it in `localSvgMap`. - * Also extracts and stores width/height dimensions from the JSX attributes into the `dimensions` object. - * - * @param dimensions - Object to collect extracted width/height info. - * @returns Raw SVG string content from the local file, or undefined if the tag is not found in `localSvgMap`. - */ transformSvgNode(dimensions: Record) { - if (!this.localSvgMap[this.name]) { + if (!this.svgPath) { return undefined; } - const { path, content } = this.localSvgMap[this.name]; + const content = this.readSvgContent(this.svgPath); if (!content) { - this.localSvgMap[this.name].content = fs.readFileSync(path, 'utf8'); + return undefined; } this.processAttributes( @@ -56,22 +44,9 @@ export class LocalSvgHandler implements SvgHandler { dimensions ); - return this.localSvgMap[this.name].content; + return content; } - /** - * Processes the attributes of a JSXElement to extract relevant SVG metadata. - * Specifically identifies and handles dimension-related attributes (e.g., width, height), - * storing them into the provided `dimensions` object. Ignores spread attributes. - * - * @param t - Babel types helper. - * - * @param rootElementPath - The path of the root JSX element containing the SVG. - * Used to locate lexical scopes (component or program) for resolving variable references. - * May be `null` if no traversal context is available. - * @param jsxElement - The JSXElement whose attributes will be processed. - * @param dimensions - Object to collect extracted width/height info. - */ private processAttributes( t: typeof Babel.types, rootElementPath: Babel.NodePath | null, @@ -90,7 +65,6 @@ export class LocalSvgHandler implements SvgHandler { continue; } - // Handle SVG dimensions handleSvgDimensions( t, rootElementPath, diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts index 89caccf03..55ffb3b58 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts @@ -32,7 +32,7 @@ export class UriSvgHandler implements SvgHandler { * Also extracts and stores width/height dimensions from the JSX attributes into the `dimensions` object. * * @param dimensions - Object to collect extracted width/height info. - * @returns Raw SVG string content from the local file, or undefined if the tag is not found in `localSvgMap`. + * @returns The `uri` attribute value, or undefined if the element has none. */ transformSvgNode(dimensions: Record) { const uri = this.processAttributes( diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts index 1d3fed1ab..aa2949a48 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts @@ -25,22 +25,23 @@ type SvgOffset = { length: number; }; -/** - * Internal processor responsible for detecting, transforming, and wrapping - * React Native SVG components for use with Session Replay. - * - * This class scans the project for `.svg` imports, builds a mapping between - * JSX identifiers and SVG files, and transforms JSX SVG nodes into - * optimized, web-compatible SVG markup. Each transformed element is then - * wrapped in a `SessionReplayView.Privacy` component with metadata used by - * the native Session Replay layer. - */ +// A raw re-export edge found while scanning a file: `svgPath` is terminal +// (points at an .svg); `fromFile`/`fromName` is a barrel hop still to resolve. +type SvgExportEdge = + | { svgPath: string } + | { fromFile: string; fromName: string }; + export class ReactNativeSVG { svgMap: Record = {}; svgOffset: Record = {}; - localSvgMap: Record = {}; + // Resolved SVG re-exports, keyed by the barrel file's absolute path, then + // by the name it exposes. Direct `.svg` imports are resolved live in + // resolveSvgImport and are not stored here. + svgFileMap: Record> = {}; + + private svgContentCache: Record = {}; t: typeof Babel.types | null = null; @@ -55,37 +56,21 @@ export class ReactNativeSVG { } /** - * Scans all source files in the project to detect `.svg` imports and builds a mapping - * of JSX identifiers to their corresponding SVG file paths. This is done by parsing each - * file's AST and collecting `import` or `export` declarations that reference `.svg` files. - * - * The collected mappings are stored in `localSvgMap`, keyed by the local/imported variable - * names (e.g., `Logo`, `IconSearch`), with their values pointing to the resolved file path. - * - * This method ignores files in `node_modules`, `lib`, and `dist`, as well as `.d.ts`, test, - * and config files. - * - * If `saveSvgMapToDisk` is false, it will first attempt to load the mapping from a previously - * saved `svg-map.json` file for better performance. If the file doesn't exist or can't be read, - * it falls back to scanning the codebase. - * - * If `saveSvgMapToDisk` is true, the mapping will be saved to a JSON file in the assets directory - * after scanning. + * Scans the project for `.svg` barrel re-exports and populates svgFileMap, + * following `export ... from` chains down to their terminal `.svg` file. */ buildSvgMap() { if (!this.t) { return; } - // If not saving to disk, try to load from existing svg-map.json first if (!this.saveSvgMapToDisk) { - // Resolve to package root: from lib/commonjs/libraries/react-native-svg -> package root - const packageRoot = pathN.resolve(__dirname, '../../../..'); + const packageRoot = resolvePackageRoot(__dirname); const svgMapPath = pathN.join(packageRoot, 'svg-map.json'); try { if (fs.existsSync(svgMapPath)) { const mapContent = fs.readFileSync(svgMapPath, 'utf8'); - this.localSvgMap = JSON.parse(mapContent); + this.svgFileMap = JSON.parse(mapContent); return; } } catch (err) { @@ -113,6 +98,8 @@ export class ReactNativeSVG { } ); + const rawEdges: Record> = {}; + for (const file of files) { try { const code = fs.readFileSync(file, 'utf8'); @@ -132,57 +119,61 @@ export class ReactNativeSVG { }); traverse(ast, { - ImportDeclaration: path => { + ExportNamedDeclaration: path => { if (!this.t) { return; } - const source = path.node.source.value; - if (!source.endsWith('.svg')) { + const source = path.node.source?.value; + if (!source) { return; } - const resolved = pathN.resolve( - pathN.dirname(file), - source - ); - for (const spec of path.node.specifiers) { - const name = getNodeName(this.t, spec.local.name); - if (name) { - this.localSvgMap[name] = { - path: resolved - }; - } - } - }, - ExportNamedDeclaration: path => { - if (!this.t) { - return; - } - const source = path.node.source?.value; - if (!source?.endsWith('.svg')) { + const resolvedSourceFile = source.endsWith('.svg') + ? pathN.resolve(pathN.dirname(file), source) + : this.resolveModuleFile( + pathN.dirname(file), + source + ); + + if (!resolvedSourceFile) { return; } - const resolved = pathN.resolve( - pathN.dirname(file), - source - ); for (const spec of path.node.specifiers) { - if (spec.type === 'ExportSpecifier') { - const name = getNodeName( - this.t, - spec.local.name - ); - if (name) { - this.localSvgMap[name] = { - path: resolved - }; - } - } else { + if (spec.type !== 'ExportSpecifier') { console.warn( `[buildSvgMap]: Unhandled export specifier type: ${spec.type}` ); + continue; } + + // spec.exported is the name consumers import under + // ('default' would be wrong for aliased re-exports) + const exported = spec.exported; + const exportedName = getNodeName( + this.t, + this.t.isStringLiteral(exported) + ? exported.value + : exported.name + ); + const localName = getNodeName( + this.t, + spec.local.name + ); + + if (!exportedName || !localName) { + continue; + } + + rawEdges[file] ??= {}; + rawEdges[file][exportedName] = source.endsWith( + '.svg' + ) + ? { svgPath: resolvedSourceFile } + : { + fromFile: resolvedSourceFile, + fromName: localName + }; } } }); @@ -191,15 +182,44 @@ export class ReactNativeSVG { } } - // Save the mapping to disk if requested + const resolveEdge = ( + file: string, + name: string, + seen: Set = new Set() + ): string | null => { + const key = `${file}#${name}`; + if (seen.has(key)) { + return null; + } + seen.add(key); + + const edge = rawEdges[file]?.[name]; + if (!edge) { + return null; + } + if ('svgPath' in edge) { + return edge.svgPath; + } + return resolveEdge(edge.fromFile, edge.fromName, seen); + }; + + for (const file of Object.keys(rawEdges)) { + for (const name of Object.keys(rawEdges[file])) { + const svgPath = resolveEdge(file, name); + if (svgPath) { + this.svgFileMap[file] ??= {}; + this.svgFileMap[file][name] = svgPath; + } + } + } + if (this.saveSvgMapToDisk) { try { - // Resolve to package root: from lib/commonjs/libraries/react-native-svg -> package root - const packageRoot = pathN.resolve(__dirname, '../../../..'); + const packageRoot = resolvePackageRoot(__dirname); const svgMapPath = pathN.join(packageRoot, 'svg-map.json'); fs.writeFileSync( svgMapPath, - JSON.stringify(this.localSvgMap, null, 2), + JSON.stringify(this.svgFileMap, null, 2), 'utf8' ); } catch (err) { @@ -211,21 +231,120 @@ export class ReactNativeSVG { } } + private resolveModuleFile(fromDir: string, source: string): string | null { + const base = pathN.resolve(fromDir, source); + const candidates = [ + base, + `${base}.ts`, + `${base}.tsx`, + `${base}.js`, + `${base}.jsx`, + pathN.join(base, 'index.ts'), + pathN.join(base, 'index.tsx'), + pathN.join(base, 'index.js'), + pathN.join(base, 'index.jsx') + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + } + + return null; + } + /** - * Processes a JSXElement representing an SVG-based component and transforms it into - * a web-compliant SVG string with normalized attributes and extracted dimensions. - * The resulting SVG content and its metadata (e.g., width/height) are stored in `svgMap`, - * keyed by a generated UUID for later reference. - * - * Internally, the appropriate handler is selected based on the tag name and used to - * perform the transformation. - * - * @param path - Babel NodePath pointing to the JSXElement to process. - * @param name - JSX tag name (e.g., 'Svg', 'Logo') used to resolve the appropriate handler. - * @returns An object containing the original SVG string and its optimized version, - * or `undefined` if no transformation could be performed. + * Resolves the `.svg` path (if any) a JSX tag actually refers to in the + * current file, via its real import binding rather than a name-only + * lookup — this is what prevents cross-file name collisions. */ - processItem(path: Babel.NodePath, name: string) { + resolveSvgImport( + path: Babel.NodePath, + name: string, + currentFile: string + ): string | null { + if (!this.t) { + return null; + } + + const binding = path.scope.getBinding(name); + if (!binding) { + return null; + } + + const bindingNode = binding.path.node; + const isDefaultSpecifier = this.t.isImportDefaultSpecifier(bindingNode); + const isNamedSpecifier = this.t.isImportSpecifier(bindingNode); + const isNamespaceSpecifier = this.t.isImportNamespaceSpecifier( + bindingNode + ); + + if (!isDefaultSpecifier && !isNamedSpecifier && !isNamespaceSpecifier) { + return null; + } + + const importDeclaration = binding.path.parentPath?.node; + if ( + !importDeclaration || + !this.t.isImportDeclaration(importDeclaration) + ) { + return null; + } + + const source = importDeclaration.source.value; + + if (source.endsWith('.svg')) { + return pathN.resolve(pathN.dirname(currentFile), source); + } + + // A namespace import of a barrel (`import * as Icons from './icons'`) + // doesn't map to a single re-exported name. + if (isNamespaceSpecifier) { + return null; + } + + let importedName: string | null = null; + if (isDefaultSpecifier) { + importedName = 'default'; + } else if (this.t.isImportSpecifier(bindingNode)) { + const imported = bindingNode.imported; + importedName = this.t.isStringLiteral(imported) + ? imported.value + : imported.name; + } + + if (!importedName) { + return null; + } + + const resolvedSourceFile = this.resolveModuleFile( + pathN.dirname(currentFile), + source + ); + if (!resolvedSourceFile) { + return null; + } + + return this.svgFileMap[resolvedSourceFile]?.[importedName] ?? null; + } + + getSvgContent(svgPath: string): string { + if (!this.svgContentCache[svgPath]) { + this.svgContentCache[svgPath] = fs.readFileSync(svgPath, 'utf8'); + } + return this.svgContentCache[svgPath]; + } + + /** + * Transforms a JSXElement representing an SVG-based component into + * optimized, web-compatible SVG markup, wrapped for Session Replay. + */ + processItem( + path: Babel.NodePath, + name: string, + currentFile: string + ) { if (!this.t) { return; } @@ -237,11 +356,14 @@ export class ReactNativeSVG { return; } + const svgPath = this.resolveSvgImport(path, name, currentFile); + HandlerResolver.configure({ t: this.t, path, name, - localSvgMap: this.localSvgMap + svgPath, + readSvgContent: this.getSvgContent.bind(this) }); const handler = HandlerResolver.create(); @@ -299,37 +421,6 @@ export class ReactNativeSVG { } } - /** - * Wraps a JSX element with a `SessionReplayView.Privacy` component - * and injects metadata attributes used by Session Replay. - * - * The resulting element is transformed into: - * ```tsx - * - * {originalElement} - * - * ``` - * - * This transformation ensures the element is identifiable on the native side - * while preserving its layout and interaction behavior. - * - * @param t - Babel types helper used to build and manipulate AST nodes. - * @param path - The current JSXElement node path being transformed. - * @param id - The unique native identifier assigned to the element. - * @param hash - A content hash used to reference the corresponding resource. - * @param dimensions - Optional width and height metadata to include in the attributes. - * @returns A new `JSXElement` AST node wrapped in `SessionReplayView.Privacy`. - */ private wrapElementForSessionReplay( t: typeof Babel.types, path: Babel.NodePath, @@ -371,12 +462,10 @@ export class ReactNativeSVG { const attributesNode = [ t.jsxAttribute(jsxIdentifier('nativeID'), stringLiteral(id)), - // https://reactnative.dev/docs/view#collapsable t.jsxAttribute( t.jsxIdentifier('collapsable'), t.jsxExpressionContainer(t.booleanLiteral(false)) ), - // https://reactnative.dev/docs/view#pointerevents t.jsxAttribute( t.jsxIdentifier('pointerEvents'), t.stringLiteral('box-none') @@ -407,17 +496,6 @@ export class ReactNativeSVG { return viewWrapper; } - /** - * Ensures that the `SessionReplayView` import from - * `@datadog/mobile-react-native-session-replay` exists in the file. - * - * If the import is not already present, this method injects a new - * `import { SessionReplayView } from '@datadog/mobile-react-native-session-replay'` - * declaration at the top of the program. - * - * @param t - Babel types helper used to create and check AST nodes. - * @param path - The current JSXElement node path from which to locate the program root. - */ private ensureSessionReplayImport( t: typeof Babel.types, path: Babel.NodePath @@ -453,3 +531,37 @@ export class ReactNativeSVG { } } } + +const PACKAGE_NAME = '@datadog/mobile-react-native-babel-plugin'; + +// __dirname's depth relative to the package root differs between the built +// lib/commonjs/... output and running straight from src/... (e.g. ts-jest), +// so a hardcoded relative offset would be wrong in one of the two. Walk up +// to the actual package.json instead. +function resolvePackageRoot(startDir: string): string { + let currentDir = startDir; + + for (let i = 0; i < 10; i++) { + const packageJsonPath = pathN.join(currentDir, 'package.json'); + try { + if (fs.existsSync(packageJsonPath)) { + const pkg = JSON.parse( + fs.readFileSync(packageJsonPath, 'utf8') + ); + if (pkg.name === PACKAGE_NAME) { + return currentDir; + } + } + } catch { + // Malformed package.json — keep walking up. + } + + const parentDir = pathN.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + return pathN.resolve(startDir, '../../../..'); +} diff --git a/packages/react-native-babel-plugin/test/generate-sr-assets.test.ts b/packages/react-native-babel-plugin/test/generate-sr-assets.test.ts index 973ff0579..6c86b9a5d 100644 --- a/packages/react-native-babel-plugin/test/generate-sr-assets.test.ts +++ b/packages/react-native-babel-plugin/test/generate-sr-assets.test.ts @@ -4,11 +4,22 @@ * Copyright 2016-Present Datadog, Inc. */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + import { parseCliArgs, DEFAULT_IGNORE_PATTERNS, - normalizeIgnorePattern + normalizeIgnorePattern, + generateSessionReplayAssets } from '../src/cli/generate-sr-assets'; +import * as assetsFs from '../src/libraries/react-native-svg/processing/fs'; + +jest.mock('../src/libraries/react-native-svg/processing/fs', () => ({ + ...jest.requireActual('../src/libraries/react-native-svg/processing/fs'), + getAssetsPath: jest.fn() +})); describe('generate-sr-assets CLI', () => { describe('parseCliArgs', () => { @@ -389,3 +400,85 @@ describe('generate-sr-assets CLI', () => { }); }); }); + +describe('generateSessionReplayAssets', () => { + // getAssetsPath is mocked (rather than chdir-ing into the tmp project) so + // cwd stays the real repo root and Babel can still resolve + // @babel/preset-react/@babel/preset-typescript by name. + let projectDir: string; + let assetsDir: string; + let originalArgv: string[]; + let consoleInfoSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + let writeFileSyncSpy: jest.SpyInstance; + + // saveSvgMapToDisk writes svg-map.json to this package's real root, a + // location every other test file's buildSvgMap() also reads as a cache. + // Redirect just that one path into this test's own sandbox. + const realSvgMapPath = path.join( + path.resolve(__dirname, '..'), + 'svg-map.json' + ); + const realWriteFileSync = fs.writeFileSync; + + beforeEach(() => { + projectDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'dd-generate-sr-assets-') + ); + assetsDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'dd-generate-sr-assets-out-') + ); + fs.writeFileSync( + path.join(projectDir, 'icon.svg'), + '' + ); + fs.writeFileSync( + path.join(projectDir, 'Component.tsx'), + "import Logo from './icon.svg';\nfunction C() { return ; }" + ); + + (assetsFs.getAssetsPath as jest.Mock).mockReturnValue(assetsDir); + + originalArgv = process.argv; + process.argv = [...originalArgv, '--path', projectDir]; + + consoleInfoSpy = jest + .spyOn(console, 'info') + .mockImplementation(() => undefined); + consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + writeFileSyncSpy = jest + .spyOn(fs, 'writeFileSync') + .mockImplementation((file, data, options) => { + const target = + file === realSvgMapPath + ? path.join(assetsDir, 'svg-map.json') + : file; + return realWriteFileSync(target, data, options); + }); + }); + + afterEach(() => { + process.argv = originalArgv; + writeFileSyncSpy.mockRestore(); + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(assetsDir, { recursive: true, force: true }); + consoleInfoSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('scans the project, wraps the SVG import, and writes a populated assets index', () => { + generateSessionReplayAssets(); + + const indexPath = path.join(assetsDir, 'assets.json'); + + expect(fs.existsSync(indexPath)).toBe(true); + + const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); + expect(Object.keys(index).length).toBeGreaterThan(0); + + expect(fs.existsSync(path.join(assetsDir, 'assets.bin'))).toBe(true); + }); +}); diff --git a/packages/react-native-babel-plugin/test/react-native-svg.test.ts b/packages/react-native-babel-plugin/test/react-native-svg.test.ts index 178f450b4..044ebd8b5 100644 --- a/packages/react-native-babel-plugin/test/react-native-svg.test.ts +++ b/packages/react-native-babel-plugin/test/react-native-svg.test.ts @@ -18,6 +18,30 @@ import plugin from '../src/index'; import { RNSvgHandler } from '../src/libraries/react-native-svg/handlers/RNSvgHandler'; import { ReactNativeSVG } from '../src/libraries/react-native-svg'; +// buildSvgMap() reads /svg-map.json as a cache whenever +// saveSvgMapToDisk is false. If a real file exists there locally (e.g. from +// running datadog-generate-sr-assets), every test in this file would +// silently read stale data instead of scanning its own tmp fixtures. Hide it +// unconditionally so results don't depend on local machine state. +const REAL_SVG_MAP_PATH = path.join( + path.resolve(__dirname, '..'), + 'svg-map.json' +); +const realFsExistsSync = fs.existsSync; + +beforeEach(() => { + jest.spyOn(fs, 'existsSync').mockImplementation(p => { + if (p === REAL_SVG_MAP_PATH) { + return false; + } + return realFsExistsSync(p); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + /** * Helper function to test SVG transformation */ @@ -1146,3 +1170,495 @@ describe('SessionReplayView.Privacy SVG Wrapper', () => { expect(svgContent).not.toContain('fill={color}'); }); }); + +/** + * Parses `code` and returns the NodePath of the first JSXElement whose tag matches + * `tagName`, with real scope/bindings built. Used to exercise `resolveSvgImport`. + */ +function getJsxElementPath(code: string, tagName: string) { + const ast = parser.parse(code, { + sourceType: 'module', + plugins: ['jsx', 'typescript'] + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let result: any; + + traverse(ast, { + JSXElement(nodePath) { + const opening = nodePath.node.openingElement; + if ( + !result && + t.isJSXIdentifier(opening.name) && + opening.name.name === tagName + ) { + result = nodePath; + } + } + }); + + if (!result) { + throw new Error(`<${tagName}/> not found in test fixture`); + } + + return result; +} + +describe('ReactNativeSVG.buildSvgMap', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-buildsvgmap-')); + fs.writeFileSync( + path.join(tmpDir, 'icon.svg'), + '' + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should not populate svgFileMap for a direct import of an SVG file', () => { + const srcFile = path.join(tmpDir, 'Component.tsx'); + fs.writeFileSync( + srcFile, + `import Logo from './icon.svg';\nexport default function C() { return ; }` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.svgFileMap[srcFile]).toBeUndefined(); + }); + + it('should populate svgFileMap with the exported name for aliased re-exports', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync( + barrelFile, + `export { default as Logo } from './icon.svg';` + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.svgFileMap[barrelFile]?.['Logo']).toBe( + path.join(tmpDir, 'icon.svg') + ); + expect(instance.svgFileMap[barrelFile]?.['default']).toBeUndefined(); + }); + + it('should populate svgFileMap with the exported name for non-aliased re-exports', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync(barrelFile, `export { StarIcon } from './icon.svg';`); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.svgFileMap[barrelFile]?.['StarIcon']).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should resolve multi-hop barrel re-export chains to the terminal svg path', () => { + const innerBarrel = path.join(tmpDir, 'inner.ts'); + const outerBarrel = path.join(tmpDir, 'outer.ts'); + fs.writeFileSync(innerBarrel, `export { StarIcon } from './icon.svg';`); + fs.writeFileSync(outerBarrel, `export { StarIcon } from './inner';`); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.svgFileMap[outerBarrel]?.['StarIcon']).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should not overwrite svgFileMap when buildSvgMap is called a second time on a fresh instance', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync(barrelFile, `export { StarIcon } from './icon.svg';`); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + const mapAfterFirstCall = JSON.parse( + JSON.stringify(instance.svgFileMap) + ); + + const freshInstance = new ReactNativeSVG(tmpDir, tmpDir, false); + freshInstance.setApiTypes(t); + freshInstance.buildSvgMap(); + + expect(freshInstance.svgFileMap).toEqual(mapAfterFirstCall); + }); +}); + +describe('ReactNativeSVG.resolveSvgImport', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-resolvesvg-')); + fs.writeFileSync( + path.join(tmpDir, 'icon.svg'), + '' + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should resolve a direct default import of an SVG file', () => { + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import Logo from './icon.svg';\nfunction C() { return ; }`, + 'Logo' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + + expect(instance.resolveSvgImport(jsxPath, 'Logo', currentFile)).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should resolve a direct namespace import of an SVG file', () => { + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import * as Logo from './icon.svg';\nfunction C() { return ; }`, + 'Logo' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + + expect(instance.resolveSvgImport(jsxPath, 'Logo', currentFile)).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should resolve a direct aliased named import of an SVG file', () => { + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import { ReactComponent as StarIcon } from './icon.svg';\nfunction C() { return ; }`, + 'StarIcon' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + + expect( + instance.resolveSvgImport(jsxPath, 'StarIcon', currentFile) + ).toBe(path.join(tmpDir, 'icon.svg')); + }); + + it('should NOT resolve a namespace import of a barrel to any of its re-exports', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync( + barrelFile, + `export { default as Logo } from './icon.svg';` + ); + + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import * as Icons from './icons';\nfunction C() { return ; }`, + 'Icons' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect( + instance.resolveSvgImport(jsxPath, 'Icons', currentFile) + ).toBeNull(); + }); + + it('should resolve a barrel-imported SVG from a different file', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync( + barrelFile, + `export { default as Logo } from './icon.svg';` + ); + + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import { Logo } from './icons';\nfunction C() { return ; }`, + 'Logo' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.resolveSvgImport(jsxPath, 'Logo', currentFile)).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should resolve an aliased barrel import (import { Logo as Icon })', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync( + barrelFile, + `export { default as Logo } from './icon.svg';` + ); + + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import { Logo as Icon } from './icons';\nfunction C() { return ; }`, + 'Icon' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.resolveSvgImport(jsxPath, 'Icon', currentFile)).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + // Core regression case for the cross-file collision bug: a same-named + // component that is NOT an SVG import must never be treated as one. + it('should NOT resolve a locally declared component that merely shares a name with an SVG import elsewhere', () => { + const barrelFile = path.join(tmpDir, 'icons.ts'); + fs.writeFileSync( + barrelFile, + `export { default as Icon } from './icon.svg';` + ); + + const currentFile = path.join(tmpDir, 'Unrelated.tsx'); + const jsxPath = getJsxElementPath( + `function Icon() { return null; }\nfunction C() { return ; }`, + 'Icon' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect( + instance.resolveSvgImport(jsxPath, 'Icon', currentFile) + ).toBeNull(); + }); + + it('should NOT resolve a name imported from an unrelated (non-barrel) module', () => { + fs.writeFileSync( + path.join(tmpDir, 'icons.ts'), + `export { default as Icon } from './icon.svg';` + ); + fs.writeFileSync( + path.join(tmpDir, 'VectorIcon.tsx'), + `export default function Icon() { return null; }` + ); + + const currentFile = path.join(tmpDir, 'Unrelated.tsx'); + const jsxPath = getJsxElementPath( + `import Icon from './VectorIcon';\nfunction C() { return ; }`, + 'Icon' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect( + instance.resolveSvgImport(jsxPath, 'Icon', currentFile) + ).toBeNull(); + }); + + it('should resolve a barrel that is a directory with an index file', () => { + const barrelDir = path.join(tmpDir, 'icons'); + fs.mkdirSync(barrelDir); + fs.writeFileSync( + path.join(barrelDir, 'index.ts'), + `export { default as Logo } from '../icon.svg';` + ); + + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import { Logo } from './icons';\nfunction C() { return ; }`, + 'Logo' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + instance.buildSvgMap(); + + expect(instance.resolveSvgImport(jsxPath, 'Logo', currentFile)).toBe( + path.join(tmpDir, 'icon.svg') + ); + }); + + it('should return null (not throw) when the import source cannot be resolved to a file on disk', () => { + const currentFile = path.join(tmpDir, 'Component.tsx'); + const jsxPath = getJsxElementPath( + `import Icon from 'some-package-that-does-not-exist';\nfunction C() { return ; }`, + 'Icon' + ); + + const instance = new ReactNativeSVG(tmpDir, tmpDir, false); + instance.setApiTypes(t); + + expect(() => + instance.resolveSvgImport(jsxPath, 'Icon', currentFile) + ).not.toThrow(); + expect( + instance.resolveSvgImport(jsxPath, 'Icon', currentFile) + ).toBeNull(); + }); +}); + +describe('SVG resolution is scoped per file (end-to-end)', () => { + let projectDir: string; + let assetsDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-svg-e2e-')); + assetsDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'dd-svg-e2e-assets-') + ); + fs.writeFileSync( + path.join(projectDir, 'icon.svg'), + '' + ); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(assetsDir, { recursive: true, force: true }); + }); + + function transformProjectFile( + relativeFilename: string, + code: string, + reactNativeSVG: ReactNativeSVG + ): string | undefined { + return transform(code, { + filename: path.join(projectDir, relativeFilename), + presets: ['@babel/preset-react', '@babel/preset-typescript'], + plugins: [ + [ + plugin, + { + sessionReplay: { svgTracking: true }, + __internal_reactNativeSVG: reactNativeSVG + } + ] + ], + configFile: false + })?.code as string | undefined; + } + + it('wraps a directly-imported local SVG', () => { + const reactNativeSVG = new ReactNativeSVG(projectDir, assetsDir, false); + reactNativeSVG.setApiTypes(t); + + const output = transformProjectFile( + 'Component.tsx', + `import Logo from './icon.svg';\nfunction C() { return ; }`, + reactNativeSVG + ); + + expect(output).toContain('SessionReplayView.Privacy'); + }); + + it('wraps an SVG imported through a barrel re-export from a different file', () => { + fs.writeFileSync( + path.join(projectDir, 'icons.ts'), + `export { default as Logo } from './icon.svg';` + ); + + const reactNativeSVG = new ReactNativeSVG(projectDir, assetsDir, false); + reactNativeSVG.setApiTypes(t); + reactNativeSVG.buildSvgMap(); + + const output = transformProjectFile( + 'Component.tsx', + `import { Logo } from './icons';\nfunction C() { return ; }`, + reactNativeSVG + ); + + expect(output).toContain('SessionReplayView.Privacy'); + }); + + it('does not wrap an unrelated component that shares a name with an SVG imported elsewhere in the project', () => { + fs.writeFileSync( + path.join(projectDir, 'icons.ts'), + `export { default as Icon } from './icon.svg';` + ); + + const reactNativeSVG = new ReactNativeSVG(projectDir, assetsDir, false); + reactNativeSVG.setApiTypes(t); + reactNativeSVG.buildSvgMap(); + + const outputA = transformProjectFile( + 'FileA.tsx', + `import { Icon } from './icons';\nfunction A() { return ; }`, + reactNativeSVG + ); + expect(outputA).toContain('SessionReplayView.Privacy'); + + const inputB = `function Icon() { return null; }\nfunction B() { return ; }`; + const outputB = transformProjectFile( + 'FileB.tsx', + inputB, + reactNativeSVG + ); + + expect(outputB).not.toContain('SessionReplayView.Privacy'); + expect(outputB).toContain('function Icon()'); + }); +}); + +// Every other test in this file supplies __internal_reactNativeSVG pre-built +// with setApiTypes() already called, bypassing pre()'s own instance-construction +// branch entirely. This exercises that real path. +describe('Babel plugin: pre() constructs its own ReactNativeSVG instance', () => { + let projectDir: string; + let originalCwd: string; + let originalPluginDev: string | undefined; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-pre-hook-')); + fs.writeFileSync( + path.join(projectDir, 'icon.svg'), + '' + ); + + originalCwd = process.cwd(); + originalPluginDev = process.env.pluginDev; + process.env.pluginDev = 'true'; + process.chdir(projectDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (originalPluginDev === undefined) { + delete process.env.pluginDev; + } else { + process.env.pluginDev = originalPluginDev; + } + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + it('wraps a directly-imported local SVG without an injected ReactNativeSVG instance', () => { + const output = transform( + `import Logo from './icon.svg';\nfunction C() { return ; }`, + { + filename: path.join(projectDir, 'Component.tsx'), + // No node_modules under projectDir to resolve presets from. + parserOpts: { plugins: ['jsx', 'typescript'] }, + plugins: [[plugin, { sessionReplay: { svgTracking: true } }]], + configFile: false + } + )?.code as string | undefined; + + expect(output).toContain('SessionReplayView.Privacy'); + }); +}); diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt index 7ae53ae2f..edd88fd47 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt @@ -60,20 +60,22 @@ internal open class SvgViewMapper( val wireframes = mutableListOf() if (view is DdPrivacyView) { - val hash = view.attributes?.get("hash") ?: return listOf( - MobileSegment.Wireframe.ShapeWireframe( - resolveViewId(view), - viewGlobalBounds.x, - viewGlobalBounds.y, - viewGlobalBounds.width, - viewGlobalBounds.height, - shapeStyle = shapeStyle, - border = border - ) + val containerWireframe = MobileSegment.Wireframe.ShapeWireframe( + resolveViewId(view), + viewGlobalBounds.x, + viewGlobalBounds.y, + viewGlobalBounds.width, + viewGlobalBounds.height, + shapeStyle = shapeStyle, + border = border ) + + val hash = view.attributes?.get("hash") ?: return listOf(containerWireframe) val width = view.attributes?.get("width") val height = view.attributes?.get("height") + wireframes.add(containerWireframe) + var entryData = internalCallback.getEntryData(hash) ?: return wireframes @@ -96,16 +98,6 @@ internal open class SvgViewMapper( entryData = entryStr.toByteArray(Charsets.UTF_8); } - wireframes.add(MobileSegment.Wireframe.ShapeWireframe( - resolveViewId(view), - viewGlobalBounds.x, - viewGlobalBounds.y, - viewGlobalBounds.width, - viewGlobalBounds.height, - shapeStyle = shapeStyle, - border = border - )) - val imageWireframeId = viewIdentifierResolver.resolveChildUniqueIdentifier(view, "svg") ?: return wireframes val imgWireframe = MobileSegment.Wireframe.ImageWireframe( imageWireframeId, diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt index 97b01be5e..f37c3bc50 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapperTest.kt @@ -148,7 +148,7 @@ internal class SvgViewMapperTest { } @Test - fun `M return empty list W map() { DdPrivacyView with hash but no entry data }`() { + fun `M return ShapeWireframe W map() { DdPrivacyView with hash but no entry data }`() { // Given val hash = "missing-entry-hash" whenever(mockDdPrivacyView.attributes).thenReturn(mapOf("hash" to hash)) @@ -163,11 +163,12 @@ internal class SvgViewMapperTest { ) // Then - assertThat(result).isEmpty() + assertThat(result).hasSize(1) + assertThat(result[0]).isInstanceOf(MobileSegment.Wireframe.ShapeWireframe::class.java) } @Test - fun `M return empty list W map() { DdPrivacyView with hash but no child view }`() { + fun `M return ShapeWireframe W map() { DdPrivacyView with hash but no child view }`() { // Given val hash = "no-child-hash" val svgBytes = "".toByteArray(Charsets.UTF_8) @@ -184,6 +185,7 @@ internal class SvgViewMapperTest { ) // Then - assertThat(result).isEmpty() + assertThat(result).hasSize(1) + assertThat(result[0]).isInstanceOf(MobileSegment.Wireframe.ShapeWireframe::class.java) } }