diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 356ee31f3..335320914 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -127,7 +127,15 @@ export type BundlerReport = GlobalData['bundler'] & { rawConfig?: any; }; -export type InjectedValue = string | (() => Promise); +export type ChunkInfo = { + sourceOrHash: string; + fileName: string; + isEntry: boolean; +}; + +// Static string, lazy async loader (e.g. file fetch), or per-chunk code generator. +export type InjectedValue = string | (() => Promise) | ((sourceOrHash?: string) => string); + export enum InjectPosition { BEFORE, MIDDLE, diff --git a/packages/plugins/apps/src/identifier.test.ts b/packages/plugins/apps/src/identifier.test.ts index c01f9917b..9286d7481 100644 --- a/packages/plugins/apps/src/identifier.test.ts +++ b/packages/plugins/apps/src/identifier.test.ts @@ -95,7 +95,7 @@ describe('Apps Plugin - identifier helpers', () => { describe('buildIdentifier', () => { test('Should hash the combination of repository and name when both exist', () => { const result = buildIdentifier('https://github.com/org/repo', 'my-app'); - // The identifier should be a 32-character MD5 hash + // The identifier should be a 32-character hex string (SHA-256 truncated to 128 bits) expect(result).toMatch(/^[a-f0-9]{32}$/); // Verify it's consistent expect(buildIdentifier('https://github.com/org/repo', 'my-app')).toBe(result); diff --git a/packages/plugins/apps/src/identifier.ts b/packages/plugins/apps/src/identifier.ts index 5fb90d66b..9b3a8308e 100644 --- a/packages/plugins/apps/src/identifier.ts +++ b/packages/plugins/apps/src/identifier.ts @@ -76,9 +76,8 @@ export const buildIdentifier = (repository?: string, name?: string): string | un } const plainIdentifier = `${repository}:${name}`; - // Use MD5 hash (128 bits, 32 hex characters) for a compact identifier - // MD5 is sufficient for non-cryptographic purposes like creating unique identifiers - return createHash('md5').update(plainIdentifier).digest('hex'); + // SHA-256 truncated to 128 bits (32 hex characters) for a compact identifier. + return createHash('sha256').update(plainIdentifier).digest('hex').slice(0, 32); }; export const resolveIdentifier = ( diff --git a/packages/plugins/error-tracking/src/index.ts b/packages/plugins/error-tracking/src/index.ts index 12c51f3d7..1a7e34353 100644 --- a/packages/plugins/error-tracking/src/index.ts +++ b/packages/plugins/error-tracking/src/index.ts @@ -14,7 +14,6 @@ import { validateOptions } from './validate'; export { CONFIG_KEY, PLUGIN_NAME } from './constants'; export type types = { - // Add the types you'd like to expose here. ErrorTrackingOptions: ErrorTrackingOptions; }; @@ -54,7 +53,7 @@ export const getPlugins: GetPlugins = ({ options, context }) => { totalTime.end(); }; - return [ + const plugins: ReturnType = [ { name: PLUGIN_NAME, enforce: 'post', @@ -82,4 +81,6 @@ export const getPlugins: GetPlugins = ({ options, context }) => { }, }, ]; + + return plugins; }; diff --git a/packages/plugins/error-tracking/src/types.ts b/packages/plugins/error-tracking/src/types.ts index 87e8f5e73..35b8be6fb 100644 --- a/packages/plugins/error-tracking/src/types.ts +++ b/packages/plugins/error-tracking/src/types.ts @@ -19,10 +19,12 @@ export type SourcemapsOptionsWithDefaults = Required; export type ErrorTrackingOptions = { enable?: boolean; + debugId?: boolean; sourcemaps?: SourcemapsOptions; }; export type ErrorTrackingOptionsWithDefaults = { + debugId?: boolean; sourcemaps?: SourcemapsOptionsWithDefaults; }; diff --git a/packages/plugins/injection/package.json b/packages/plugins/injection/package.json index 7f3ac3946..7edee65db 100644 --- a/packages/plugins/injection/package.json +++ b/packages/plugins/injection/package.json @@ -21,7 +21,8 @@ "dependencies": { "@dd/core": "workspace:*", "@dd/internal-build-report-plugin": "workspace:*", - "chalk": "2.3.1" + "chalk": "2.3.1", + "magic-string": "0.30.21" }, "devDependencies": { "typescript": "5.4.3" diff --git a/packages/plugins/injection/src/esbuild.ts b/packages/plugins/injection/src/esbuild.ts index 8c59b38a9..8260d2333 100644 --- a/packages/plugins/injection/src/esbuild.ts +++ b/packages/plugins/injection/src/esbuild.ts @@ -15,9 +15,11 @@ import path from 'path'; import { PLUGIN_NAME } from './constants'; import { getContentToInject, + hasChunkInjection, isNodeSystemError, isFileSupported, warnUnsupportedFile, + hasBeforeAfterInjection, } from './helpers'; import type { ContentsToInject } from './types'; @@ -77,9 +79,7 @@ export const getEsbuildPlugin = ( namespace: PLUGIN_NAME, }, async () => { - const content = getContentToInject(contentsToInject, { - position: InjectPosition.MIDDLE, - }); + const content = getContentToInject(contentsToInject, InjectPosition.MIDDLE); return { // We can't use an empty string otherwise esbuild will crash. @@ -98,28 +98,7 @@ export const getEsbuildPlugin = ( return; } - const bannerForEntries = getContentToInject(contentsToInject, { - position: InjectPosition.BEFORE, - }); - const footerForEntries = getContentToInject(contentsToInject, { - position: InjectPosition.AFTER, - }); - const bannerForAllChunks = getContentToInject(contentsToInject, { - position: InjectPosition.BEFORE, - onAllChunks: true, - }); - const footerForAllChunks = getContentToInject(contentsToInject, { - position: InjectPosition.AFTER, - onAllChunks: true, - }); - - if ( - !bannerForEntries && - !footerForEntries && - !bannerForAllChunks && - !footerForAllChunks - ) { - // Nothing to inject. + if (!hasBeforeAfterInjection(contentsToInject)) { return; } @@ -128,15 +107,11 @@ export const getEsbuildPlugin = ( // Process all output files for (const [p, o] of Object.entries(result.metafile.outputs)) { // Determine if this is an entry point - const isEntry = - o.entryPoint && entries.some((e) => e.resolved.endsWith(o.entryPoint!)); - - // Get the appropriate banner and footer - const banner = isEntry ? bannerForEntries : bannerForAllChunks; - const footer = isEntry ? footerForEntries : footerForAllChunks; + const isEntry = Boolean( + o.entryPoint && entries.some((e) => e.resolved.endsWith(o.entryPoint!)), + ); - // Skip if nothing to inject for this chunk type - if (!banner && !footer) { + if (!isEntry && !hasChunkInjection(contentsToInject)) { continue; } @@ -153,15 +128,43 @@ export const getEsbuildPlugin = ( proms.push( (async () => { try { - const source = await fsp.readFile(absolutePath, 'utf-8'); - const data = await esbuild.transform(source, { + const mapPath = `${absolutePath}.map`; + const [sourceOrHash, hasSourcemap] = await Promise.all([ + fsp.readFile(absolutePath, 'utf-8'), + fsp + .access(mapPath) + .then(() => true) + .catch(() => false), + ]); + const fileName = path.basename(absolutePath); + // Resolve static and per-chunk content in one pass. + const banner = getContentToInject( + contentsToInject, + InjectPosition.BEFORE, + { sourceOrHash, fileName, isEntry }, + ); + const footer = getContentToInject( + contentsToInject, + InjectPosition.AFTER, + { sourceOrHash, fileName, isEntry }, + ); + + if (!banner && !footer) { + return; + } + + const data = await esbuild.transform(sourceOrHash, { loader: 'default', banner, footer, + sourcemap: hasSourcemap ? 'external' : undefined, + sourcefile: fileName, }); - // FIXME: Handle sourcemaps. - await fsp.writeFile(absolutePath, data.code); + await Promise.all([ + fsp.writeFile(absolutePath, data.code), + hasSourcemap && data.map ? fsp.writeFile(mapPath, data.map) : null, + ]); } catch (e) { if (isNodeSystemError(e) && e.code === 'ENOENT') { // When we are using sub-builds, the entry file of sub-builds may not exist diff --git a/packages/plugins/injection/src/helpers.test.ts b/packages/plugins/injection/src/helpers.test.ts index 7d96ca53f..45433e8e8 100644 --- a/packages/plugins/injection/src/helpers.test.ts +++ b/packages/plugins/injection/src/helpers.test.ts @@ -4,11 +4,10 @@ import { InjectPosition, type ToInjectItem } from '@dd/core/types'; import { - processInjections, - processItem, - processLocalFile, + prepareInjections, processDistantFile, - getInjectedValue, + processLocalFile, + resolveWithFallback, } from '@dd/internal-injection-plugin/helpers'; import { addFixtureFiles, mockLogger } from '@dd/tests/_jest/helpers/mocks'; import nock from 'nock'; @@ -26,19 +25,26 @@ const localFileContent = 'local file content'; const distantFileContent = 'distant file content'; const codeContent = 'code content'; -const code: ToInjectItem = { type: 'code', value: codeContent }; -const existingFile: ToInjectItem = { type: 'file', value: 'fixtures/local-file.js' }; +const code: ToInjectItem = { type: 'code', value: codeContent, position: InjectPosition.BEFORE }; +const existingFile: ToInjectItem = { + type: 'file', + value: 'fixtures/local-file.js', + position: InjectPosition.BEFORE, +}; const nonExistingFile: ToInjectItem = { type: 'file', value: 'fixtures/non-existing-file.js', + position: InjectPosition.BEFORE, }; const existingDistantFile: ToInjectItem = { type: 'file', value: 'https://example.com/distant-file.js', + position: InjectPosition.BEFORE, }; const nonExistingDistantFile: ToInjectItem = { type: 'file', value: 'https://example.com/non-existing-distant-file.js', + position: InjectPosition.BEFORE, }; describe('Injection Plugin Helpers', () => { @@ -52,55 +58,37 @@ describe('Injection Plugin Helpers', () => { // Add some fixtures. addFixtureFiles( { - [await getInjectedValue(existingFile)]: localFileContent, + [existingFile.value as string]: localFileContent, }, process.cwd(), ); }); - describe('processInjections', () => { + describe('prepareInjections', () => { test('Should process injections without throwing.', async () => { - const items: Map = new Map([ - ['code', code], - ['existingFile', existingFile], - ['nonExistingFile', nonExistingFile], - ['existingDistantFile', existingDistantFile], - ['nonExistingDistantFile', nonExistingDistantFile], - ]); + const items: ToInjectItem[] = [ + code, + existingFile, + nonExistingFile, + existingDistantFile, + nonExistingDistantFile, + ]; - const results = await processInjections(items, mockLogger); - expect(Array.from(results.entries())).toEqual([ - [ - 'code', - { - position: InjectPosition.BEFORE, - value: codeContent, - injectIntoAllChunks: false, - }, - ], - [ - 'existingFile', - { - position: InjectPosition.BEFORE, - value: localFileContent, - injectIntoAllChunks: false, - }, - ], - [ - 'existingDistantFile', - { - position: InjectPosition.BEFORE, - value: distantFileContent, - injectIntoAllChunks: false, - }, - ], + const contentsToInject: ReturnType> = []; + await prepareInjections(mockLogger, items, contentsToInject); + expect(contentsToInject).toEqual([ + { type: 'code', position: InjectPosition.BEFORE, value: codeContent }, + { type: 'file', position: InjectPosition.BEFORE, value: localFileContent }, + { type: 'file', position: InjectPosition.BEFORE, value: '' }, + { type: 'file', position: InjectPosition.BEFORE, value: distantFileContent }, + { type: 'file', position: InjectPosition.BEFORE, value: '' }, ]); expect(nockScope.isDone()).toBe(true); }); }); - describe('processItem', () => { + describe('resolveWithFallback', () => { test.each<{ description: string; item: ToInjectItem; expectation?: string }>([ { description: 'basic code', @@ -145,7 +133,9 @@ describe('Injection Plugin Helpers', () => { }, ])('Should process $description without throwing.', async ({ item, expectation }) => { expect.assertions(1); - return expect(processItem(item, mockLogger)).resolves.toEqual(expectation); + return expect(resolveWithFallback(item, mockLogger)).resolves.toEqual( + expectation ?? '', + ); }); }); diff --git a/packages/plugins/injection/src/helpers.ts b/packages/plugins/injection/src/helpers.ts index 32a164ab2..4cde070d0 100644 --- a/packages/plugins/injection/src/helpers.ts +++ b/packages/plugins/injection/src/helpers.ts @@ -6,7 +6,7 @@ import { readFile } from '@dd/core/helpers/fs'; import { getAbsolutePath } from '@dd/core/helpers/paths'; import { doRequest } from '@dd/core/helpers/request'; import { truncateString } from '@dd/core/helpers/strings'; -import type { Logger, ToInjectItem } from '@dd/core/types'; +import type { ChunkInfo, Logger, ToInjectItem } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; import chalk from 'chalk'; @@ -22,14 +22,6 @@ const yellow = chalk.bold.yellow; const MAX_TIMEOUT_IN_MS = 5000; -export const getInjectedValue = async (item: ToInjectItem): Promise => { - if (typeof item.value === 'function') { - return item.value(); - } - - return item.value; -}; - export const processDistantFile = async ( url: string, timeout: number = MAX_TIMEOUT_IN_MS, @@ -62,105 +54,99 @@ export const processLocalFile = async ( return readFile(absolutePath); }; -export const processItem = async ( - item: ToInjectItem, - log: Logger, - cwd: string = process.cwd(), -): Promise => { - let result: string | undefined; - const value = await getInjectedValue(item); - try { - if (item.type === 'file') { - if (value.match(DISTANT_FILE_RX)) { - result = await processDistantFile(value); - } else { - result = await processLocalFile(value, cwd); - } - } else if (item.type === 'code') { - // TODO: Confirm the code actually executes without errors. - result = value; - } else { - throw new Error(`Invalid item type "${item.type}", only accepts "code" or "file".`); - } - } catch (error: any) { - const itemId = `${item.type} - ${truncateString(value)}`; - if (item.fallback) { - // In case of any error, we'll fallback to next item in queue. - log.debug(`Fallback for "${itemId}": ${error.toString()}`); - result = await processItem(item.fallback, log, cwd); - } else { - // Or return an empty string. - log.warn(`Failed "${itemId}": ${error.toString()}`); - } - } - - return result; -}; - -export const processInjections = async ( - toInject: Map, - log: Logger, - cwd: string = process.cwd(), -): Promise< - Map -> => { - const toReturn = new Map(); - - // Processing sequentially all the items. - for (const [id, item] of toInject.entries()) { - // eslint-disable-next-line no-await-in-loop - const value = await processItem(item, log, cwd); - if (value) { - const position = item.position || InjectPosition.BEFORE; - toReturn.set(id, { - value, - injectIntoAllChunks: - 'injectIntoAllChunks' in item ? item.injectIntoAllChunks : false, - position, - }); - } - } +export function hasBeforeAfterInjection(contentsToInject: ContentToInject[]) { + return contentsToInject.some( + (content) => + content.position === InjectPosition.BEFORE || content.position === InjectPosition.AFTER, + ); +} - return toReturn; -}; +export function hasChunkInjection(contentsToInject: ContentToInject[]) { + return contentsToInject.some((content) => content.injectIntoAllChunks); +} export const getContentToInject = ( contentToInject: ContentToInject[], - options: { - position: InjectPosition; - onAllChunks?: boolean; - }, + position: InjectPosition, + chunk?: ChunkInfo, ) => { const filtered = contentToInject.filter((content) => { return ( - content.position === options.position && - (!options.onAllChunks || content.injectIntoAllChunks) + content.position === position && + (!chunk || chunk.isEntry || content.injectIntoAllChunks) ); }); - if (filtered.length === 0) { + // Resolve function-valued content against the current chunk, drop empties. + const values = filtered + .map((content) => (isFunction(content.value) ? content.value(chunk!) : content.value)) + .filter(Boolean); + + if (values.length === 0) { return ''; } - const stringToInject = filtered + const stringToInject = values // Wrapping it in order to avoid variable name collisions. - .map((content) => `(() => {${content.value}})();`) + .map((value) => `(() => {${value}})();`) .join('\n\n'); return `${BEFORE_INJECTION}\n${stringToInject}\n${AFTER_INJECTION}`; }; -// Prepare and fetch the content to inject. -export const addInjections = async ( +export const resolveWithFallback = async ( + item: ToInjectItem, log: Logger, - toInject: Map, + cwd: string = process.cwd(), +): Promise => { + const value = isFunction(item.value) ? await item.value() : item.value; + + try { + if (item.type === 'file') { + const filePath = value; + return await (filePath.match(DISTANT_FILE_RX) + ? processDistantFile(filePath) + : processLocalFile(filePath, cwd)); + } + return value; + } catch (error: any) { + const itemId = `${item.type} - ${truncateString(value)}`; + if (item.fallback) { + log.debug(`Fallback for "${itemId}": ${error.toString()}`); + return resolveWithFallback(item.fallback, log, cwd); + } + log.warn(`Failed "${itemId}": ${error.toString()}`); + return ''; + } +}; +export const prepareInjections = async ( + log: Logger, + toInject: ToInjectItem[], contentsToInject: ContentsToInject, cwd: string = process.cwd(), ) => { - const results = await processInjections(toInject, log, cwd); - // Add processed content to the array - for (const value of results.values()) { - contentsToInject.push(value); - } + // Per-chunk functions: adapt from public API (sourceOrHash?: string) to internal (chunk: ChunkInfo). + const dynamicPerChunk = toInject.filter(isPerChunk).map((item) => { + const userFn = item.value as (sourceOrHash?: string) => string; + return { ...item, value: (chunk: ChunkInfo) => userFn(chunk.sourceOrHash) }; + }); + + // Static items (strings and async loaders) are resolved once per build. + const staticInject = toInject.filter((item) => !isPerChunk(item)); + const resolvedStaticInject = await Promise.all( + staticInject.map(async (item) => ({ + ...item, + value: await resolveWithFallback(item, log, cwd), + })), + ); + + const normalize = (item: T) => ({ + ...item, + position: item.position ?? InjectPosition.BEFORE, + }); + contentsToInject.push( + ...dynamicPerChunk.map(normalize), + ...resolvedStaticInject.map(normalize), + ); }; export interface NodeSystemError extends Error { @@ -178,3 +164,10 @@ export const isFileSupported = (ext: string): boolean => { export const warnUnsupportedFile = (log: Logger, ext: string, filename: string): void => { log.warn(`"${yellow(ext)}" files are not supported (${yellow(filename)}).`); }; + +const isPerChunk = ( + item: ToInjectItem, +): item is ToInjectItem & { value: (chunk: ChunkInfo) => string } => + typeof item.value === 'function' && item.value.length === 1; + +const isFunction = (value: any): value is Function => typeof value === 'function'; diff --git a/packages/plugins/injection/src/index.test.ts b/packages/plugins/injection/src/index.test.ts index 6104a5b34..e88c58acf 100644 --- a/packages/plugins/injection/src/index.test.ts +++ b/packages/plugins/injection/src/index.test.ts @@ -9,7 +9,7 @@ import { getUniqueId } from '@dd/core/helpers/strings'; import type { Assign, BundlerName, Options, ToInjectItem } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; import { AFTER_INJECTION, BEFORE_INJECTION } from '@dd/internal-injection-plugin/constants'; -import { addInjections, isFileSupported } from '@dd/internal-injection-plugin/helpers'; +import { prepareInjections, isFileSupported } from '@dd/internal-injection-plugin/helpers'; import { getOutDir, prepareWorkingDir } from '@dd/tests/_jest/helpers/env'; import { hardProjectEntries, @@ -80,11 +80,11 @@ jest.mock('@dd/internal-injection-plugin/helpers', () => { const original = jest.requireActual('@dd/internal-injection-plugin/helpers'); return { ...original, - addInjections: jest.fn(original.addInjections), + prepareInjections: jest.fn(original.prepareInjections), }; }); -const addInjectionsMock = jest.mocked(addInjections); +const prepareInjectionsMock = jest.mocked(prepareInjections); const getLog = (type: ContentType, position: Position) => { const positionString = `in ${position}`; @@ -118,6 +118,7 @@ describe('Injection Plugin', () => { const injectedItem: ToInjectItem = { type: 'code', value: getInjectedString(bundlerName), + position: InjectPosition.BEFORE, }; context.inject(injectedItem); return [ @@ -136,7 +137,7 @@ describe('Injection Plugin', () => { }); buildErrors.push(...errors); // Store the calls, because Jest resets mocks in beforeEach 🤷 - calls.push(...addInjectionsMock.mock.calls.flatMap((c) => Array.from(c[1].values()))); + calls.push(...prepareInjectionsMock.mock.calls.flatMap((c) => c[1])); }); test('Should not error on build', () => { diff --git a/packages/plugins/injection/src/index.ts b/packages/plugins/injection/src/index.ts index 5298075a5..055d693b8 100644 --- a/packages/plugins/injection/src/index.ts +++ b/packages/plugins/injection/src/index.ts @@ -4,7 +4,6 @@ import { INJECTED_FILE_RX } from '@dd/core/constants'; import { isXpack } from '@dd/core/helpers/bundlers'; -import { getUniqueId } from '@dd/core/helpers/strings'; import { InjectPosition, type GetInternalPlugins, @@ -15,7 +14,7 @@ import { import { PLUGIN_NAME } from './constants'; import { getEsbuildPlugin } from './esbuild'; -import { addInjections, getContentToInject } from './helpers'; +import { prepareInjections, getContentToInject } from './helpers'; import { getRollupPlugin } from './rollup'; import type { ContentsToInject } from './types'; import { getXpackPlugin } from './xpack'; @@ -26,13 +25,13 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { const { bundler, context } = arg; const log = context.getLogger(PLUGIN_NAME); // Storage for all the injections. - const injections: Map = new Map(); + const injections: ToInjectItem[] = []; // Storage for all the positional contents we want to inject. const contentsToInject: ContentsToInject = []; context.inject = (item: ToInjectItem) => { - injections.set(getUniqueId(), item); + injections.push(item); }; const plugin: PluginOptions = { @@ -53,9 +52,10 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { handler() { // For Vite, we inject MIDDLE content by adding a script tag // that references the virtual injected file - const middleContent = getContentToInject(contentsToInject, { - position: InjectPosition.MIDDLE, - }); + const middleContent = getContentToInject( + contentsToInject, + InjectPosition.MIDDLE, + ); if (middleContent) { // Return a tag descriptor instead of modifying HTML directly return [ @@ -84,9 +84,7 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { }, handler() { return { - code: getContentToInject(contentsToInject, { - position: InjectPosition.MIDDLE, - }), + code: getContentToInject(contentsToInject, InjectPosition.MIDDLE), }; }, }; @@ -97,7 +95,7 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { // Here for all the other non-xpack bundlers. plugin.buildStart = async () => { // Prepare the injections. - await addInjections(log, injections, contentsToInject, context.buildRoot); + await prepareInjections(log, injections, contentsToInject, context.buildRoot); }; } diff --git a/packages/plugins/injection/src/rollup.ts b/packages/plugins/injection/src/rollup.ts index 2e3878e9c..8663f82df 100644 --- a/packages/plugins/injection/src/rollup.ts +++ b/packages/plugins/injection/src/rollup.ts @@ -6,7 +6,9 @@ import { INJECTED_FILE } from '@dd/core/constants'; import { isInjectionFile } from '@dd/core/helpers/plugins'; import type { Logger, PluginOptions } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; +import MagicString from 'magic-string'; import path from 'path'; +import type { RenderedChunk } from 'rollup'; import { getContentToInject, isFileSupported, warnUnsupportedFile } from './helpers'; import type { ContentsToInject } from './types'; @@ -20,24 +22,41 @@ export const getRollupPlugin = ( contentsToInject: ContentsToInject, ): PluginOptions['rollup'] => { return { - banner(chunk) { - const banner = getContentToInject(contentsToInject, { - position: InjectPosition.BEFORE, - onAllChunks: !chunk.isEntry, + renderChunk(code, chunk: RenderedChunk) { + const { base, ext } = path.parse(chunk.fileName); + if (!isFileSupported(ext)) { + warnUnsupportedFile(log, ext, base); + return null; + } + + const banner = getContentToInject(contentsToInject, InjectPosition.BEFORE, { + sourceOrHash: code, + fileName: chunk.fileName, + isEntry: chunk.isEntry, + }); + const footer = getContentToInject(contentsToInject, InjectPosition.AFTER, { + sourceOrHash: code, + fileName: chunk.fileName, + isEntry: chunk.isEntry, }); - if (banner === '' || !chunk.fileName) { - return ''; + if (!banner && !footer) { + return null; } - const { base, ext } = path.parse(chunk.fileName); - const isOutputSupported = isFileSupported(ext); - if (!isOutputSupported) { - warnUnsupportedFile(log, ext, base); - return ''; + const s = new MagicString(code); + + if (banner) { + s.prepend(`${banner}\n`); + } + if (footer) { + s.append(`\n${footer}`); } - return banner; + return { + code: s.toString(), + map: s.generateMap({ file: chunk.fileName, hires: true }), + }; }, async resolveId(source, importer, options) { if (isInjectionFile(source)) { @@ -45,12 +64,7 @@ export const getRollupPlugin = ( // "treeshake.moduleSideEffects: false" may prevent the injection from being included. return { id: source, moduleSideEffects: true }; } - if ( - options.isEntry && - getContentToInject(contentsToInject, { - position: InjectPosition.MIDDLE, - }) - ) { + if (options.isEntry && getContentToInject(contentsToInject, InjectPosition.MIDDLE)) { // Skip HTML entries - Vite handles these specially via transformIndexHtml // The proxy mechanism breaks Vite's HTML->module tracking if (source.endsWith('.html')) { @@ -86,9 +100,7 @@ export const getRollupPlugin = ( load(id) { if (isInjectionFile(id)) { // Replace with injection content. - return getContentToInject(contentsToInject, { - position: InjectPosition.MIDDLE, - }); + return getContentToInject(contentsToInject, InjectPosition.MIDDLE); } if (id.endsWith(TO_INJECT_SUFFIX)) { const entryId = id.slice(0, -TO_INJECT_SUFFIX.length); @@ -103,24 +115,5 @@ export const getRollupPlugin = ( } return null; }, - footer(chunk) { - const footer = getContentToInject(contentsToInject, { - position: InjectPosition.AFTER, - onAllChunks: !chunk.isEntry, - }); - - if (footer === '' || !chunk.fileName) { - return ''; - } - - const { base, ext } = path.parse(chunk.fileName); - const isOutputSupported = isFileSupported(ext); - if (!isOutputSupported) { - warnUnsupportedFile(log, ext, base); - return ''; - } - - return footer; - }, }; }; diff --git a/packages/plugins/injection/src/types.ts b/packages/plugins/injection/src/types.ts index 84d0d835f..65c2bda9f 100644 --- a/packages/plugins/injection/src/types.ts +++ b/packages/plugins/injection/src/types.ts @@ -2,12 +2,15 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { InjectPosition } from '@dd/core/types'; +import type { ChunkInfo, InjectPosition } from '@dd/core/types'; + +// A static string, an async resolver, or a function resolved per emitted chunk. +export type InjectValue = string | (() => Promise) | ((chunk: ChunkInfo) => string); export type ContentToInject = { - injectIntoAllChunks: boolean; + injectIntoAllChunks?: boolean; position: InjectPosition; - value: string; + value: InjectValue; }; export type ContentsToInject = Array; diff --git a/packages/plugins/injection/src/xpack.ts b/packages/plugins/injection/src/xpack.ts index ec7ed8a96..d7ee94cc7 100644 --- a/packages/plugins/injection/src/xpack.ts +++ b/packages/plugins/injection/src/xpack.ts @@ -9,7 +9,12 @@ import { InjectPosition } from '@dd/core/types'; import path from 'path'; import { PLUGIN_NAME } from './constants'; -import { getContentToInject, addInjections, isFileSupported, warnUnsupportedFile } from './helpers'; +import { + getContentToInject, + prepareInjections, + isFileSupported, + warnUnsupportedFile, +} from './helpers'; import type { ContentsToInject } from './types'; export const getXpackPlugin = @@ -17,7 +22,7 @@ export const getXpackPlugin = bundler: any, log: Logger, context: GlobalContext, - toInject: Map, + toInject: ToInjectItem[], contentsToInject: ContentsToInject, ): PluginOptions['rspack'] & PluginOptions['webpack'] => (compiler) => { @@ -100,7 +105,7 @@ export const getXpackPlugin = // Otherwise they'll be empty once resolved. const setupInjections = async () => { // Prepare the injections. - await addInjections(log, toInject, contentsToInject, context.buildRoot); + await prepareInjections(log, toInject, contentsToInject, context.buildRoot); }; // For one-time builds (production mode) @@ -115,33 +120,11 @@ export const getXpackPlugin = // with both banner and footer. compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { const hookCb = () => { - const bannerForEntries = getContentToInject(contentsToInject, { - position: InjectPosition.BEFORE, - }); - const footerForEntries = getContentToInject(contentsToInject, { - position: InjectPosition.AFTER, - }); - const bannerForAllChunks = getContentToInject(contentsToInject, { - position: InjectPosition.BEFORE, - onAllChunks: true, - }); - const footerForAllChunks = getContentToInject(contentsToInject, { - position: InjectPosition.AFTER, - onAllChunks: true, - }); - for (const chunk of compilation.chunks) { - let banner = bannerForEntries; - let footer = footerForEntries; - - if (!chunk.canBeInitial()) { - banner = bannerForAllChunks; - footer = footerForAllChunks; - } - - if (banner === '' && footer === '') { - continue; - } + const isEntry = chunk.canBeInitial(); + // Per-chunk content (e.g. context snippets) keys off the chunk + // content hash, the only chunk source available at this stage. + const chunkSource = chunk.contentHash?.javascript ?? chunk.hash; for (const file of chunk.files) { const { base, ext } = path.parse(file); @@ -151,6 +134,23 @@ export const getXpackPlugin = continue; } + const fileName = path.basename(file); + // Resolve static and per-chunk content in one pass. + const banner = getContentToInject(contentsToInject, InjectPosition.BEFORE, { + sourceOrHash: chunkSource, + fileName, + isEntry, + }); + const footer = getContentToInject(contentsToInject, InjectPosition.AFTER, { + sourceOrHash: chunkSource, + fileName, + isEntry, + }); + + if (banner === '' && footer === '') { + continue; + } + compilation.updateAsset(file, (old) => { const cached = cache.get(old); diff --git a/packages/plugins/rum/src/debugId.ts b/packages/plugins/rum/src/debugId.ts new file mode 100644 index 000000000..90bc956f4 --- /dev/null +++ b/packages/plugins/rum/src/debugId.ts @@ -0,0 +1,32 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { createHash } from 'crypto'; + +export const SUPPORTED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); + +// The debug ID is embedded in the DD_SOURCE_CODE_CONTEXT context object as a "debugId" field. +const DEBUG_ID_RX = /"ddDebugId":"([^"]+)"/; + +export const getDebugIdFromSource = (source: string): string | undefined => { + const match = source.match(DEBUG_ID_RX); + return match ? match[1] : undefined; +}; +const VARIANT_CHARS = ['8', '9', 'a', 'b'] as const; + +// SHA-256(input) truncated to 128 bits → deterministic UUID-v4-shaped identifier. +// SHA-256 is used instead of MD5 for FIPS 140-2/3 compliance. +export const stringToUUID = (input: string): string => { + const hash = createHash('sha256').update(input).digest('hex').slice(0, 32); + const withVersion = `${hash.slice(0, 12)}4${hash.slice(13)}`; + const variantIndex = withVersion.charCodeAt(16) % 4; + const withVariant = `${withVersion.slice(0, 16)}${VARIANT_CHARS[variantIndex]}${withVersion.slice(17)}`; + return [ + withVariant.slice(0, 8), + withVariant.slice(8, 12), + withVariant.slice(12, 16), + withVariant.slice(16, 20), + withVariant.slice(20, 32), + ].join('-'); +}; diff --git a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts index 036b02fe1..c13614805 100644 --- a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts +++ b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts @@ -2,6 +2,9 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { randomUUID } from 'crypto'; + +import { stringToUUID } from './debugId'; import type { SourceCodeContextOptions } from './types'; export const DEFAULT_SOURCE_CODE_CONTEXT_VARIABLE = 'DD_SOURCE_CODE_CONTEXT' as const; @@ -19,6 +22,25 @@ export const DEFAULT_SOURCE_CODE_CONTEXT_VARIABLE = 'DD_SOURCE_CODE_CONTEXT' as // s && (m[s] = c) // } catch (e) {} // })(context, variableName); -export const getSourceCodeContextSnippet = (context: SourceCodeContextOptions): string => { + +type SourceCodeContext = { + service?: string; + version?: string; + ddDebugId?: string; +}; +export const getSourceCodeContextSnippet = ( + contextOptions: SourceCodeContextOptions, + codeOrHash?: string, +): string => { + const context: SourceCodeContext = { + service: contextOptions.service, + version: contextOptions.version, + }; + + if (contextOptions.debugId) { + // Compute deterministic debug IDs whenever possible preventing the backend from storing duplicate source maps for identical build + context.ddDebugId = codeOrHash ? stringToUUID(codeOrHash) : randomUUID(); + } + return `(function(c,n){try{if(typeof window==='undefined')return;var w=window,m=w[n]=w[n]||{},s=new Error().stack;s&&(m[s]=c)}catch(e){}})(${JSON.stringify(context)},${JSON.stringify(DEFAULT_SOURCE_CODE_CONTEXT_VARIABLE)});`; }; diff --git a/packages/plugins/rum/src/index.test.ts b/packages/plugins/rum/src/index.test.ts index 35306fab8..2ba67a511 100644 --- a/packages/plugins/rum/src/index.test.ts +++ b/packages/plugins/rum/src/index.test.ts @@ -2,6 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import type { ToInjectItem } from '@dd/core/types'; import type { RumOptions } from '@dd/rum-plugin/types'; import { getPlugins } from '@dd/rum-plugin'; import { @@ -18,72 +19,39 @@ jest.mock('@dd/rum-plugin/sdk', () => ({ })); describe('RUM Plugin', () => { - const injections = { - 'browser-sdk': path.resolve('../plugins/rum/src/rum-browser-sdk.js'), - 'sdk-init': injectionValue, - 'source-code-context': - /(?=.*DD_SOURCE_CODE_CONTEXT)(?=.*"service":"checkout")(?=.*"version":"1\.2\.3")/, + const run = (config: RumOptions) => { + const injectSpy = jest.fn((_item: ToInjectItem) => {}); + getPlugins( + getGetPluginsArg( + { ...defaultPluginOptions, rum: config }, + getContextMock({ inject: injectSpy }), + ), + ); + return injectSpy.mock.calls.map(([item]) => item.value); }; - const expectations: { - type: string; - config: RumOptions; - should: { inject: (keyof typeof injections)[] }; - }[] = [ - { - type: 'no sdk', - config: {}, - should: { inject: [] }, - }, - { - type: 'sdk', - config: { sdk: { applicationId: 'app-id' } }, - should: { inject: ['browser-sdk', 'sdk-init'] }, - }, - { - type: 'source code context', - config: { - sourceCodeContext: { - service: 'checkout', - version: '1.2.3', - }, - }, - should: { inject: ['source-code-context'] }, - }, - ]; - describe('getPlugins', () => { - const injectMock = jest.fn(); - test('Should initialize the plugin', async () => { - getPlugins( - getGetPluginsArg( - { - rum: { - sdk: { applicationId: 'app-id', clientToken: '123' }, - }, - }, - { inject: injectMock }, - ), - ); - expect(injectMock).toHaveBeenCalled(); - }); + test('Should inject nothing with no config', () => { + expect(run({})).toHaveLength(0); }); - test.each(expectations)( - 'Should inject the necessary files with "$type".', - async ({ config, should }) => { - const mockContext = getContextMock(); - const pluginConfig = { ...defaultPluginOptions, rum: config }; + test('Should inject SDK files with sdk config', () => { + const values = run({ sdk: { applicationId: 'app-id' } }); + expect(values).toHaveLength(2); + expect(values).toContain(path.resolve('../plugins/rum/src/rum-browser-sdk.js')); + expect(values).toContain(injectionValue); + }); - getPlugins(getGetPluginsArg(pluginConfig, mockContext)); + test('Should inject source code context snippet', () => { + const value = run({ + sourceCodeContext: { service: 'checkout', version: '1.2.3' }, + })[0] as () => string; + expect(value()).toMatch( + /(?=.*DD_SOURCE_CODE_CONTEXT)(?=.*"service":"checkout")(?=.*"version":"1\.2\.3")/, + ); + }); - expect(mockContext.inject).toHaveBeenCalledTimes(should.inject.length); - for (const inject of should.inject) { - expect(mockContext.inject).toHaveBeenCalledWith( - expect.objectContaining({ - value: expect.stringMatching(injections[inject]), - }), - ); - } - }, - ); + test('Should inject debug id snippet', () => { + const value = run({ sourceCodeContext: { debugId: true } })[0] as () => string; + expect(value()).toMatch(/(?=.*DD_SOURCE_CODE_CONTEXT)(?=.*"ddDebugId":"[0-9a-f-]+")/); + }); }); diff --git a/packages/plugins/rum/src/index.ts b/packages/plugins/rum/src/index.ts index 249aadca7..d5e227a03 100644 --- a/packages/plugins/rum/src/index.ts +++ b/packages/plugins/rum/src/index.ts @@ -30,13 +30,14 @@ export const getPlugins: GetPlugins = ({ options, context }) => { const log = context.getLogger(PLUGIN_NAME); const validatedOptions = validateOptions(options, log); const plugins: PluginOptions[] = []; + const sourceCodeContext = validatedOptions.sourceCodeContext; - if (validatedOptions.sourceCodeContext) { + if (sourceCodeContext) { context.inject({ type: 'code', position: InjectPosition.BEFORE, injectIntoAllChunks: true, - value: getSourceCodeContextSnippet(validatedOptions.sourceCodeContext), + value: (sourceOrHash) => getSourceCodeContextSnippet(sourceCodeContext, sourceOrHash), }); } diff --git a/packages/plugins/rum/src/types.ts b/packages/plugins/rum/src/types.ts index ef16abe5b..445f16d37 100644 --- a/packages/plugins/rum/src/types.ts +++ b/packages/plugins/rum/src/types.ts @@ -8,8 +8,9 @@ import type { RumInitConfiguration } from './browserSdkTypes'; import type { PrivacyOptions, PrivacyOptionsWithDefaults } from './privacy/types'; export type SourceCodeContextOptions = { - service: string; + service?: string; version?: string; + debugId?: boolean; }; export type RumOptions = { diff --git a/packages/plugins/rum/src/validate.ts b/packages/plugins/rum/src/validate.ts index 8241be69c..c7e5547f4 100644 --- a/packages/plugins/rum/src/validate.ts +++ b/packages/plugins/rum/src/validate.ts @@ -168,7 +168,7 @@ export const validateSourceCodeContextOptions = ( const cfg: SourceCodeContextOptions = validatedOptions.sourceCodeContext; - if (!cfg?.service || typeof cfg.service !== 'string') { + if (!cfg?.debugId && (!cfg?.service || typeof cfg.service !== 'string')) { toReturn.errors.push(`Missing ${red('"rum.sourceCodeContext.service"')}.`); } diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index 8212b6e88..c824b7b72 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -58,6 +58,7 @@ "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", @@ -88,8 +89,7 @@ "@babel/parser": "^7.24.5", "@babel/traverse": "^7.24.5", "@babel/types": "^7.24.5", - "esbuild": ">=0.x", - "magic-string": "^0.30.0" + "esbuild": ">=0.x" }, "peerDependenciesMeta": { "@babel/parser": { @@ -100,9 +100,6 @@ }, "@babel/types": { "optional": true - }, - "magic-string": { - "optional": true } } } diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index d75cb2de2..44f7ca399 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -61,6 +61,7 @@ "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", @@ -91,7 +92,6 @@ "@babel/parser": "^7.24.5", "@babel/traverse": "^7.24.5", "@babel/types": "^7.24.5", - "magic-string": "^0.30.0", "rollup": ">= 3.x < 5.x" }, "peerDependenciesMeta": { @@ -103,9 +103,6 @@ }, "@babel/types": { "optional": true - }, - "magic-string": { - "optional": true } } } diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index b3d34d85f..79331161d 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -58,6 +58,7 @@ "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index a4cac0c57..cdf4c03ac 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -58,6 +58,7 @@ "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", @@ -88,7 +89,6 @@ "@babel/parser": "^7.24.5", "@babel/traverse": "^7.24.5", "@babel/types": "^7.24.5", - "magic-string": "^0.30.0", "vite": ">= 5.x <= 7.x" }, "peerDependenciesMeta": { @@ -100,9 +100,6 @@ }, "@babel/types": { "optional": true - }, - "magic-string": { - "optional": true } } } diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index 1501c56ef..1d1346312 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -58,6 +58,7 @@ "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", @@ -88,7 +89,6 @@ "@babel/parser": "^7.24.5", "@babel/traverse": "^7.24.5", "@babel/types": "^7.24.5", - "magic-string": "^0.30.0", "webpack": ">= 5.x < 6.x" }, "peerDependenciesMeta": { @@ -100,9 +100,6 @@ }, "@babel/types": { "optional": true - }, - "magic-string": { - "optional": true } } } diff --git a/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts b/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts index 4381e979f..0da456c3b 100644 --- a/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts +++ b/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts @@ -4,6 +4,7 @@ /* eslint-env browser */ /* global globalThis */ +import type { BundlerName } from '@dd/core/types'; import { verifyProjectBuild } from '@dd/tests/_playwright/helpers/buildProject'; import type { TestOptions } from '@dd/tests/_playwright/testParams'; import { test } from '@dd/tests/_playwright/testParams'; @@ -16,6 +17,7 @@ const { expect, beforeAll, describe } = test; const SERVICE_NAME = 'test-micro-frontend'; const SERVICE_VERSION = '1.2.3'; +const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler']) => { // Navigate to our page. @@ -30,27 +32,35 @@ const getRUMEvents = async (page: Page) => { return page.evaluate(() => (globalThis as any).rum_events); }; +const getDebugIds = async (page: Page): Promise => { + return page.evaluate(() => + Object.values((globalThis as any)['DD_SOURCE_CODE_CONTEXT'] || {}) + .map((ctx: unknown) => (ctx as Record)?.ddDebugId) + .filter((id: unknown): id is string => typeof id === 'string'), + ); +}; + +async function build(publicDir: string, suiteName: string, bundlers: BundlerName[]) { + const source = path.resolve(__dirname, 'project'); + const destination = path.resolve(publicDir, suiteName); + await verifyProjectBuild(source, destination, bundlers, pluginConfig, { splitting: true }); +} + +const pluginConfig = { + ...defaultConfig, + rum: { + sourceCodeContext: { + service: SERVICE_NAME, + version: SERVICE_VERSION, + debugId: true, + }, + }, +}; + describe('Source Code Context', () => { // Build our fixture project. beforeAll(async ({ publicDir, bundlers, suiteName }) => { - const source = path.resolve(__dirname, 'project'); - const destination = path.resolve(publicDir, suiteName); - await verifyProjectBuild( - source, - destination, - bundlers, - { - ...defaultConfig, - rum: { - enable: true, - sourceCodeContext: { - service: SERVICE_NAME, - version: SERVICE_VERSION, - }, - }, - }, - { splitting: true }, - ); + await build(publicDir, suiteName, bundlers); }); test('Should inject DD_SOURCE_CODE_CONTEXT global variable', async ({ @@ -84,13 +94,7 @@ describe('Source Code Context', () => { expect(errors).toEqual([]); }); - test('Should not throw errors', async ({ - page, - bundler, - browserName, - suiteName, - devServerUrl, - }) => { + test('Should not throw errors', async ({ page, bundler, suiteName, devServerUrl }) => { const errors: string[] = []; const testBaseUrl = `${devServerUrl}/${suiteName}`; @@ -148,4 +152,55 @@ describe('Source Code Context', () => { expect(entryError).toMatchObject({ version: SERVICE_VERSION, service: SERVICE_NAME }); expect(chunkError).toMatchObject({ version: SERVICE_VERSION, service: SERVICE_NAME }); }); + + test('Should register a distinct debug_id for a dynamically loaded chunk', async ({ + page, + bundler, + suiteName, + devServerUrl, + }) => { + await userFlow(`${devServerUrl}/${suiteName}`, page, bundler); + + const before = await getDebugIds(page); + + // Loading a separate chunk evaluates its own injected snippet. + await page.click('#load_chunk'); + await page.waitForFunction(() => window.chunkLoaded === true); + + const after = await getDebugIds(page); + + // The chunk contributed at least one new, distinct debug_id (per emitted file). + expect(after.length).toBeGreaterThan(before.length); + const newDebugIds = after.filter((debugId) => !before.includes(debugId)); + expect(newDebugIds.length).toBeGreaterThanOrEqual(1); + for (const debugId of after) { + expect(debugId).toMatch(UUID_RX); + } + }); + + test('Should generate the same debug_id across two builds', async ({ + page, + bundler, + suiteName, + devServerUrl, + publicDir, + bundlers, + }) => { + // rspack chunk.contentHash.javascript is non-deterministic across builds when devtool + // is enabled, causing different debug IDs each time. + test.skip( + bundler === 'rspack', + 'rspack content hash is not deterministic across build directories when devtool is enabled', + ); + + await build(publicDir, `${suiteName}-rebuild`, bundlers); + + await userFlow(`${devServerUrl}/${suiteName}`, page, bundler); + const firstBuildIds = await getDebugIds(page); + + await userFlow(`${devServerUrl}/${suiteName}-rebuild`, page, bundler); + const secondBuildIds = await getDebugIds(page); + + expect(firstBuildIds.sort()).toEqual(secondBuildIds.sort()); + }); }); diff --git a/yarn.lock b/yarn.lock index 495ca9fae..5c99d323c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1705,6 +1705,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1718,7 +1719,6 @@ __metadata: "@babel/traverse": ^7.24.5 "@babel/types": ^7.24.5 esbuild: ">=0.x" - magic-string: ^0.30.0 peerDependenciesMeta: "@babel/parser": optional: true @@ -1726,8 +1726,6 @@ __metadata: optional: true "@babel/types": optional: true - magic-string: - optional: true languageName: unknown linkType: soft @@ -1765,6 +1763,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1777,7 +1776,6 @@ __metadata: "@babel/parser": ^7.24.5 "@babel/traverse": ^7.24.5 "@babel/types": ^7.24.5 - magic-string: ^0.30.0 rollup: ">= 3.x < 5.x" peerDependenciesMeta: "@babel/parser": @@ -1786,8 +1784,6 @@ __metadata: optional: true "@babel/types": optional: true - magic-string: - optional: true languageName: unknown linkType: soft @@ -1818,6 +1814,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1871,6 +1868,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1883,7 +1881,6 @@ __metadata: "@babel/parser": ^7.24.5 "@babel/traverse": ^7.24.5 "@babel/types": ^7.24.5 - magic-string: ^0.30.0 vite: ">= 5.x <= 7.x" peerDependenciesMeta: "@babel/parser": @@ -1892,8 +1889,6 @@ __metadata: optional: true "@babel/types": optional: true - magic-string: - optional: true languageName: unknown linkType: soft @@ -1924,6 +1919,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1936,7 +1932,6 @@ __metadata: "@babel/parser": ^7.24.5 "@babel/traverse": ^7.24.5 "@babel/types": ^7.24.5 - magic-string: ^0.30.0 webpack: ">= 5.x < 6.x" peerDependenciesMeta: "@babel/parser": @@ -1945,8 +1940,6 @@ __metadata: optional: true "@babel/types": optional: true - magic-string: - optional: true languageName: unknown linkType: soft @@ -2091,6 +2084,7 @@ __metadata: "@dd/core": "workspace:*" "@dd/internal-build-report-plugin": "workspace:*" chalk: "npm:2.3.1" + magic-string: "npm:0.30.21" typescript: "npm:5.4.3" languageName: unknown linkType: soft