diff --git a/packages/core/tests/plugins/sourcemapTool.test.ts b/packages/core/tests/plugins/sourcemapTool.test.ts index 1b468a43..6d69df89 100644 --- a/packages/core/tests/plugins/sourcemapTool.test.ts +++ b/packages/core/tests/plugins/sourcemapTool.test.ts @@ -489,4 +489,347 @@ describe('sourcemapTool', () => { expect(sourceMap).toBe('console.log("test");'); }); }); + + describe('asset no map - assetsWithoutSourceMap tracking', () => { + it('should add JS asset to assetsWithoutSourceMap when no sourcemap exists', async () => { + const plugin = createMockPluginInstance(); + const compilation = { + compiler: { rspack: {} }, + options: { + output: { + filename: '[name].[contenthash].js', + }, + }, + getAssets: () => [ + { + name: 'bundle.abc123.js', + source: { + source: () => 'console.log("test");\n', + name: 'bundle.abc123.js', + sourceAndMap: () => ({ + source: 'console.log("test");\n', + map: null, // No inline source map + }), + }, + info: { + related: {}, // No source map reference + }, + }, + ], + } as any; + + await handleAfterEmitAssets(compilation, plugin); + + // The asset should be marked as having no sourcemap + expect(plugin.assetsWithoutSourceMap).toBeDefined(); + expect(plugin.assetsWithoutSourceMap.has('bundle.abc123.js')).toBe(true); + expect(plugin.assetsWithoutSourceMap.size).toBe(1); + // No sourcemap data should be collected + expect(plugin.sourceMapSets.size).toBe(0); + }); + + it('should add CSS asset to assetsWithoutSourceMap when no sourcemap exists', async () => { + const plugin = createMockPluginInstance(); + const compilation = { + compiler: { rspack: {} }, + options: { + output: {}, + }, + getAssets: () => [ + { + name: 'styles.css', + source: { + source: () => '.header { color: red; }\n', + name: 'styles.css', + sourceAndMap: () => ({ + source: '.header { color: red; }\n', + map: null, + }), + }, + info: { + related: {}, + }, + }, + ], + } as any; + + await handleAfterEmitAssets(compilation, plugin); + + expect(plugin.assetsWithoutSourceMap).toBeDefined(); + expect(plugin.assetsWithoutSourceMap.has('styles.css')).toBe(true); + expect(plugin.assetsWithoutSourceMap.size).toBe(1); + expect(plugin.sourceMapSets.size).toBe(0); + }); + + it('should NOT add asset to assetsWithoutSourceMap when inline sourcemap exists', async () => { + const plugin = createMockPluginInstance(); + const compilation = createMockCompilation(); + + await handleAfterEmitAssets(compilation, plugin); + + // Asset has inline sourcemap, should not be in assetsWithoutSourceMap + expect(plugin.assetsWithoutSourceMap).toBeDefined(); + expect(plugin.assetsWithoutSourceMap.has('main.js')).toBe(false); + expect(plugin.assetsWithoutSourceMap.size).toBe(0); + // Sourcemap data should be collected + expect(plugin.sourceMapSets.size).toBe(1); + }); + + it('should NOT add asset to assetsWithoutSourceMap when external sourcemap file exists', async () => { + const plugin = createMockPluginInstance(); + const compilation = { + compiler: { rspack: {} }, + options: { + output: {}, + }, + getAssets: () => [ + { + name: 'app.js', + source: { + source: () => 'console.log("app");\n', + name: 'app.js', + sourceAndMap: () => ({ + source: 'console.log("app");\n', + map: null, // No inline map + }), + }, + info: { + related: { + sourceMap: 'app.js.map', // But has external map reference + }, + }, + }, + { + name: 'app.js.map', + source: { + source: () => JSON.stringify(mockJsMap), + name: 'app.js.map', + }, + info: {}, + }, + ], + } as any; + + await handleAfterEmitAssets(compilation, plugin); + + // Asset has external sourcemap file, should not be in assetsWithoutSourceMap + expect(plugin.assetsWithoutSourceMap).toBeDefined(); + expect(plugin.assetsWithoutSourceMap.has('app.js')).toBe(false); + expect(plugin.assetsWithoutSourceMap.size).toBe(0); + // Sourcemap data should be collected from external file + expect(plugin.sourceMapSets.size).toBe(1); + }); + + it('should track multiple assets without sourcemap', async () => { + const plugin = createMockPluginInstance(); + const compilation = { + compiler: { rspack: {} }, + options: { + output: {}, + }, + getAssets: () => [ + { + name: 'main.js', + source: { + source: () => 'console.log("main");\n', + name: 'main.js', + sourceAndMap: () => ({ + source: 'console.log("main");\n', + map: null, + }), + }, + info: { + related: {}, + }, + }, + { + name: 'vendor.js', + source: { + source: () => 'console.log("vendor");\n', + name: 'vendor.js', + sourceAndMap: () => ({ + source: 'console.log("vendor");\n', + map: null, + }), + }, + info: { + related: {}, + }, + }, + { + name: 'styles.css', + source: { + source: () => '.app { color: blue; }\n', + name: 'styles.css', + sourceAndMap: () => ({ + source: '.app { color: blue; }\n', + map: null, + }), + }, + info: { + related: {}, + }, + }, + ], + } as any; + + await handleAfterEmitAssets(compilation, plugin); + + expect(plugin.assetsWithoutSourceMap).toBeDefined(); + expect(plugin.assetsWithoutSourceMap.size).toBe(3); + expect(plugin.assetsWithoutSourceMap.has('main.js')).toBe(true); + expect(plugin.assetsWithoutSourceMap.has('vendor.js')).toBe(true); + expect(plugin.assetsWithoutSourceMap.has('styles.css')).toBe(true); + expect(plugin.sourceMapSets.size).toBe(0); + }); + + it('should NOT add non-JS/CSS assets to assetsWithoutSourceMap', async () => { + const plugin = createMockPluginInstance(); + const compilation = { + compiler: { rspack: {} }, + options: { + output: {}, + }, + getAssets: () => [ + { + name: 'image.png', + source: { + source: () => Buffer.from('fake-image-data'), + name: 'image.png', + sourceAndMap: () => ({ + source: Buffer.from('fake-image-data'), + map: null, + }), + }, + info: { + related: {}, + }, + }, + { + name: 'data.json', + source: { + source: () => '{"key": "value"}', + name: 'data.json', + sourceAndMap: () => ({ + source: '{"key": "value"}', + map: null, + }), + }, + info: { + related: {}, + }, + }, + ], + } as any; + + await handleAfterEmitAssets(compilation, plugin); + + // Non-JS/CSS assets should not be tracked + expect(plugin.assetsWithoutSourceMap).toBeDefined(); + expect(plugin.assetsWithoutSourceMap.size).toBe(0); + expect(plugin.sourceMapSets.size).toBe(0); + }); + + it('should handle mixed scenario: assets with and without sourcemap', async () => { + const plugin = createMockPluginInstance(); + const compilation = { + compiler: { rspack: {} }, + options: { + output: {}, + }, + getAssets: () => [ + // Asset WITH inline sourcemap + { + name: 'with-map.js', + source: { + source: () => 'console.log("with map");\n', + name: 'with-map.js', + sourceAndMap: () => ({ + source: 'console.log("with map");\n', + map: mockJsMap, + }), + }, + info: {}, + }, + // Asset WITHOUT sourcemap + { + name: 'no-map.js', + source: { + source: () => 'console.log("no map");\n', + name: 'no-map.js', + sourceAndMap: () => ({ + source: 'console.log("no map");\n', + map: null, + }), + }, + info: { + related: {}, + }, + }, + ], + } as any; + + await handleAfterEmitAssets(compilation, plugin); + + // Only the asset without sourcemap should be tracked + expect(plugin.assetsWithoutSourceMap).toBeDefined(); + expect(plugin.assetsWithoutSourceMap.size).toBe(1); + expect(plugin.assetsWithoutSourceMap.has('no-map.js')).toBe(true); + expect(plugin.assetsWithoutSourceMap.has('with-map.js')).toBe(false); + // Sourcemap data should only be collected from asset with map + expect(plugin.sourceMapSets.size).toBe(1); + }); + + it('should clear assetsWithoutSourceMap on subsequent calls', async () => { + const plugin = createMockPluginInstance(); + const compilation1 = { + compiler: { rspack: {} }, + options: { output: {} }, + getAssets: () => [ + { + name: 'bundle1.js', + source: { + source: () => 'console.log("1");\n', + name: 'bundle1.js', + sourceAndMap: () => ({ + source: 'console.log("1");\n', + map: null, + }), + }, + info: { related: {} }, + }, + ], + } as any; + + await handleAfterEmitAssets(compilation1, plugin); + expect(plugin.assetsWithoutSourceMap.size).toBe(1); + expect(plugin.assetsWithoutSourceMap.has('bundle1.js')).toBe(true); + + // Second call with different assets + const compilation2 = { + compiler: { rspack: {} }, + options: { output: {} }, + getAssets: () => [ + { + name: 'bundle2.js', + source: { + source: () => 'console.log("2");\n', + name: 'bundle2.js', + sourceAndMap: () => ({ + source: 'console.log("2");\n', + map: null, + }), + }, + info: { related: {} }, + }, + ], + } as any; + + await handleAfterEmitAssets(compilation2, plugin); + // Should clear previous entries and only have new ones + expect(plugin.assetsWithoutSourceMap.size).toBe(1); + expect(plugin.assetsWithoutSourceMap.has('bundle1.js')).toBe(false); + expect(plugin.assetsWithoutSourceMap.has('bundle2.js')).toBe(true); + }); + }); }); diff --git a/packages/graph/tests/transform/chunks/assetsModules.test.ts b/packages/graph/tests/transform/chunks/assetsModules.test.ts new file mode 100644 index 00000000..bd97f08f --- /dev/null +++ b/packages/graph/tests/transform/chunks/assetsModules.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeEach } from '@rstest/core'; +import { getAssetsModulesData } from '../../../src/transform/chunks/assetsModules'; +import { SDK } from '@rsdoctor/types'; + +describe('assetsModules - asset no map integration', () => { + let mockModuleGraph: SDK.ModuleGraphInstance; + let mockChunkGraph: SDK.ChunkGraphInstance; + + beforeEach(() => { + // Create mock module graph + mockModuleGraph = { + getModuleByFile: (filePath: string) => { + // Return mock modules + return [ + { + setSize: () => {}, + setSource: () => {}, + }, + ]; + }, + getModules: () => [], + getModuleByWebpackId: (id: string) => { + return { + setSize: () => {}, + setSource: () => {}, + }; + }, + } as any; + + // Create mock chunk graph + mockChunkGraph = { + getAssets: () => [], + } as any; + }); + + it('should use AST parsing when assetsWithoutSourceMap is provided', async () => { + const assetsWithoutSourceMap = new Set(['bundle.js', 'vendor.js']); + const sourceMapSets = new Map([ + ['src/index.js', 'console.log("with map");'], + ]); + + let parseBundleCalled = false; + // Mock parseBundle function + const parseBundle = (assetFile: string) => { + parseBundleCalled = true; + return { + modules: { + 'module-1': { + size: 100, + sizeConvert: '100 B', + content: 'parsed content', + }, + }, + }; + }; + + // Set up mock assets + mockChunkGraph.getAssets = () => + [{ path: 'bundle.js' }, { path: 'vendor.js' }] as any; + + await getAssetsModulesData( + mockModuleGraph, + mockChunkGraph, + '/fake/bundle/dir', + { parseBundle }, + sourceMapSets, + assetsWithoutSourceMap, + ); + + // parseBundle should be called for assets without sourcemap + expect(parseBundleCalled).toBe(true); + }); + + it('should use AST parsing as fallback when no sourcemap exists at all', async () => { + const sourceMapSets = new Map(); // Empty - no sourcemaps + let parseBundleCalled = false; + const parseBundle = () => { + parseBundleCalled = true; + return { modules: {} }; + }; + + mockChunkGraph.getAssets = () => [{ path: 'bundle.js' }] as any; + + await getAssetsModulesData( + mockModuleGraph, + mockChunkGraph, + '/fake/bundle/dir', + { parseBundle }, + sourceMapSets, + undefined, // No assetsWithoutSourceMap + ); + + // parseBundle should be called as fallback + expect(parseBundleCalled).toBe(true); + }); + + it('should NOT use AST parsing when all assets have sourcemap', async () => { + const sourceMapSets = new Map([ + ['src/index.js', 'console.log("code1");'], + ['src/app.js', 'console.log("code2");'], + ]); + const assetsWithoutSourceMap = new Set(); // Empty - all assets have sourcemap + let parseBundleCalled = false; + const parseBundle = () => { + parseBundleCalled = true; + return { modules: {} }; + }; + + mockChunkGraph.getAssets = () => [{ path: 'bundle.js' }] as any; + + await getAssetsModulesData( + mockModuleGraph, + mockChunkGraph, + '/fake/bundle/dir', + { parseBundle }, + sourceMapSets, + assetsWithoutSourceMap, + ); + + // parseBundle should NOT be called when all assets have sourcemap + expect(parseBundleCalled).toBe(false); + }); + + it('should only parse assets that are in assetsWithoutSourceMap', async () => { + const assetsWithoutSourceMap = new Set(['no-map.js']); // Only this one has no map + const sourceMapSets = new Map([ + ['src/index.js', 'console.log("with map");'], + ]); + let parseCount = 0; + const parsedAssets: string[] = []; + + const parseBundle = (assetFile: string) => { + parseCount++; + parsedAssets.push(assetFile); + return { modules: {} }; + }; + + mockChunkGraph.getAssets = () => + [ + { path: 'with-map.js' }, // This one should be skipped + { path: 'no-map.js' }, // This one should be parsed + ] as any; + + await getAssetsModulesData( + mockModuleGraph, + mockChunkGraph, + '/fake/bundle/dir', + { parseBundle }, + sourceMapSets, + assetsWithoutSourceMap, + ); + + // parseBundle should only be called once for no-map.js + expect(parseCount).toBe(1); + expect(parsedAssets[0]).toBe('/fake/bundle/dir/no-map.js'); + }); + + it('should process sourcemap data for modules with sourcemap', async () => { + const sourceMapSets = new Map([ + ['src/index.js', 'const x = 1;\nconsole.log(x);'], + ['src/utils.js', 'export const util = () => {};'], + ]); + + const moduleCalls: Array<{ file: string; size?: any; source?: any }> = []; + + mockModuleGraph.getModuleByFile = (filePath: string) => { + return [ + { + setSize: (data: any) => { + moduleCalls.push({ file: filePath, size: data }); + }, + setSource: (data: any) => { + moduleCalls.push({ file: filePath, source: data }); + }, + }, + ]; + }; + + await getAssetsModulesData( + mockModuleGraph, + mockChunkGraph, + '/fake/bundle/dir', + {}, + sourceMapSets, + new Set(), // Empty - all have sourcemap + ); + + // Verify setSize and setSource were called + const indexCalls = moduleCalls.filter((c) => c.file === 'src/index.js'); + const utilsCalls = moduleCalls.filter((c) => c.file === 'src/utils.js'); + + expect(indexCalls.length).toBeGreaterThan(0); + expect(utilsCalls.length).toBeGreaterThan(0); + + // Check that size was set + const indexSizeCall = indexCalls.find((c) => c.size); + expect(indexSizeCall?.size.parsedSize).toBe( + 'const x = 1;\nconsole.log(x);'.length, + ); + + // Check that source was set + const indexSourceCall = indexCalls.find((c) => c.source); + expect(indexSourceCall?.source.parsedSource).toBe( + 'const x = 1;\nconsole.log(x);', + ); + }); + + it('should handle mixed scenario: some assets with sourcemap, some without', async () => { + const sourceMapSets = new Map([ + ['src/withMap.js', 'console.log("has sourcemap");'], + ]); + const assetsWithoutSourceMap = new Set(['noMap.js']); + + let parseBundleCalled = false; + const parseBundle = () => { + parseBundleCalled = true; + return { + modules: { + 'noMap-module': { + size: 50, + sizeConvert: '50 B', + content: 'parsed from AST', + }, + }, + }; + }; + + const sourceCalls: string[] = []; + + mockModuleGraph.getModuleByFile = (filePath: string) => { + if (filePath === 'src/withMap.js') { + return [ + { + setSize: () => {}, + setSource: (data: any) => { + sourceCalls.push(data.parsedSource); + }, + }, + ]; + } + return []; + }; + + mockChunkGraph.getAssets = () => + [{ path: 'withMap.js' }, { path: 'noMap.js' }] as any; + + await getAssetsModulesData( + mockModuleGraph, + mockChunkGraph, + '/fake/bundle/dir', + { parseBundle }, + sourceMapSets, + assetsWithoutSourceMap, + ); + + // Module with sourcemap should use sourcemap data + expect(sourceCalls).toContain('console.log("has sourcemap");'); + + // Module without sourcemap should use AST parsing + expect(parseBundleCalled).toBe(true); + }); + + it('should handle empty sourcemap content gracefully', async () => { + const sourceMapSets = new Map([ + ['src/empty.js', ''], // Empty content + ]); + + let sizeData: any = null; + let sourceData: any = null; + + mockModuleGraph.getModuleByFile = () => [ + { + setSize: (data: any) => { + sizeData = data; + }, + setSource: (data: any) => { + sourceData = data; + }, + }, + ]; + + await getAssetsModulesData( + mockModuleGraph, + mockChunkGraph, + '/fake/bundle/dir', + {}, + sourceMapSets, + new Set(), + ); + + // Should still call setSize with 0 length + expect(sizeData).not.toBeNull(); + expect(sizeData.parsedSize).toBe(0); + expect(sourceData).not.toBeNull(); + expect(sourceData.parsedSource).toBe(''); + }); +});