From e380484bb7162c47fa101fa96a6f0857bf1fdd0a Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:18:40 +0200 Subject: [PATCH 1/5] Improve runtime for generate-svg-assets --- .../src/cli/generate-sr-assets.ts | 10 +++++++- .../react-native-babel-plugin/src/index.ts | 6 +++-- .../src/libraries/react-native-svg/index.ts | 25 ++++++++++++++++--- .../src/types/general.ts | 1 + 4 files changed, 36 insertions(+), 6 deletions(-) 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 c94485c6d..3eb506bba 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 @@ -15,6 +15,7 @@ import { clearAssetsDir, getAssetsPath } from '../libraries/react-native-svg/processing/fs'; +import { ReactNativeSVG } from '../libraries/react-native-svg'; type SvgIndexEntry = { offset: number; @@ -141,6 +142,12 @@ function generateSessionReplayAssets() { let errorCount = 0; const errors: Array<{ file: string; error: string }> = []; + const reactNativeSVG = new ReactNativeSVG( + process.cwd(), + assetsPath, + true + ); + for (const file of files) { try { const code = fs.readFileSync(file, 'utf8'); @@ -155,7 +162,8 @@ function generateSessionReplayAssets() { sessionReplay: { svgTracking: true }, - __internal_saveSvgMapToDisk: true + __internal_saveSvgMapToDisk: true, + __internal_reactNativeSVG: reactNativeSVG } ] ], diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index 8379c600f..463b65f64 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -39,7 +39,7 @@ export default declare( } }; - let reactNativeSVG: ReactNativeSVG | null = null; + let reactNativeSVG: ReactNativeSVG | undefined = undefined; let assetsPath: string | null = null; @@ -53,14 +53,16 @@ export default declare( assetsPath = getAssetsPath(); } + reactNativeSVG = options.__internal_reactNativeSVG; if (!reactNativeSVG && assetsPath) { reactNativeSVG = new ReactNativeSVG( - api.types, process.cwd(), assetsPath, options.__internal_saveSvgMapToDisk || false ); + reactNativeSVG.buildSvgMap(); } + reactNativeSVG?.setApiTypes(api.types); }, visitor: { Program: { 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 1ef61ff28..ef6301ff1 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 @@ -42,13 +42,18 @@ export class ReactNativeSVG { localSvgMap: Record = {}; + t: typeof Babel.types | null = null; + constructor( - private t: typeof Babel.types, private rootDir: string, private assetsPath: string, private saveSvgMapToDisk: boolean = false ) { - this.buildSvgMap(); + + } + + setApiTypes(t: typeof Babel.types) { + this.t = t; } /** @@ -70,6 +75,10 @@ export class ReactNativeSVG { * after scanning. */ 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 @@ -122,10 +131,13 @@ export class ReactNativeSVG { 'classProperties', 'dynamicImport' ] - }); + }); traverse(ast, { ImportDeclaration: path => { + if (!this.t) { + return; + } const source = path.node.source.value; if (!source.endsWith('.svg')) { return; @@ -145,6 +157,9 @@ export class ReactNativeSVG { } }, ExportNamedDeclaration: path => { + if (!this.t) { + return; + } const source = path.node.source?.value; if (!source?.endsWith('.svg')) { return; @@ -213,6 +228,10 @@ export class ReactNativeSVG { * or `undefined` if no transformation could be performed. */ processItem(path: Babel.NodePath, name: string) { + if (!this.t) { + return; + } + try { const dimensions: { width?: string; height?: string } = {}; diff --git a/packages/react-native-babel-plugin/src/types/general.ts b/packages/react-native-babel-plugin/src/types/general.ts index 0d17ff8c2..e9869e5b0 100644 --- a/packages/react-native-babel-plugin/src/types/general.ts +++ b/packages/react-native-babel-plugin/src/types/general.ts @@ -44,6 +44,7 @@ export type PluginOptions = { }; // Internal option used by CLI - not meant for end users __internal_saveSvgMapToDisk?: boolean; + __internal_reactNativeSVG?: ReactNativeSVG; }; export type PluginPassState = Babel.PluginPass & { From f87aef68fe7badc4b35dbbaff254976fa2a5e602 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:11:52 +0200 Subject: [PATCH 2/5] Add cli flags --- .../src/cli/generate-sr-assets.ts | 233 ++++++++++++++-- .../react-native-babel-plugin/src/index.ts | 2 +- .../src/libraries/react-native-svg/index.ts | 6 +- .../test/generate-sr-assets.test.ts | 256 ++++++++++++++++++ 4 files changed, 470 insertions(+), 27 deletions(-) create mode 100644 packages/react-native-babel-plugin/test/generate-sr-assets.test.ts 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 3eb506bba..237d3516f 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 @@ -24,6 +24,110 @@ type SvgIndexEntry = { type SvgIndex = Record; +export type CliOptions = { + ignore: string[]; + verbose: boolean; + path: string | null; + followSymlinks: boolean; +}; + +export const DEFAULT_IGNORE_PATTERNS = [ + '**/node_modules/**', + '**/lib/**', + '**/dist/**', + '**/build/**', + '**/*.d.ts', + '**/*.test.*', + '**/*.spec.*', + '**/*.config.js', + '**/__tests__/**', + '**/__mocks__/**' +]; + +/** + * Parses command line arguments for the generate-sr-assets CLI. + * + * Supported arguments: + * --ignore Additional glob patterns to ignore (can be specified multiple times) + * --verbose, -v Enable verbose output for debugging + * --path, -p Path to the root directory to scan (defaults to current working directory) + * --followSymlinks Follow symbolic links during traversal (default: false) + * --help, -h Show help message + * + * @param args - Optional array of arguments (defaults to process.argv.slice(2)) + * @returns Parsed CLI options + */ +export function parseCliArgs(args?: string[]): CliOptions { + const argv = args ?? process.argv.slice(2); + const options: CliOptions = { + ignore: [], + verbose: false, + path: null, + followSymlinks: false + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else if (arg === '--ignore' || arg === '-i') { + const value = argv[++i]; + if (value && !value.startsWith('-')) { + options.ignore.push(value); + } else { + console.warn( + 'Warning: --ignore flag requires a pattern argument' + ); + i--; // Reprocess this arg if it's another flag + } + } else if (arg === '--verbose' || arg === '-v') { + options.verbose = true; + } else if (arg === '--path' || arg === '-p') { + const value = argv[++i]; + if (value && !value.startsWith('-')) { + options.path = value; + } else { + console.warn('Warning: --path flag requires a directory path'); + i--; // Reprocess this arg if it's another flag + } + } else if (arg === '--followSymlinks') { + options.followSymlinks = true; + } + } + + return options; +} + +/** + * Prints the help message for the CLI. + */ +function printHelp(): void { + console.info(` +Usage: npx datadog-generate-sr-assets [options] + +Pre-generate SVG assets for Datadog Session Replay. + +Options: + --ignore, -i Additional glob patterns to ignore during scanning. + Can be specified multiple times. + Example: --ignore "**/legacy/**" + --path, -p Path to the root directory to scan. + Defaults to the current working directory. + --verbose, -v Enable verbose output for debugging. + --followSymlinks Follow symbolic links during directory traversal. + Default: false (symlinks are ignored). + --help, -h Show this help message. + +Examples: + npx datadog-generate-sr-assets + npx datadog-generate-sr-assets --path ./src + npx datadog-generate-sr-assets --ignore "**/legacy/**" --verbose + npx datadog-generate-sr-assets -p ./src -i "**/old/**" -v +`); +} + /** * Merges all individual SVG files into assets.bin and creates an index in assets.json. * This function reads all .svg files from the assets directory and packs them into @@ -105,48 +209,97 @@ function mergeSvgAssets(assetsDir: string) { * references are available during the build process. * * Usage: - * npx @datadog/mobile-react-native-babel-plugin generate-sr-assets + * npx @datadog/mobile-react-native-babel-plugin generate-sr-assets [options] * or - * npx datadog-generate-sr-assets + * npx datadog-generate-sr-assets [options] + * + * Options: + * --ignore, -i pattern Additional glob patterns to ignore during scanning. + * Can be specified multiple times. + * Example: --ignore "**\/legacy\/**" --ignore "**\/vendor\/**" + * --verbose, -v Enable verbose output for debugging. + * --path, -p path Path to the root directory to scan. + * Defaults to the current working directory. + * Example: --path ./src + * --followSymlinks Follow symbolic links during directory traversal. + * Default: false (symlinks are ignored). */ function generateSessionReplayAssets() { - const rootDir = process.cwd(); + const cliOptions = parseCliArgs(); + const { verbose } = cliOptions; + + // Resolve the root directory from --path flag or default to cwd + const rootDir = cliOptions.path + ? path.resolve(process.cwd(), cliOptions.path) + : process.cwd(); + + // Validate the path exists + if (cliOptions.path && !fs.existsSync(rootDir)) { + console.error(`Error: Path does not exist: ${rootDir}`); + process.exit(1); + } + + if (cliOptions.path && !fs.statSync(rootDir).isDirectory()) { + console.error(`Error: Path is not a directory: ${rootDir}`); + process.exit(1); + } + const assetsPath = getAssetsPath(); + const startTime = Date.now(); + if (!assetsPath) { + if (verbose) { + console.info( + '[verbose] No assets path found. Session Replay module may not be installed.' + ); + } process.exit(0); } console.info(`Scanning for session replay assets in ${rootDir}...`); + if (verbose) { + console.info(`[verbose] Assets output path: ${assetsPath}`); + } + // Clear existing assets to ensure a fresh state clearAssetsDir(assetsPath); + // Merge default ignore patterns with user-provided ones + // Convert simple folder names to glob patterns (e.g., "legacy" → "**/legacy/**") + const userIgnorePatterns = cliOptions.ignore.map(pattern => { + // If it looks like a glob pattern, use as-is + if (pattern.includes('*') || pattern.includes('?')) { + return pattern; + } + // Otherwise, treat it as a folder name and convert to glob pattern + return `**/${pattern}/**`; + }); + const ignorePatterns = [...DEFAULT_IGNORE_PATTERNS, ...userIgnorePatterns]; + + if (verbose) { + console.info(`[verbose] Follow symlinks: ${cliOptions.followSymlinks}`); + console.info('[verbose] Ignore patterns:'); + ignorePatterns.forEach(pattern => console.info(` - ${pattern}`)); + } + const files = glob.sync(['**/*.{js,jsx,ts,tsx}'], { cwd: rootDir, absolute: true, - ignore: [ - '**/node_modules/**', - '**/lib/**', - '**/dist/**', - '**/build/**', - '**/*.d.ts', - '**/*.test.*', - '**/*.spec.*', - '**/*.config.js', - '**/__tests__/**', - '**/__mocks__/**' - ] + ignore: ignorePatterns, + followSymbolicLinks: cliOptions.followSymlinks }); + if (verbose) { + console.info(`[verbose] Found ${files.length} files to scan`); + } + let errorCount = 0; + let processedCount = 0; const errors: Array<{ file: string; error: string }> = []; - const reactNativeSVG = new ReactNativeSVG( - process.cwd(), - assetsPath, - true - ); + const reactNativeSVG = new ReactNativeSVG(rootDir, assetsPath, true); for (const file of files) { try { @@ -178,11 +331,19 @@ function generateSessionReplayAssets() { code: false, ast: false }); + + processedCount++; } catch (error) { errorCount++; const errorMessage = error instanceof Error ? error.message : String(error); errors.push({ file, error: errorMessage }); + + if (verbose) { + const relativePath = path.relative(rootDir, file); + console.warn(`[verbose] Error processing ${relativePath}:`); + console.warn(` ${errorMessage}`); + } } } @@ -193,6 +354,32 @@ function generateSessionReplayAssets() { // Merge all individual SVG files into assets.bin and assets.json mergeSvgAssets(assetsPath); + const duration = Date.now() - startTime; + + if (verbose) { + console.info('\n[verbose] Summary:'); + console.info(` Files scanned: ${files.length}`); + console.info(` Files processed successfully: ${processedCount}`); + console.info(` Files with errors: ${errorCount}`); + console.info(` Duration: ${duration}ms`); + + if (errors.length > 0 && errors.length <= 10) { + console.info('\n[verbose] Files with errors:'); + errors.forEach(({ file }) => { + const relativePath = path.relative(rootDir, file); + console.info(` - ${relativePath}`); + }); + } else if (errors.length > 10) { + console.info( + `\n[verbose] ${errors.length} files had errors (showing first 10):` + ); + errors.slice(0, 10).forEach(({ file }) => { + const relativePath = path.relative(rootDir, file); + console.info(` - ${relativePath}`); + }); + } + } + if (errorCount > 0) { console.info( 'Asset generation finished, but some files encountered errors.' @@ -202,5 +389,7 @@ function generateSessionReplayAssets() { console.info('Your assets are now ready to be used by Session Replay.'); } -// TODO: Add flag support [e.g., --verbose] (RUM-12186) -generateSessionReplayAssets(); +// Only run when executed directly (not when imported for testing) +if (require.main === module) { + generateSessionReplayAssets(); +} diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index 463b65f64..d9be0d57b 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -39,7 +39,7 @@ export default declare( } }; - let reactNativeSVG: ReactNativeSVG | undefined = undefined; + let reactNativeSVG: ReactNativeSVG | undefined; let assetsPath: string | null = null; 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 ef6301ff1..38b4dcd30 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 @@ -48,9 +48,7 @@ export class ReactNativeSVG { private rootDir: string, private assetsPath: string, private saveSvgMapToDisk: boolean = false - ) { - - } + ) {} setApiTypes(t: typeof Babel.types) { this.t = t; @@ -131,7 +129,7 @@ export class ReactNativeSVG { 'classProperties', 'dynamicImport' ] - }); + }); traverse(ast, { ImportDeclaration: path => { 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 new file mode 100644 index 000000000..966c5c360 --- /dev/null +++ b/packages/react-native-babel-plugin/test/generate-sr-assets.test.ts @@ -0,0 +1,256 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { + parseCliArgs, + DEFAULT_IGNORE_PATTERNS +} from '../src/cli/generate-sr-assets'; + +describe('generate-sr-assets CLI', () => { + describe('parseCliArgs', () => { + describe('default values', () => { + it('should return default options when no arguments are provided', () => { + const result = parseCliArgs([]); + + expect(result).toEqual({ + ignore: [], + verbose: false, + path: null, + followSymlinks: false + }); + }); + }); + + describe('--ignore flag', () => { + it('should parse single --ignore flag', () => { + const result = parseCliArgs(['--ignore', '**/legacy/**']); + + expect(result.ignore).toEqual(['**/legacy/**']); + }); + + it('should parse -i shorthand flag', () => { + const result = parseCliArgs(['-i', '**/vendor/**']); + + expect(result.ignore).toEqual(['**/vendor/**']); + }); + + it('should parse multiple --ignore flags', () => { + const result = parseCliArgs([ + '--ignore', + '**/legacy/**', + '--ignore', + '**/vendor/**', + '-i', + '**/old/**' + ]); + + expect(result.ignore).toEqual([ + '**/legacy/**', + '**/vendor/**', + '**/old/**' + ]); + }); + + it('should warn and skip when --ignore has no value', () => { + const warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(); + + const result = parseCliArgs(['--ignore']); + + expect(result.ignore).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith( + 'Warning: --ignore flag requires a pattern argument' + ); + + warnSpy.mockRestore(); + }); + + it('should warn and skip when --ignore is followed by another flag', () => { + const warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(); + + const result = parseCliArgs(['--ignore', '--verbose']); + + expect(result.ignore).toEqual([]); + expect(result.verbose).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + 'Warning: --ignore flag requires a pattern argument' + ); + + warnSpy.mockRestore(); + }); + }); + + describe('--verbose flag', () => { + it('should parse --verbose flag', () => { + const result = parseCliArgs(['--verbose']); + + expect(result.verbose).toBe(true); + }); + + it('should parse -v shorthand flag', () => { + const result = parseCliArgs(['-v']); + + expect(result.verbose).toBe(true); + }); + }); + + describe('--path flag', () => { + it('should parse --path flag', () => { + const result = parseCliArgs(['--path', './src']); + + expect(result.path).toBe('./src'); + }); + + it('should parse -p shorthand flag', () => { + const result = parseCliArgs(['-p', '/absolute/path']); + + expect(result.path).toBe('/absolute/path'); + }); + + it('should warn and skip when --path has no value', () => { + const warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(); + + const result = parseCliArgs(['--path']); + + expect(result.path).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + 'Warning: --path flag requires a directory path' + ); + + warnSpy.mockRestore(); + }); + + it('should warn and skip when --path is followed by another flag', () => { + const warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(); + + const result = parseCliArgs(['--path', '-v']); + + expect(result.path).toBeNull(); + expect(result.verbose).toBe(true); + expect(warnSpy).toHaveBeenCalledWith( + 'Warning: --path flag requires a directory path' + ); + + warnSpy.mockRestore(); + }); + }); + + describe('--followSymlinks flag', () => { + it('should parse --followSymlinks flag', () => { + const result = parseCliArgs(['--followSymlinks']); + + expect(result.followSymlinks).toBe(true); + }); + + it('should default to false when not provided', () => { + const result = parseCliArgs([]); + + expect(result.followSymlinks).toBe(false); + }); + }); + + describe('combined flags', () => { + it('should parse all flags together', () => { + const result = parseCliArgs([ + '--path', + './src', + '--ignore', + '**/legacy/**', + '--verbose', + '--followSymlinks', + '-i', + '**/vendor/**' + ]); + + expect(result).toEqual({ + path: './src', + ignore: ['**/legacy/**', '**/vendor/**'], + verbose: true, + followSymlinks: true + }); + }); + + it('should parse shorthand flags together', () => { + const result = parseCliArgs([ + '-p', + './app', + '-i', + '**/test/**', + '-v' + ]); + + expect(result).toEqual({ + path: './app', + ignore: ['**/test/**'], + verbose: true, + followSymlinks: false + }); + }); + }); + + describe('unknown flags', () => { + it('should ignore unknown flags', () => { + const result = parseCliArgs([ + '--unknown', + '--verbose', + '--another-unknown', + 'value' + ]); + + expect(result.verbose).toBe(true); + expect(result.ignore).toEqual([]); + expect(result.path).toBeNull(); + }); + }); + }); + + describe('DEFAULT_IGNORE_PATTERNS', () => { + it('should include node_modules', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/node_modules/**'); + }); + + it('should include lib directory', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/lib/**'); + }); + + it('should include dist directory', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/dist/**'); + }); + + it('should include build directory', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/build/**'); + }); + + it('should include TypeScript declaration files', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/*.d.ts'); + }); + + it('should include test files', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/*.test.*'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/*.spec.*'); + }); + + it('should include config files', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/*.config.js'); + }); + + it('should include __tests__ and __mocks__ directories', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/__tests__/**'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/__mocks__/**'); + }); + + it('should have expected number of patterns', () => { + expect(DEFAULT_IGNORE_PATTERNS).toHaveLength(10); + }); + }); +}); From c1c12a158384d903f4876fa9074c0db7ef2903c7 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:12:55 +0200 Subject: [PATCH 3/5] Add some additional folders to ignore --- .../src/cli/generate-sr-assets.ts | 58 +++++++++---- .../test/generate-sr-assets.test.ts | 83 +++++++++++++++++-- 2 files changed, 120 insertions(+), 21 deletions(-) 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 237d3516f..2b5125eb4 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 @@ -31,17 +31,52 @@ export type CliOptions = { followSymlinks: boolean; }; +/** + * Converts a user-provided ignore pattern to a glob pattern. + * Simple folder names are converted to glob patterns (e.g., "legacy" becomes "** /legacy/** "). + * Patterns that already contain glob characters (* or ?) are used as-is. + * + * @param pattern - The user-provided pattern + * @returns A glob-compatible pattern + */ +export function normalizeIgnorePattern(pattern: string): string { + // If it looks like a glob pattern, use as-is + if (pattern.includes('*') || pattern.includes('?')) { + return pattern; + } + // Otherwise, treat it as a folder name and convert to glob pattern + return `**/${pattern}/**`; +} + export const DEFAULT_IGNORE_PATTERNS = [ + // Dependencies and build output '**/node_modules/**', '**/lib/**', '**/dist/**', '**/build/**', + '**/vendor/**', + // Native code (not React components) + '**/ios/**', + '**/android/**', + '**/Pods/**', + // Expo + '**/.expo/**', + // Cache and metadata + '**/.git/**', + '**/.cache/**', + '**/.yarn/**', + // TypeScript declarations '**/*.d.ts', + // Test files and directories '**/*.test.*', '**/*.spec.*', - '**/*.config.js', '**/__tests__/**', - '**/__mocks__/**' + '**/__mocks__/**', + '**/__snapshots__/**', + '**/coverage/**', + // Config files + '**/*.config.js', + '**/*.config.ts' ]; /** @@ -110,9 +145,10 @@ Usage: npx datadog-generate-sr-assets [options] Pre-generate SVG assets for Datadog Session Replay. Options: - --ignore, -i Additional glob patterns to ignore during scanning. + --ignore, -i Additional patterns to ignore during scanning. + Can be a folder name or a glob pattern. + Folder names are auto-converted to glob patterns. Can be specified multiple times. - Example: --ignore "**/legacy/**" --path, -p Path to the root directory to scan. Defaults to the current working directory. --verbose, -v Enable verbose output for debugging. @@ -123,8 +159,9 @@ Options: Examples: npx datadog-generate-sr-assets npx datadog-generate-sr-assets --path ./src - npx datadog-generate-sr-assets --ignore "**/legacy/**" --verbose - npx datadog-generate-sr-assets -p ./src -i "**/old/**" -v + npx datadog-generate-sr-assets --ignore legacy --ignore vendor + npx datadog-generate-sr-assets --ignore "**/custom-pattern/**" --verbose + npx datadog-generate-sr-assets -p ./src -i old-code -v `); } @@ -268,14 +305,7 @@ function generateSessionReplayAssets() { // Merge default ignore patterns with user-provided ones // Convert simple folder names to glob patterns (e.g., "legacy" → "**/legacy/**") - const userIgnorePatterns = cliOptions.ignore.map(pattern => { - // If it looks like a glob pattern, use as-is - if (pattern.includes('*') || pattern.includes('?')) { - return pattern; - } - // Otherwise, treat it as a folder name and convert to glob pattern - return `**/${pattern}/**`; - }); + const userIgnorePatterns = cliOptions.ignore.map(normalizeIgnorePattern); const ignorePatterns = [...DEFAULT_IGNORE_PATTERNS, ...userIgnorePatterns]; if (verbose) { 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 966c5c360..e034c8aa1 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 @@ -6,7 +6,8 @@ import { parseCliArgs, - DEFAULT_IGNORE_PATTERNS + DEFAULT_IGNORE_PATTERNS, + normalizeIgnorePattern } from '../src/cli/generate-sr-assets'; describe('generate-sr-assets CLI', () => { @@ -231,26 +232,94 @@ describe('generate-sr-assets CLI', () => { expect(DEFAULT_IGNORE_PATTERNS).toContain('**/build/**'); }); + it('should include vendor directory', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/vendor/**'); + }); + + it('should include native code directories', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/ios/**'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/android/**'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/Pods/**'); + }); + + it('should include Expo directory', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/.expo/**'); + }); + + it('should include cache and metadata directories', () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/.git/**'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/.cache/**'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/.yarn/**'); + }); + it('should include TypeScript declaration files', () => { expect(DEFAULT_IGNORE_PATTERNS).toContain('**/*.d.ts'); }); - it('should include test files', () => { + it('should include test files and directories', () => { expect(DEFAULT_IGNORE_PATTERNS).toContain('**/*.test.*'); expect(DEFAULT_IGNORE_PATTERNS).toContain('**/*.spec.*'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/__tests__/**'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/__mocks__/**'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/__snapshots__/**'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/coverage/**'); }); it('should include config files', () => { expect(DEFAULT_IGNORE_PATTERNS).toContain('**/*.config.js'); + expect(DEFAULT_IGNORE_PATTERNS).toContain('**/*.config.ts'); }); - it('should include __tests__ and __mocks__ directories', () => { - expect(DEFAULT_IGNORE_PATTERNS).toContain('**/__tests__/**'); - expect(DEFAULT_IGNORE_PATTERNS).toContain('**/__mocks__/**'); + it('should have expected number of patterns', () => { + expect(DEFAULT_IGNORE_PATTERNS).toHaveLength(21); }); + }); - it('should have expected number of patterns', () => { - expect(DEFAULT_IGNORE_PATTERNS).toHaveLength(10); + describe('normalizeIgnorePattern', () => { + describe('folder names (no glob characters)', () => { + it('should convert simple folder name to glob pattern', () => { + expect(normalizeIgnorePattern('legacy')).toBe('**/legacy/**'); + }); + + it('should convert folder name with hyphen to glob pattern', () => { + expect(normalizeIgnorePattern('old-code')).toBe( + '**/old-code/**' + ); + }); + + it('should convert folder name with underscore to glob pattern', () => { + expect(normalizeIgnorePattern('temp_files')).toBe( + '**/temp_files/**' + ); + }); + + it('should convert nested path to glob pattern', () => { + expect(normalizeIgnorePattern('src/legacy')).toBe( + '**/src/legacy/**' + ); + }); + }); + + describe('glob patterns (with * or ?)', () => { + it('should keep pattern with ** as-is', () => { + expect(normalizeIgnorePattern('**/custom/**')).toBe( + '**/custom/**' + ); + }); + + it('should keep pattern with single * as-is', () => { + expect(normalizeIgnorePattern('*.backup')).toBe('*.backup'); + }); + + it('should keep pattern with ? as-is', () => { + expect(normalizeIgnorePattern('file?.txt')).toBe('file?.txt'); + }); + + it('should keep complex glob pattern as-is', () => { + expect(normalizeIgnorePattern('**/src/**/*.test.ts')).toBe( + '**/src/**/*.test.ts' + ); + }); }); }); }); From 71ca6709fd53a3145963165840e86f7e2541e8a0 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:01:11 +0200 Subject: [PATCH 4/5] Gracefully continue on failure to parse assets --- .../react-native-svg/handlers/RNSvgHandler.ts | 5 +- .../src/libraries/react-native-svg/index.ts | 70 +++++++++++-------- .../test/react-native-svg.test.ts | 16 ++++- 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts index d65c3cfdb..659fec676 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts @@ -106,9 +106,10 @@ export class RNSvgHandler implements SvgHandler { if (isJSXIdentifierOpen) { openingNode.name = convertAttributeCasing(openingNode.name); if (!svgElements.has(openingNode.name)) { - throw new Error( - `RNSvgHandler[transformElement]: Failed to transform element: "${openingNode.name}" is not supported` + console.warn( + `RNSvgHandler[transformElement]: Skipping unsupported element: "${openingNode.name}"` ); + return; // Skip unsupported elements instead of crashing } } 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 38b4dcd30..325f9f2d9 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 @@ -253,41 +253,49 @@ export class ReactNativeSVG { const id = uuidv4(); - const optimized = output.startsWith('http') - ? output - : optimize(output, { - multipass: true, - plugins: ['preset-default'] - }).data; - - const hash = createHash('md5') - .update(optimized, 'utf8') - .digest('hex'); - - const wrapper = this.wrapElementForSessionReplay( - this.t, - path, - id, - hash, - dimensions - ); - path.replaceWith(wrapper); + try { + const optimized = output.startsWith('http') + ? output + : optimize(output, { + multipass: true, + plugins: ['preset-default'] + }).data; + + const hash = createHash('md5') + .update(optimized, 'utf8') + .digest('hex'); + + const wrapper = this.wrapElementForSessionReplay( + this.t, + path, + id, + hash, + dimensions + ); + + path.replaceWith(wrapper); - path.node.extra = { - __wrappedForSR: true - }; + path.node.extra = { + __wrappedForSR: true + }; - this.svgMap[id] = { - file: optimized, - ...dimensions - }; + this.svgMap[id] = { + file: optimized, + ...dimensions + }; - writeAssetToDisk(this.assetsPath, id, hash, optimized); + writeAssetToDisk(this.assetsPath, id, hash, optimized); - return { original: output, optimized }; - } catch (err) { - console.warn(err); - return { original: null, optimized: null }; + return { original: output, optimized }; + } catch (err) { + console.warn(err); + return { original: null, optimized: null }; + } + } catch (svgoError) { + console.warn( + 'ReactNativeSVG[processItem]: Skipping SVG with dynamic expressions (cannot be optimized)' + ); + return; } } 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 e3070a95f..c57542a82 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 @@ -729,9 +729,21 @@ describe('React Native SVG Processing - RNSvgHandler', () => { }); describe('Error Handling', () => { - it('should throw error or warn for unsupported element names', () => { + it('should warn for unsupported element names but still include them in output', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const input = ''; - expect(() => transformSvg(input)).toThrow(); + const output = transformSvg(input); + + // Unsupported elements are converted to lowercase and included + expect(output).toMatchInlineSnapshot( + `""` + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Skipping unsupported element') + ); + + warnSpy.mockRestore(); }); it('should handle malformed transform array gracefully', () => { From 506c58646c9d5d5c52e90679387e03d0783dff02 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:41:10 +0200 Subject: [PATCH 5/5] Make ignore handling more robust --- .../src/cli/generate-sr-assets.ts | 56 ++++++++-- .../test/generate-sr-assets.test.ts | 100 +++++++++++++++--- 2 files changed, 131 insertions(+), 25 deletions(-) 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 2b5125eb4..c6cffe00e 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 @@ -33,18 +33,53 @@ export type CliOptions = { /** * Converts a user-provided ignore pattern to a glob pattern. - * Simple folder names are converted to glob patterns (e.g., "legacy" becomes "** /legacy/** "). - * Patterns that already contain glob characters (* or ?) are used as-is. + * + * Handling rules: + * - Glob patterns (containing * ? [ ] { } ( )) are left unchanged + * - Absolute paths are appended with /** if not already present + * - Relative paths (./ or ../ or containing path separator) are resolved to absolute and appended with /** + * - Simple folder names (no slashes, no globs) are wrapped with ** /name/** for wildcard matching * * @param pattern - The user-provided pattern + * @param cwd - The base directory for resolving relative paths (required) * @returns A glob-compatible pattern */ -export function normalizeIgnorePattern(pattern: string): string { - // If it looks like a glob pattern, use as-is - if (pattern.includes('*') || pattern.includes('?')) { +export function normalizeIgnorePattern(pattern: string, cwd: string): string { + // Detect glob patterns - leave unchanged + const isGlob = /[*?[\]{}()]/.test(pattern); + if (isGlob) { return pattern; } - // Otherwise, treat it as a folder name and convert to glob pattern + + const isAbsolute = path.isAbsolute(pattern); + const isRelative = + pattern.startsWith('./') || + pattern.startsWith('../') || + pattern.startsWith('.\\') || + pattern.startsWith('..\\') || + pattern.includes(path.sep) || + pattern.includes('/'); // Also check for forward slash on all platforms + + // Absolute paths - append /** if needed + if (isAbsolute) { + if (pattern.endsWith('/**') || pattern.endsWith(`${path.sep}**`)) { + return pattern; + } + return path.join(pattern, '**'); + } + + // Relative paths - resolve to absolute and append /** + if (isRelative) { + const resolved = path.resolve(cwd, pattern); + + if (resolved.endsWith('/**') || resolved.endsWith(`${path.sep}**`)) { + return resolved; + } + + return path.join(resolved, '**'); + } + + // Simple folder names (no slashes, no glob characters) - wildcard match return `**/${pattern}/**`; } @@ -304,8 +339,13 @@ function generateSessionReplayAssets() { clearAssetsDir(assetsPath); // Merge default ignore patterns with user-provided ones - // Convert simple folder names to glob patterns (e.g., "legacy" → "**/legacy/**") - const userIgnorePatterns = cliOptions.ignore.map(normalizeIgnorePattern); + // Convert folder names/paths to glob patterns based on type: + // - Simple names: "legacy" → "**/legacy/**" + // - Relative paths: "./android" → "/abs/path/android/**" + // - Absolute paths: "/home/dev/android" → "/home/dev/android/**" + const userIgnorePatterns = cliOptions.ignore.map(pattern => + normalizeIgnorePattern(pattern, rootDir) + ); const ignorePatterns = [...DEFAULT_IGNORE_PATTERNS, ...userIgnorePatterns]; if (verbose) { 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 e034c8aa1..973ff0579 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 @@ -276,50 +276,116 @@ describe('generate-sr-assets CLI', () => { }); describe('normalizeIgnorePattern', () => { - describe('folder names (no glob characters)', () => { - it('should convert simple folder name to glob pattern', () => { - expect(normalizeIgnorePattern('legacy')).toBe('**/legacy/**'); + const mockCwd = '/home/user/project'; + + describe('simple folder names (no slashes, no glob)', () => { + it('should convert simple folder name to wildcard glob pattern', () => { + expect(normalizeIgnorePattern('legacy', mockCwd)).toBe( + '**/legacy/**' + ); }); - it('should convert folder name with hyphen to glob pattern', () => { - expect(normalizeIgnorePattern('old-code')).toBe( + it('should convert folder name with hyphen to wildcard glob pattern', () => { + expect(normalizeIgnorePattern('old-code', mockCwd)).toBe( '**/old-code/**' ); }); - it('should convert folder name with underscore to glob pattern', () => { - expect(normalizeIgnorePattern('temp_files')).toBe( + it('should convert folder name with underscore to wildcard glob pattern', () => { + expect(normalizeIgnorePattern('temp_files', mockCwd)).toBe( '**/temp_files/**' ); }); + }); + + describe('relative paths (with ./ or ../)', () => { + it('should resolve relative path starting with ./', () => { + const result = normalizeIgnorePattern('./android', mockCwd); + expect(result).toBe('/home/user/project/android/**'); + }); + + it('should resolve relative path starting with ../', () => { + const result = normalizeIgnorePattern('../other', mockCwd); + expect(result).toBe('/home/user/other/**'); + }); - it('should convert nested path to glob pattern', () => { - expect(normalizeIgnorePattern('src/legacy')).toBe( - '**/src/legacy/**' + it('should resolve nested relative path', () => { + const result = normalizeIgnorePattern('./src/legacy', mockCwd); + expect(result).toBe('/home/user/project/src/legacy/**'); + }); + }); + + describe('paths containing slashes (treated as relative)', () => { + it('should resolve path with forward slash as relative', () => { + const result = normalizeIgnorePattern('packages/app', mockCwd); + expect(result).toBe('/home/user/project/packages/app/**'); + }); + + it('should resolve nested path as relative', () => { + const result = normalizeIgnorePattern( + 'src/components/legacy', + mockCwd + ); + expect(result).toBe( + '/home/user/project/src/components/legacy/**' ); }); }); - describe('glob patterns (with * or ?)', () => { + describe('absolute paths', () => { + it('should append /** to absolute path', () => { + const result = normalizeIgnorePattern( + '/home/dev/app/android', + mockCwd + ); + expect(result).toBe('/home/dev/app/android/**'); + }); + + it('should keep absolute path with /** unchanged', () => { + const result = normalizeIgnorePattern( + '/home/dev/app/android/**', + mockCwd + ); + expect(result).toBe('/home/dev/app/android/**'); + }); + }); + + describe('glob patterns (with * ? [ ] { } ( ))', () => { it('should keep pattern with ** as-is', () => { - expect(normalizeIgnorePattern('**/custom/**')).toBe( + expect(normalizeIgnorePattern('**/custom/**', mockCwd)).toBe( '**/custom/**' ); }); it('should keep pattern with single * as-is', () => { - expect(normalizeIgnorePattern('*.backup')).toBe('*.backup'); + expect(normalizeIgnorePattern('*.backup', mockCwd)).toBe( + '*.backup' + ); }); it('should keep pattern with ? as-is', () => { - expect(normalizeIgnorePattern('file?.txt')).toBe('file?.txt'); + expect(normalizeIgnorePattern('file?.txt', mockCwd)).toBe( + 'file?.txt' + ); }); - it('should keep complex glob pattern as-is', () => { - expect(normalizeIgnorePattern('**/src/**/*.test.ts')).toBe( - '**/src/**/*.test.ts' + it('should keep pattern with brackets as-is', () => { + expect(normalizeIgnorePattern('[abc].txt', mockCwd)).toBe( + '[abc].txt' + ); + }); + + it('should keep pattern with braces as-is', () => { + expect(normalizeIgnorePattern('*.{js,ts}', mockCwd)).toBe( + '*.{js,ts}' ); }); + + it('should keep complex glob pattern as-is', () => { + expect( + normalizeIgnorePattern('**/src/**/*.test.ts', mockCwd) + ).toBe('**/src/**/*.test.ts'); + }); }); }); });