diff --git a/packages/core/src/plugins/build-report/esbuild.ts b/packages/core/src/plugins/build-report/esbuild.ts index 05b1801d..8c7562f9 100644 --- a/packages/core/src/plugins/build-report/esbuild.ts +++ b/packages/core/src/plugins/build-report/esbuild.ts @@ -7,7 +7,7 @@ import type { Logger } from '@dd/core/log'; import type { Entry, GlobalContext, Input, Output, PluginOptions } from '@dd/core/types'; import { glob } from 'glob'; -import { cleanName, getAbsolutePath, getType } from './helpers'; +import { cleanName, getAbsolutePath, getType, isInjection } from './helpers'; // Re-index metafile data for easier access. const reIndexMeta = (obj: Record, cwd: string) => @@ -95,6 +95,10 @@ export const getEsbuildPlugin = (context: GlobalContext, log: Logger): PluginOpt // Loop through inputs. for (const [filename, input] of Object.entries(result.metafile.inputs)) { + if (isInjection(filename)) { + continue; + } + const filepath = getAbsolutePath(cwd, filename); const name = cleanName(context, filename); @@ -117,6 +121,10 @@ export const getEsbuildPlugin = (context: GlobalContext, log: Logger): PluginOpt // Get inputs of this output. const inputFiles: Input[] = []; for (const inputName of Object.keys(output.inputs)) { + if (isInjection(inputName)) { + continue; + } + const inputFound = reportInputsIndexed[getAbsolutePath(cwd, inputName)]; if (!inputFound) { warn(`Input ${inputName} not found for output ${cleanedName}`); @@ -208,7 +216,7 @@ export const getEsbuildPlugin = (context: GlobalContext, log: Logger): PluginOpt // There are some exceptions we want to ignore. const FILE_EXCEPTIONS_RX = /(|https:|file:|data:|#)/g; const isFileSupported = (filePath: string) => { - if (filePath.match(FILE_EXCEPTIONS_RX)) { + if (isInjection(filePath) || filePath.match(FILE_EXCEPTIONS_RX)) { return false; } return true; diff --git a/packages/core/src/plugins/build-report/helpers.ts b/packages/core/src/plugins/build-report/helpers.ts index 1ce1e61c..a2647579 100644 --- a/packages/core/src/plugins/build-report/helpers.ts +++ b/packages/core/src/plugins/build-report/helpers.ts @@ -2,6 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { INJECTED_FILE } from '@dd/core/plugins/injection/constants'; import type { BuildReport, SerializedEntry, @@ -188,6 +189,9 @@ export const unserializeBuildReport = (report: SerializedBuildReport): BuildRepo }; }; +// Is the file coming from the injection plugin? +export const isInjection = (filename: string) => filename.includes(INJECTED_FILE); + const BUNDLER_SPECIFICS = ['unknown', 'commonjsHelpers.js', 'vite/preload-helper.js']; // Make list of paths unique, remove the current file and particularities. export const cleanReport = ( @@ -199,6 +203,8 @@ export const cleanReport = ( for (const reportFilepath of report) { const cleanedPath = cleanPath(reportFilepath); if ( + // Don't add injections. + isInjection(reportFilepath) || // Don't add itself into it. cleanedPath === filepath || // Remove common specific files injected by bundlers. @@ -237,6 +243,10 @@ export const cleanPath = (filepath: string) => { // Will only prepend the cwd if not already there. export const getAbsolutePath = (cwd: string, filepath: string) => { + if (isInjection(filepath)) { + return INJECTED_FILE; + } + if (filepath.startsWith(cwd)) { return filepath; } @@ -245,6 +255,10 @@ export const getAbsolutePath = (cwd: string, filepath: string) => { // Extract a name from a path based on the context (out dir and cwd). export const cleanName = (context: GlobalContext, filepath: string) => { + if (isInjection(filepath)) { + return INJECTED_FILE; + } + if (filepath === 'unknown') { return filepath; } diff --git a/packages/core/src/plugins/build-report/webpack.ts b/packages/core/src/plugins/build-report/webpack.ts index d30601e5..b3c4c7b9 100644 --- a/packages/core/src/plugins/build-report/webpack.ts +++ b/packages/core/src/plugins/build-report/webpack.ts @@ -5,7 +5,7 @@ import type { Logger } from '@dd/core/log'; import type { Entry, GlobalContext, Input, Output, PluginOptions } from '@dd/core/types'; -import { cleanName, cleanReport, getAbsolutePath, getType } from './helpers'; +import { cleanName, cleanReport, getAbsolutePath, getType, isInjection } from './helpers'; export const getWebpackPlugin = (context: GlobalContext, PLUGIN_NAME: string, log: Logger): PluginOptions['webpack'] => @@ -136,6 +136,20 @@ export const getWebpackPlugin = : 'unknown'; }; + const isModuleSupported = (module: (typeof modules)[number]) => { + if ( + isInjection(getModulePath(module)) || + // Do not report runtime modules as they are very specific to webpack. + module.moduleType === 'runtime' || + module.name?.startsWith('(webpack)') || + // Also ignore orphan modules + module.type === 'orphan modules' + ) { + return false; + } + return true; + }; + const getModules = (reason: Reason) => { const { moduleIdentifier, moduleId } = reason; if (!moduleIdentifier && !moduleId) { @@ -174,12 +188,7 @@ export const getWebpackPlugin = // Build inputs const modulesDone = new Set(); for (const module of modules) { - // Do not report runtime modules as they are very specific to webpack. - if ( - module.moduleType === 'runtime' || - module.name?.startsWith('(webpack)') || - module.type === 'orphan modules' - ) { + if (!isModuleSupported(module)) { continue; } diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 1f2b78ac..38c9ad51 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -2,11 +2,12 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { GlobalContext, Meta, Options, PluginOptions } from '@dd/core/types'; +import type { GlobalContext, Meta, Options, PluginOptions, ToInjectItem } from '@dd/core/types'; import { getBuildReportPlugin } from './build-report'; import { getBundlerReportPlugin } from './bundler-report'; import { getGitPlugin } from './git'; +import { getInjectionPlugins } from './injection'; export const getInternalPlugins = ( options: Options, @@ -16,6 +17,7 @@ export const getInternalPlugins = ( const variant = meta.framework === 'webpack' ? (meta.webpack.compiler['webpack'] ? '5' : '4') : ''; + const toInject: ToInjectItem[] = []; const globalContext: GlobalContext = { auth: options.auth, bundler: { @@ -29,6 +31,9 @@ export const getInternalPlugins = ( warnings: [], }, cwd, + inject: (item: ToInjectItem) => { + toInject.push(item); + }, start: Date.now(), version: meta.version, }; @@ -36,9 +41,10 @@ export const getInternalPlugins = ( const bundlerReportPlugin = getBundlerReportPlugin(options, globalContext); const buildReportPlugin = getBuildReportPlugin(options, globalContext); const gitPlugin = getGitPlugin(options, globalContext); + const injectionPlugins = getInjectionPlugins(options, globalContext, toInject); return { globalContext, - internalPlugins: [bundlerReportPlugin, buildReportPlugin, gitPlugin], + internalPlugins: [bundlerReportPlugin, buildReportPlugin, gitPlugin, ...injectionPlugins], }; }; diff --git a/packages/core/src/plugins/injection/constants.ts b/packages/core/src/plugins/injection/constants.ts new file mode 100644 index 00000000..6bce5aba --- /dev/null +++ b/packages/core/src/plugins/injection/constants.ts @@ -0,0 +1,9 @@ +// 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. + +export const PREPARATION_PLUGIN_NAME = 'datadog-injection-preparation-plugin'; +export const PLUGIN_NAME = 'datadog-injection-plugin'; +export const RESOLUTION_PLUGIN_NAME = 'datadog-injection-resolution-plugin'; +export const INJECTED_FILE = '__DATADOG_INJECTION_STUB'; +export const DISTANT_FILE_RX = /^https?:\/\//; diff --git a/packages/core/src/plugins/injection/helpers.ts b/packages/core/src/plugins/injection/helpers.ts new file mode 100644 index 00000000..2122906d --- /dev/null +++ b/packages/core/src/plugins/injection/helpers.ts @@ -0,0 +1,86 @@ +// 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 { doRequest, truncateString } from '@dd/core/helpers'; +import type { Logger } from '@dd/core/log'; +import { getAbsolutePath } from '@dd/core/plugins/build-report/helpers'; +import type { ToInjectItem } from '@dd/core/types'; +import { readFile } from 'fs/promises'; + +import { DISTANT_FILE_RX } from './constants'; + +const MAX_TIMEOUT_IN_MS = 5000; + +export const processDistantFile = async ( + item: ToInjectItem, + timeout: number = MAX_TIMEOUT_IN_MS, +): Promise => { + let timeoutId: ReturnType | undefined; + return Promise.race([ + doRequest({ url: item.value }).finally(() => { + if (timeout) { + clearTimeout(timeoutId); + } + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error('Timeout')); + }, timeout); + }), + ]); +}; + +export const processLocalFile = async (item: ToInjectItem): Promise => { + const absolutePath = getAbsolutePath(process.cwd(), item.value); + return readFile(absolutePath, { encoding: 'utf-8' }); +}; + +export const processRawCode = async (item: ToInjectItem): Promise => { + // TODO: Confirm the code actually executes without errors. + return item.value; +}; + +export const processItem = async (item: ToInjectItem, log: Logger): Promise => { + let result: string; + try { + if (item.type === 'file') { + if (item.value.match(DISTANT_FILE_RX)) { + result = await processDistantFile(item); + } else { + result = await processLocalFile(item); + } + } else if (item.type === 'code') { + result = await processRawCode(item); + } else { + throw new Error(`Invalid item type "${item.type}", only accepts "code" or "file".`); + } + } catch (error: any) { + const itemId = `${item.type} - ${truncateString(item.value)}`; + if (item.fallback) { + // In case of any error, we'll fallback to next item in queue. + log(`Fallback for "${itemId}": ${error.toString()}`, 'warn'); + result = await processItem(item.fallback, log); + } else { + // Or return an empty string. + log(`Failed "${itemId}": ${error.toString()}`, 'warn'); + result = ''; + } + } + + return result; +}; + +export const processInjections = async ( + toInject: ToInjectItem[], + log: Logger, +): Promise => { + const proms: (Promise | string)[] = []; + + for (const item of toInject) { + proms.push(processItem(item, log)); + } + + const results = await Promise.all(proms); + return results.filter(Boolean); +}; diff --git a/packages/core/src/plugins/injection/index.ts b/packages/core/src/plugins/injection/index.ts new file mode 100644 index 00000000..80df01f8 --- /dev/null +++ b/packages/core/src/plugins/injection/index.ts @@ -0,0 +1,154 @@ +// 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 { getLogger } from '@dd/core/log'; +import type { GlobalContext, Options, PluginOptions, ToInjectItem } from '@dd/core/types'; + +import { + INJECTED_FILE, + PLUGIN_NAME, + PREPARATION_PLUGIN_NAME, + RESOLUTION_PLUGIN_NAME, +} from './constants'; +import { processInjections } from './helpers'; + +export const getInjectionPlugins = ( + opts: Options, + context: GlobalContext, + toInject: ToInjectItem[], +): PluginOptions[] => { + const log = getLogger(opts.logLevel, PLUGIN_NAME); + const contentToInject: string[] = []; + + const getContentToInject = () => { + contentToInject.unshift( + // Needs at least one element otherwise ESBuild will throw 'Do not know how to load path'. + // Most likely because it tries to generate an empty file. + ` +/********************************************/ +/* BEGIN INJECTION BY DATADOG BUILD PLUGINS */`, + ); + contentToInject.push(` +/* END INJECTION BY DATADOG BUILD PLUGINS */ +/********************************************/`); + + return contentToInject.join('\n\n'); + }; + + // Rollup uses its own banner hook + // and doesn't need to create a virtual INJECTED_FILE. + // We use its native functionality. + const rollupInjectionPlugin: PluginOptions['rollup'] = { + banner(chunk) { + if (chunk.isEntry) { + return getContentToInject(); + } + return ''; + }, + }; + + // This plugin happens in 3 steps in order to cover all bundlers: + // 1. Prepare the content to inject, fetching distant/local files and anything necessary. + // 2. Inject a virtual file into the bundling, this file will be home of all injected content. + // 3. Resolve the virtual file, returning the prepared injected content. + return [ + // Prepare and fetch the content to inject. + { + name: PREPARATION_PLUGIN_NAME, + enforce: 'pre', + // We use buildStart as it is the first async hook. + async buildStart() { + const results = await processInjections(toInject, log); + contentToInject.push(...results); + }, + }, + // Inject the virtual file that will be home of all injected content. + { + name: PLUGIN_NAME, + esbuild: { + setup(build) { + const { initialOptions } = build; + initialOptions.inject = initialOptions.inject || []; + initialOptions.inject.push(INJECTED_FILE); + }, + }, + webpack: (compiler) => { + const injectEntry = (originalEntry: any) => { + if (!originalEntry) { + return [INJECTED_FILE]; + } + + if (Array.isArray(originalEntry)) { + return [INJECTED_FILE, ...originalEntry]; + } + + if (typeof originalEntry === 'function') { + return async () => { + const originEntry = await originalEntry(); + return [INJECTED_FILE, originEntry]; + }; + } + + if (typeof originalEntry === 'string') { + return [INJECTED_FILE, originalEntry]; + } + + // We need to adjust the existing entries to import our injected file. + if (typeof originalEntry === 'object') { + const newEntry: typeof originalEntry = {}; + if (Object.keys(originalEntry).length === 0) { + newEntry[INJECTED_FILE] = + // Webpack 4 and 5 have different entry formats. + context.bundler.variant === '5' + ? { import: [INJECTED_FILE] } + : INJECTED_FILE; + return newEntry; + } + + for (const entryName in originalEntry) { + if (!Object.hasOwn(originalEntry, entryName)) { + continue; + } + const entry = originalEntry[entryName]; + newEntry[entryName] = + // Webpack 4 and 5 have different entry formats. + typeof entry === 'string' + ? [INJECTED_FILE, entry] + : { + ...entry, + import: [INJECTED_FILE, ...entry.import], + }; + } + + return newEntry; + } + + return [INJECTED_FILE, originalEntry]; + }; + + compiler.options.entry = injectEntry(compiler.options.entry); + }, + rollup: rollupInjectionPlugin, + vite: rollupInjectionPlugin, + }, + // Resolve the injected file. + { + name: RESOLUTION_PLUGIN_NAME, + enforce: 'post', + resolveId(id) { + if (id === INJECTED_FILE) { + return { id, moduleSideEffects: true }; + } + }, + loadInclude(id) { + return id === INJECTED_FILE; + }, + load(id) { + if (id === INJECTED_FILE) { + return getContentToInject(); + } + }, + }, + ]; +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index fe9d7580..395199bb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -65,8 +65,11 @@ export type BundlerReport = { variant?: string; // e.g. Major version of the bundler (webpack 4, webpack 5) }; +export type ToInjectItem = { type: 'file' | 'code'; value: string; fallback?: ToInjectItem }; + export type GlobalContext = { auth?: AuthOptions; + inject: (item: ToInjectItem) => void; bundler: BundlerReport; build: BuildReport; cwd: string; diff --git a/packages/tests/package.json b/packages/tests/package.json index b42c7ba6..acb163c0 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -31,6 +31,7 @@ "@dd/core": "workspace:*", "@dd/telemetry-plugins": "workspace:*", "@rollup/plugin-commonjs": "25.0.7", + "glob": "11.0.0", "jest": "29.7.0", "ts-jest": "29.1.2" }, diff --git a/packages/tests/src/core/plugins/injection/helpers.test.ts b/packages/tests/src/core/plugins/injection/helpers.test.ts new file mode 100644 index 00000000..cc2c5db8 --- /dev/null +++ b/packages/tests/src/core/plugins/injection/helpers.test.ts @@ -0,0 +1,166 @@ +// 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 type { Logger } from '@dd/core/log'; +import { + processInjections, + processItem, + processLocalFile, + processDistantFile, +} from '@dd/core/plugins/injection/helpers'; +import type { ToInjectItem } from '@dd/core/types'; +import { vol } from 'memfs'; +import nock from 'nock'; +import path from 'path'; + +jest.mock('fs', () => require('memfs').fs); +jest.mock('fs/promises', () => require('memfs').fs.promises); + +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 nonExistingFile: ToInjectItem = { + type: 'file', + value: 'fixtures/non-existing-file.js', +}; +const existingDistantFile: ToInjectItem = { + type: 'file', + value: 'https://example.com/distant-file.js', +}; +const nonExistingDistantFile: ToInjectItem = { + type: 'file', + value: 'https://example.com/non-existing-distant-file.js', +}; + +describe('Injection Plugin Helpers', () => { + const mockLogger: Logger = jest.fn(); + let nockScope: nock.Scope; + + beforeEach(() => { + nockScope = nock('https://example.com') + .get('/distant-file.js') + .reply(200, distantFileContent); + // Emulate some fixtures. + vol.fromJSON({ + [existingFile.value]: localFileContent, + }); + }); + + afterEach(() => { + vol.reset(); + }); + + describe('processInjections', () => { + test('Should process injections without throwing.', async () => { + const items: ToInjectItem[] = [ + code, + existingFile, + nonExistingFile, + existingDistantFile, + nonExistingDistantFile, + ]; + + const expectResult = expect(processInjections(items, mockLogger)).resolves; + + await expectResult.not.toThrow(); + await expectResult.toEqual([codeContent, localFileContent, distantFileContent]); + + expect(nockScope.isDone()).toBe(true); + }); + }); + + describe('processItem', () => { + test.each<{ description: string; item: ToInjectItem; expectation: string }>([ + { + description: 'basic code', + expectation: codeContent, + item: code, + }, + { + description: 'an existing file', + expectation: localFileContent, + item: existingFile, + }, + { + description: 'a non existing file', + expectation: '', + item: nonExistingFile, + }, + { + description: 'an existing distant file', + expectation: distantFileContent, + item: existingDistantFile, + }, + { + description: 'a non existing distant file', + expectation: '', + item: nonExistingDistantFile, + }, + { + description: 'failing fallbacks', + expectation: '', + item: { + ...nonExistingDistantFile, + fallback: nonExistingFile, + }, + }, + { + description: 'successful fallbacks', + expectation: codeContent, + item: { + ...nonExistingDistantFile, + fallback: { + ...nonExistingFile, + fallback: code, + }, + }, + }, + ])('Should process $description without throwing.', async ({ item, expectation }) => { + const expectResult = expect(processItem(item, mockLogger)).resolves; + + await expectResult.not.toThrow(); + await expectResult.toEqual(expectation); + }); + }); + + describe('processLocalFile', () => { + test.each([ + { + description: 'with a relative path', + value: './fixtures/local-file.js', + expectation: localFileContent, + }, + { + description: 'with an absolute path', + value: path.join(process.cwd(), './fixtures/local-file.js'), + expectation: localFileContent, + }, + ])('Should process local file $description.', async ({ value, expectation }) => { + const item: ToInjectItem = { type: 'file', value }; + const expectResult = expect(processLocalFile(item)).resolves; + + await expectResult.not.toThrow(); + await expectResult.toEqual(expectation); + }); + }); + + describe('processDistantFile', () => { + test('Should timeout after a given delay.', async () => { + nock('https://example.com') + .get('/delayed-distant-file.js') + .delay(10) + .reply(200, 'delayed distant file content'); + + const item: ToInjectItem = { + type: 'file', + value: 'https://example.com/delayed-distant-file.js', + }; + + await expect(processDistantFile(item, 1)).rejects.toThrow('Timeout'); + }); + }); +}); diff --git a/packages/tests/src/core/plugins/injection/index.test.ts b/packages/tests/src/core/plugins/injection/index.test.ts new file mode 100644 index 00000000..579e5749 --- /dev/null +++ b/packages/tests/src/core/plugins/injection/index.test.ts @@ -0,0 +1,129 @@ +// 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 type { Options } from '@dd/core/types'; +import { getComplexBuildOverrides } from '@dd/tests/helpers/mocks'; +import type { CleanupFn } from '@dd/tests/helpers/runBundlers'; +import { BUNDLERS, runBundlers } from '@dd/tests/helpers/runBundlers'; +import { readFileSync } from 'fs'; +import { glob } from 'glob'; +import nock from 'nock'; +import path from 'path'; + +describe('Injection Plugin', () => { + const distantFileContent = 'console.log("Hello injection from distant file.");'; + const localFileContent = 'console.log("Hello injection from local file.");'; + const codeContent = 'console.log("Hello injection from code.");'; + let outdirs: Record = {}; + + const customPlugins: Options['customPlugins'] = (opts, context) => { + context.inject({ + type: 'file', + value: 'https://example.com/distant-file.js', + }); + context.inject({ + type: 'file', + value: './src/fixtures/file-to-inject.js', + }); + context.inject({ + type: 'code', + value: codeContent, + }); + + return [ + { + name: 'get-outdirs', + writeBundle() { + // Store the seeded outdir to inspect the produced files. + outdirs[context.bundler.fullName] = context.bundler.outDir; + }, + }, + ]; + }; + + describe('Basic build', () => { + let nockScope: nock.Scope; + let cleanup: CleanupFn; + + beforeAll(async () => { + nockScope = nock('https://example.com') + .get('/distant-file.js') + .times(BUNDLERS.length) + .reply(200, distantFileContent); + + cleanup = await runBundlers({ + customPlugins, + }); + }); + + afterAll(async () => { + outdirs = {}; + nock.cleanAll(); + await cleanup(); + }); + + test('Should have requested the distant file for each bundler.', () => { + expect(nockScope.isDone()).toBe(true); + }); + + describe.each(BUNDLERS)('$name | $version', ({ name }) => { + test.each([ + { type: 'some string', content: codeContent }, + { type: 'a local file', content: localFileContent }, + { type: 'a distant file', content: distantFileContent }, + ])('Should inject $type once.', ({ content }) => { + const files = glob.sync(path.resolve(outdirs[name], '*.js')); + const fullContent = files.map((file) => readFileSync(file, 'utf8')).join('\n'); + + // We have a single entry, so the content should be repeated only once. + expect(fullContent).toRepeatStringTimes(content, 1); + }); + }); + }); + + describe('Complex build', () => { + let nockScope: nock.Scope; + let cleanup: CleanupFn; + + beforeAll(async () => { + nockScope = nock('https://example.com') + .get('/distant-file.js') + .times(BUNDLERS.length) + .reply(200, distantFileContent); + + cleanup = await runBundlers( + { + customPlugins, + }, + getComplexBuildOverrides(), + ); + }); + + afterAll(async () => { + outdirs = {}; + nock.cleanAll(); + await cleanup(); + }); + + test('Should have requested the distant file for each bundler.', () => { + expect(nockScope.isDone()).toBe(true); + }); + + describe.each(BUNDLERS)('$name | $version', ({ name }) => { + test.each([ + { type: 'some string', content: codeContent }, + { type: 'a local file', content: localFileContent }, + { type: 'a distant file', content: distantFileContent }, + ])('Should inject $type.', ({ content }) => { + const files = glob.sync(path.resolve(outdirs[name], '*.js')); + const fullContent = files.map((file) => readFileSync(file, 'utf8')).join('\n'); + + // We don't know exactly how each bundler will concattenate the files. + // Since we have two entries here, we can expect the content + // to be repeated at least once and at most twice. + expect(fullContent).toRepeatStringRange(content, [1, 2]); + }); + }); + }); +}); diff --git a/packages/tests/src/fixtures/file-to-inject.js b/packages/tests/src/fixtures/file-to-inject.js new file mode 100644 index 00000000..8c9d16b3 --- /dev/null +++ b/packages/tests/src/fixtures/file-to-inject.js @@ -0,0 +1,5 @@ +// 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. + +console.log("Hello injection from local file."); diff --git a/packages/tests/src/helpers/mocks.ts b/packages/tests/src/helpers/mocks.ts index 6ac5a7cb..ca417fd0 100644 --- a/packages/tests/src/helpers/mocks.ts +++ b/packages/tests/src/helpers/mocks.ts @@ -39,6 +39,7 @@ export const getContextMock = (options: Partial = {}): GlobalCont errors: [], }, cwd: '/cwd/path', + inject: jest.fn(), start: Date.now(), version: 'FAKE_VERSION', ...options, diff --git a/yarn.lock b/yarn.lock index b83f61f6..7694216c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1863,6 +1863,7 @@ __metadata: esbuild: "npm:0.21.5" faker: "npm:5.5.3" fs-extra: "npm:7.0.1" + glob: "npm:11.0.0" jest: "npm:29.7.0" memfs: "npm:4.9.2" nock: "npm:14.0.0-beta.7"