diff --git a/examples/next-js/next.config.js b/examples/next-js/next.config.js index 29674541..3d9411f6 100644 --- a/examples/next-js/next.config.js +++ b/examples/next-js/next.config.js @@ -3,7 +3,9 @@ const { codecovWebpackPlugin } = require("@codecov/webpack-plugin"); /** @type {import('next').NextConfig} */ const nextConfig = { webpack: (config, options) => { - config.plugins.push(codecovWebpackPlugin({ enableBundleAnalysis: true })); + config.plugins.push( + codecovWebpackPlugin({ enableBundleAnalysis: true, dryRun: true }), + ); return config; }, diff --git a/examples/rollup/rollup.config.ts b/examples/rollup/rollup.config.ts index 4057a581..7b4676bd 100644 --- a/examples/rollup/rollup.config.ts +++ b/examples/rollup/rollup.config.ts @@ -19,6 +19,6 @@ export default defineConfig({ resolve(), // tells Rollup how to find date-fns in node_modules commonjs(), // converts date-fns to ES modules production && terser(), // minify, but only in production - codecovRollupPlugin({ enableBundleAnalysis: true }), + codecovRollupPlugin({ enableBundleAnalysis: true, dryRun: true }), ], }); diff --git a/examples/vite/vite.config.ts b/examples/vite/vite.config.ts index 3fb1724d..9aa704ee 100644 --- a/examples/vite/vite.config.ts +++ b/examples/vite/vite.config.ts @@ -4,5 +4,20 @@ import { codecovVitePlugin } from "@codecov/vite-plugin"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react(), codecovVitePlugin({ enableBundleAnalysis: true })], + build: { + rollupOptions: { + output: { + assetFileNames: "[name].[hash].js", + chunkFileNames: "[name]-[hash].js", + }, + }, + }, + plugins: [ + react(), + codecovVitePlugin({ + enableBundleAnalysis: true, + dryRun: true, + globalUploadToken: "super-cool-token", + }), + ], }); diff --git a/examples/webpack/webpack.config.js b/examples/webpack/webpack.config.js index 72d10f27..c5abe3d8 100644 --- a/examples/webpack/webpack.config.js +++ b/examples/webpack/webpack.config.js @@ -11,6 +11,7 @@ module.exports = { plugins: [ codecovWebpackPlugin({ enableBundleAnalysis: true, + dryRun: true, }), ], }; diff --git a/packages/bundler-plugin-core/src/index.ts b/packages/bundler-plugin-core/src/index.ts index 56415ab8..b69ac3ab 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -11,8 +11,8 @@ import { type UploadOverrides, type Output, } from "./types.ts"; - import { red } from "./utils/logging.ts"; +import { normalizePath } from "./utils/normalizePath.ts"; import { bundleAnalysisPluginFactory } from "./bundle-analysis/bundleAnalysisPluginFactory.ts"; const NODE_VERSION_RANGE = ">=18.18.0"; @@ -48,8 +48,6 @@ function codecovUnpluginFactory({ }); } -export { red, codecovUnpluginFactory }; - export type { BundleAnalysisUploadPlugin, Asset, @@ -60,3 +58,5 @@ export type { UploadOverrides, Output, }; + +export { normalizePath, codecovUnpluginFactory, red }; diff --git a/packages/bundler-plugin-core/src/types.ts b/packages/bundler-plugin-core/src/types.ts index e3e005e1..2cf6506c 100644 --- a/packages/bundler-plugin-core/src/types.ts +++ b/packages/bundler-plugin-core/src/types.ts @@ -8,6 +8,7 @@ export interface Dependency { export interface Asset { name: string; size: number; + normalized: string; } export interface Chunk { @@ -15,14 +16,13 @@ export interface Chunk { uniqueId: string; entry: boolean; initial: boolean; - files: string[]; names: string[]; + files: string[]; } export interface Module { name: string; size?: number; - chunks: (string | number)[]; chunkUniqueIds: string[]; } diff --git a/packages/bundler-plugin-core/src/utils/__tests__/normalizePath.test.ts b/packages/bundler-plugin-core/src/utils/__tests__/normalizePath.test.ts new file mode 100644 index 00000000..3b077df5 --- /dev/null +++ b/packages/bundler-plugin-core/src/utils/__tests__/normalizePath.test.ts @@ -0,0 +1,68 @@ +import { normalizePath } from "../normalizePath"; + +interface Test { + name: string; + input: { + path: string; + format: string; + }; + expected: string; +} + +const tests: Test[] = [ + { + name: "should replace '[hash]' with '*'", + input: { + path: "test.123.chunk.js", + format: "[name].[hash].chunk.js", + }, + expected: "test.*.chunk.js", + }, + { + name: "should replace '[contenthash]' with '*'", + input: { + path: "test.123.chunk.js", + format: "[name].[contenthash].chunk.js", + }, + expected: "test.*.chunk.js", + }, + { + name: "should replace '[fullhash]' with '*'", + input: { + path: "test.123.chunk.js", + format: "[name].[fullhash].chunk.js", + }, + expected: "test.*.chunk.js", + }, + { + name: "should replace '[chunkhash]' with '*'", + input: { + path: "test.123.chunk.js", + format: "[name].[chunkhash].chunk.js", + }, + expected: "test.*.chunk.js", + }, + { + name: "should replace multiple hash format occurrences '*'", + input: { + path: "test.123.456.chunk.js", + format: "[name].[hash].[chunkhash].chunk.js", + }, + expected: "test.*.*.chunk.js", + }, + { + name: "should brute force wildcard if no hash format is found", + input: { + path: "test.12345678.chunk.js", + format: "[name].chunk.js", + }, + expected: "test.*.chunk.js", + }, +]; + +describe("normalizePath", () => { + it.each(tests)("$name", ({ input, expected }) => { + const expectation = normalizePath(input.path, input.format); + expect(expectation).toEqual(expected); + }); +}); diff --git a/packages/bundler-plugin-core/src/utils/normalizePath.ts b/packages/bundler-plugin-core/src/utils/normalizePath.ts new file mode 100644 index 00000000..cdf11e8a --- /dev/null +++ b/packages/bundler-plugin-core/src/utils/normalizePath.ts @@ -0,0 +1,60 @@ +const HASH_REGEX = /[a-f0-9]{8,}/i; +const POTENTIAL_HASHES = [ + "[hash]", + "[contenthash]", + "[fullhash]", + "[chunkhash]", +]; + +const escapeRegex = (string: string): string => + string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d"); + +interface HashMatch { + hashString: string; + hashIndex: number; +} + +export const normalizePath = (path: string, format: string): string => { + // grab all potential hashes in the format string + const matches: HashMatch[] = []; + for (const hash of POTENTIAL_HASHES) { + const index = format.indexOf(hash); + if (index !== -1) { + matches.push({ hashString: hash, hashIndex: index }); + } + } + + let normalizedPath = path; + // loop through all the matches and replace the hash with a wildcard + for (const match of matches) { + // grab the leading delimiter and create a regex group for it + const leadingDelimiter = format.at(match.hashIndex - 1) ?? ""; + const leadingRegex = `(?${escapeRegex( + leadingDelimiter, + )})`; + + // grab the ending delimiter and create a regex group for it + const endingDelimiter = + format.at(match.hashIndex + match.hashString.length) ?? ""; + const endingRegex = `(?${escapeRegex(endingDelimiter)})`; + + // create a regex that will match the hash + const regexString = `(${leadingRegex}(?[0-9a-f]+)${endingRegex})`; + const HASH_REPLACE_REGEX = new RegExp(regexString, "i"); + + // replace the hash with a wildcard and the delimiters + normalizedPath = normalizedPath.replace( + HASH_REPLACE_REGEX, + "$*$", + ); + } + + // if the path is the same as the normalized path, and the path contains a + // hash, then we can assume that something went wrong and we should just + // replace/brute force the hash with a wildcard + if (normalizedPath === path && HASH_REGEX.test(normalizedPath)) { + return normalizedPath.replace(HASH_REGEX, "*"); + } + + return normalizedPath; +}; diff --git a/packages/rollup-plugin/src/rollup-bundle-analysis/rollupBundleAnalysisPlugin.ts b/packages/rollup-plugin/src/rollup-bundle-analysis/rollupBundleAnalysisPlugin.ts index 4e9a351b..43ada534 100644 --- a/packages/rollup-plugin/src/rollup-bundle-analysis/rollupBundleAnalysisPlugin.ts +++ b/packages/rollup-plugin/src/rollup-bundle-analysis/rollupBundleAnalysisPlugin.ts @@ -5,6 +5,7 @@ import { type Module, type BundleAnalysisUploadPlugin, red, + normalizePath, } from "@codecov/bundler-plugin-core"; const PLUGIN_NAME = "codecov-rollup-bundle-analysis-plugin"; @@ -31,17 +32,25 @@ export const rollupBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ output.bundleName = `${userOptions.bundleName}-${options.name}`; } + const cwd = process.cwd(); + const assets: Asset[] = []; + const chunks: Chunk[] = []; + const moduleByFileName = new Map(); + const items = Object.values(bundle); const customOptions = { moduleOriginalSize: false, ...options, }; - const assets: Asset[] = []; - const chunks: Chunk[] = []; - const moduleByFileName = new Map(); - const items = Object.values(bundle); + let assetFormatString = ""; + if (typeof customOptions.assetFileNames === "string") { + assetFormatString = customOptions.assetFileNames; + } - const cwd = process.cwd(); + let chunkFormatString = ""; + if (typeof customOptions.chunkFileNames === "string") { + chunkFormatString = customOptions.chunkFileNames; + } let counter = 0; for (const item of items) { @@ -53,6 +62,7 @@ export const rollupBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ assets.push({ name: fileName, size: size, + normalized: normalizePath(fileName, assetFormatString), }); } else { const fileName = item?.fileName ?? ""; @@ -61,6 +71,7 @@ export const rollupBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ assets.push({ name: fileName, size: size, + normalized: normalizePath(fileName, assetFormatString), }); } } @@ -75,6 +86,7 @@ export const rollupBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ assets.push({ name: fileName, size: size, + normalized: normalizePath(fileName, chunkFormatString), }); chunks.push({ @@ -82,7 +94,7 @@ export const rollupBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ uniqueId: uniqueId, entry: item?.isEntry, initial: item?.isDynamicEntry, - files: [item?.fileName], + files: [fileName], names: [item?.name], }); @@ -103,17 +115,15 @@ export const rollupBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ // if the modules exists append chunk ids to the grabbed module // else create a new module and create a new entry in the map if (moduleEntry) { - moduleEntry.chunks.push(chunkId); moduleEntry.chunkUniqueIds.push(uniqueId); } else { const size = customOptions.moduleOriginalSize ? moduleInfo.originalLength : moduleInfo.renderedLength; - const module = { + const module: Module = { name: relativeModulePathWithPrefix, size: size, - chunks: [chunkId], chunkUniqueIds: [uniqueId], }; diff --git a/packages/vite-plugin/src/vite-bundle-analysis/viteBundleAnalysisPlugin.ts b/packages/vite-plugin/src/vite-bundle-analysis/viteBundleAnalysisPlugin.ts index f5ac4d6a..d15656f4 100644 --- a/packages/vite-plugin/src/vite-bundle-analysis/viteBundleAnalysisPlugin.ts +++ b/packages/vite-plugin/src/vite-bundle-analysis/viteBundleAnalysisPlugin.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { + normalizePath, type Asset, type Chunk, type Module, @@ -32,17 +33,25 @@ export const viteBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ output.bundleName = `${userOptions.bundleName}-${options.name}`; } + const cwd = process.cwd(); + const assets: Asset[] = []; + const chunks: Chunk[] = []; + const moduleByFileName = new Map(); + const items = Object.values(bundle); const customOptions = { moduleOriginalSize: false, ...options, }; - const assets: Asset[] = []; - const chunks: Chunk[] = []; - const moduleByFileName = new Map(); - const items = Object.values(bundle); + let assetFormatString = ""; + if (typeof customOptions.assetFileNames === "string") { + assetFormatString = customOptions.assetFileNames; + } - const cwd = process.cwd(); + let chunkFormatString = ""; + if (typeof customOptions.chunkFileNames === "string") { + chunkFormatString = customOptions.chunkFileNames; + } let counter = 0; for (const item of items) { @@ -54,6 +63,7 @@ export const viteBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ assets.push({ name: fileName, size: size, + normalized: normalizePath(fileName, assetFormatString), }); } else { const fileName = item?.fileName ?? ""; @@ -62,6 +72,7 @@ export const viteBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ assets.push({ name: fileName, size: size, + normalized: normalizePath(fileName, assetFormatString), }); } } @@ -76,6 +87,7 @@ export const viteBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ assets.push({ name: fileName, size: size, + normalized: normalizePath(fileName, chunkFormatString), }); chunks.push({ @@ -83,7 +95,7 @@ export const viteBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ uniqueId: uniqueId, entry: item?.isEntry, initial: item?.isDynamicEntry, - files: [item?.fileName], + files: [fileName], names: [item?.name], }); @@ -105,17 +117,15 @@ export const viteBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ // if the modules exists append chunk ids to the grabbed module // else create a new module and create a new entry in the map if (moduleEntry) { - moduleEntry.chunks.push(chunkId); moduleEntry.chunkUniqueIds.push(uniqueId); } else { const size = customOptions.moduleOriginalSize ? moduleInfo.originalLength : moduleInfo.renderedLength; - const module = { + const module: Module = { name: relativeModulePathWithPrefix, size: size, - chunks: [chunkId], chunkUniqueIds: [uniqueId], }; diff --git a/packages/webpack-plugin/src/webpack-bundle-analysis/__tests__/findFileFormat.test.ts b/packages/webpack-plugin/src/webpack-bundle-analysis/__tests__/findFileFormat.test.ts new file mode 100644 index 00000000..61687b88 --- /dev/null +++ b/packages/webpack-plugin/src/webpack-bundle-analysis/__tests__/findFileFormat.test.ts @@ -0,0 +1,93 @@ +import { findFilenameFormat } from "../findFileFormat"; + +describe("findFilenameFormat ", () => { + describe("when filename matches the format", () => { + it("returns the filename format", () => { + const result = findFilenameFormat({ + assetName: "test-123.js", + filename: "[name]-[hash].js", + assetModuleFilename: "", + chunkFilename: "", + cssFilename: "", + cssChunkFilename: "", + }); + + expect(result).toEqual("[name]-[hash].js"); + }); + }); + + describe("when assetModuleFilename matches the format", () => { + it("returns the assetModuleFilename format", () => { + const result = findFilenameFormat({ + assetName: "test-123.js", + filename: "", + assetModuleFilename: "[name]-[hash].js", + chunkFilename: "", + cssFilename: "", + cssChunkFilename: "", + }); + + expect(result).toEqual("[name]-[hash].js"); + }); + }); + + describe("when chunkFilename matches the format", () => { + it("returns the chunkFilename format", () => { + const result = findFilenameFormat({ + assetName: "test-123.js", + filename: "", + assetModuleFilename: "", + chunkFilename: "[name]-[hash].js", + cssFilename: "", + cssChunkFilename: "", + }); + + expect(result).toEqual("[name]-[hash].js"); + }); + }); + + describe("when cssFilename matches the format", () => { + it("returns the cssFilename format", () => { + const result = findFilenameFormat({ + assetName: "test-123.css", + filename: "", + assetModuleFilename: "", + chunkFilename: "", + cssChunkFilename: "[name]-[hash].css", + cssFilename: "", + }); + + expect(result).toEqual("[name]-[hash].css"); + }); + }); + + describe("when cssChunkFilename matches the format", () => { + it("returns the cssChunkFilename format", () => { + const result = findFilenameFormat({ + assetName: "test-123.css", + filename: "", + assetModuleFilename: "", + chunkFilename: "", + cssFilename: "[name]-[hash].css", + cssChunkFilename: "", + }); + + expect(result).toEqual("[name]-[hash].css"); + }); + }); + + describe("when no format matches", () => { + it("returns an empty string", () => { + const result = findFilenameFormat({ + assetName: "test", + filename: "", + assetModuleFilename: "", + chunkFilename: "", + cssFilename: "", + cssChunkFilename: "", + }); + + expect(result).toEqual(""); + }); + }); +}); diff --git a/packages/webpack-plugin/src/webpack-bundle-analysis/findFileFormat.ts b/packages/webpack-plugin/src/webpack-bundle-analysis/findFileFormat.ts new file mode 100644 index 00000000..41a162bb --- /dev/null +++ b/packages/webpack-plugin/src/webpack-bundle-analysis/findFileFormat.ts @@ -0,0 +1,60 @@ +const STRIP_CHARS_REGEX = /(\w|\[|]|\/)/g; + +interface FindFilenameFormatArgs { + assetName: string; + filename: string; + assetModuleFilename: string; + chunkFilename: string; + cssFilename: string; + cssChunkFilename: string; +} + +export const findFilenameFormat = ({ + assetName, + filename, + assetModuleFilename, + chunkFilename, + cssFilename, + cssChunkFilename, +}: FindFilenameFormatArgs) => { + const currAssetFormat = assetName.replaceAll(STRIP_CHARS_REGEX, ""); + + if ( + filename !== "" && + currAssetFormat.includes(filename.replaceAll(STRIP_CHARS_REGEX, "")) + ) { + return filename; + } + + if ( + chunkFilename !== "" && + currAssetFormat.includes(chunkFilename.replaceAll(STRIP_CHARS_REGEX, "")) + ) { + return chunkFilename; + } + + if ( + cssFilename !== "" && + currAssetFormat.includes(cssFilename.replaceAll(STRIP_CHARS_REGEX, "")) + ) { + return cssFilename; + } + + if ( + cssChunkFilename !== "" && + currAssetFormat.includes(cssChunkFilename.replaceAll(STRIP_CHARS_REGEX, "")) + ) { + return cssChunkFilename; + } + + if ( + assetModuleFilename !== "" && + currAssetFormat.includes( + assetModuleFilename.replaceAll(STRIP_CHARS_REGEX, ""), + ) + ) { + return assetModuleFilename; + } + + return ""; +}; diff --git a/packages/webpack-plugin/src/webpack-bundle-analysis/webpackBundleAnalysisPlugin.ts b/packages/webpack-plugin/src/webpack-bundle-analysis/webpackBundleAnalysisPlugin.ts index 7ce1c4c1..7545907d 100644 --- a/packages/webpack-plugin/src/webpack-bundle-analysis/webpackBundleAnalysisPlugin.ts +++ b/packages/webpack-plugin/src/webpack-bundle-analysis/webpackBundleAnalysisPlugin.ts @@ -1,8 +1,10 @@ import { - type BundleAnalysisUploadPlugin, red, + normalizePath, + type BundleAnalysisUploadPlugin, } from "@codecov/bundler-plugin-core"; import * as webpack4or5 from "webpack"; +import { findFilenameFormat } from "./findFileFormat"; const PLUGIN_NAME = "codecov-webpack-bundle-analysis-plugin"; @@ -48,13 +50,39 @@ export const webpackBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ version: webpack4or5.version, }; + const outputOptions = compilation.outputOptions; const { assets, chunks, modules } = compilationStats; if (assets) { output.assets = assets.map((asset) => { + const format = findFilenameFormat({ + assetName: asset.name, + filename: + typeof outputOptions.filename === "string" + ? outputOptions.filename + : "", + assetModuleFilename: + typeof outputOptions.assetModuleFilename === "string" + ? outputOptions.assetModuleFilename + : "", + chunkFilename: + typeof outputOptions.chunkFilename === "string" + ? outputOptions.chunkFilename + : "", + cssFilename: + typeof outputOptions.cssFilename === "string" + ? outputOptions.cssFilename + : "", + cssChunkFilename: + typeof outputOptions.chunkFilename === "string" + ? outputOptions.chunkFilename + : "", + }); + return { name: asset.name, size: asset.size, + normalized: normalizePath(asset.name, format), }; }); } @@ -96,7 +124,6 @@ export const webpackBundleAnalysisPlugin: BundleAnalysisUploadPlugin = ({ return { name: module.name ?? "", size: module.size ?? 0, - chunks: module.chunks ?? [], chunkUniqueIds: chunkUniqueIds, }; }); diff --git a/tsconfig.json b/tsconfig.json index a407a750..4402d95a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { /* LANGUAGE COMPILATION OPTIONS */ "target": "ESNext", - "lib": ["DOM", "DOM.Iterable", "ES2020"], + "lib": ["DOM", "DOM.Iterable", "ES2021"], "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true,