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..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 @@ -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; @@ -23,6 +24,182 @@ type SvgIndexEntry = { type SvgIndex = Record; +export type CliOptions = { + ignore: string[]; + verbose: boolean; + path: string | null; + followSymlinks: boolean; +}; + +/** + * Converts a user-provided ignore pattern to a glob pattern. + * + * 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, cwd: string): string { + // Detect glob patterns - leave unchanged + const isGlob = /[*?[\]{}()]/.test(pattern); + if (isGlob) { + return 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}/**`; +} + +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.*', + '**/__tests__/**', + '**/__mocks__/**', + '**/__snapshots__/**', + '**/coverage/**', + // Config files + '**/*.config.js', + '**/*.config.ts' +]; + +/** + * 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 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. + --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 --ignore vendor + npx datadog-generate-sr-assets --ignore "**/custom-pattern/**" --verbose + npx datadog-generate-sr-assets -p ./src -i old-code -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 @@ -104,43 +281,96 @@ 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 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) { + 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(rootDir, assetsPath, true); + for (const file of files) { try { const code = fs.readFileSync(file, 'utf8'); @@ -155,7 +385,8 @@ function generateSessionReplayAssets() { sessionReplay: { svgTracking: true }, - __internal_saveSvgMapToDisk: true + __internal_saveSvgMapToDisk: true, + __internal_reactNativeSVG: reactNativeSVG } ] ], @@ -170,11 +401,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}`); + } } } @@ -185,6 +424,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.' @@ -194,5 +459,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 8379c600f..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 | null = null; + let reactNativeSVG: ReactNativeSVG | 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/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 1ef61ff28..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 @@ -42,13 +42,16 @@ 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 +73,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 @@ -126,6 +133,9 @@ export class ReactNativeSVG { traverse(ast, { ImportDeclaration: path => { + if (!this.t) { + return; + } const source = path.node.source.value; if (!source.endsWith('.svg')) { return; @@ -145,6 +155,9 @@ export class ReactNativeSVG { } }, ExportNamedDeclaration: path => { + if (!this.t) { + return; + } const source = path.node.source?.value; if (!source?.endsWith('.svg')) { return; @@ -213,6 +226,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 } = {}; @@ -236,41 +253,49 @@ export class ReactNativeSVG { const id = uuidv4(); - const optimized = output.startsWith('http') - ? output - : optimize(output, { - multipass: true, - plugins: ['preset-default'] - }).data; + 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 + ); - const hash = createHash('md5') - .update(optimized, 'utf8') - .digest('hex'); + path.replaceWith(wrapper); - 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/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 & { 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..973ff0579 --- /dev/null +++ b/packages/react-native-babel-plugin/test/generate-sr-assets.test.ts @@ -0,0 +1,391 @@ +/* + * 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, + normalizeIgnorePattern +} 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 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 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 have expected number of patterns', () => { + expect(DEFAULT_IGNORE_PATTERNS).toHaveLength(21); + }); + }); + + describe('normalizeIgnorePattern', () => { + 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 wildcard glob pattern', () => { + expect(normalizeIgnorePattern('old-code', mockCwd)).toBe( + '**/old-code/**' + ); + }); + + 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 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('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/**', mockCwd)).toBe( + '**/custom/**' + ); + }); + + it('should keep pattern with single * as-is', () => { + expect(normalizeIgnorePattern('*.backup', mockCwd)).toBe( + '*.backup' + ); + }); + + it('should keep pattern with ? as-is', () => { + expect(normalizeIgnorePattern('file?.txt', mockCwd)).toBe( + 'file?.txt' + ); + }); + + 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'); + }); + }); + }); +}); 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', () => {