diff --git a/.eslintrc.js b/.eslintrc.js index eff08d41f..0b6142c0e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -230,7 +230,8 @@ module.exports = { ], 'no-delete-var': 'error', 'no-label-var': 'error', - 'no-shadow': 'error', + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': ['error'], 'no-shadow-restricted-names': 'error', 'no-undef': 'error', 'no-undef-init': 'error', @@ -390,7 +391,12 @@ module.exports = { }, }, { - files: ['rollup.config.mjs', 'packages/core/**/*', 'packages/published/**/*'], + files: [ + 'rollup.config.mjs', + 'packages/core/**/*', + 'packages/published/**/*', + 'packages/plugins/**/built/*', + ], rules: { 'import/no-extraneous-dependencies': 'off', }, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 27de7a7c1..a8d8f13f9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -23,6 +23,10 @@ packages/tests/src/plugins/injection @yoannmoin packages/plugins/telemetry @DataDog/frontend-devx @yoannmoinet packages/tests/src/plugins/telemetry @DataDog/frontend-devx @yoannmoinet +# Error Tracking +packages/plugins/error-tracking @yoannmoinet +packages/tests/src/plugins/error-tracking @yoannmoinet + # Rum -packages/plugins/rum @DataDog/rum @yoannmoinet -packages/tests/src/plugins/rum @DataDog/rum @yoannmoinet +packages/plugins/rum @yoannmoinet +packages/tests/src/plugins/rum @yoannmoinet \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0ba48f334..507327191 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,6 +21,7 @@ jobs: with: node-version: ${{matrix.node}}.x - run: yarn install + - run: yarn build:all - run: yarn test lint: diff --git a/.node-version b/.node-version index a9d087399..1117d417c 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -18.19.0 +18.20.5 diff --git a/.vscode/settings.json b/.vscode/settings.json index be309b700..65d300429 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,5 +25,6 @@ "**/.yarn/": true, "**/.yarnrc.yml": true, "**/yarn.lock": true - } + }, + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/.yarn/cache/@datadog-browser-core-npm-6.0.0-6d2c41f4eb-283097b37b.zip b/.yarn/cache/@datadog-browser-core-npm-6.0.0-6d2c41f4eb-283097b37b.zip new file mode 100644 index 000000000..64521c7f7 Binary files /dev/null and b/.yarn/cache/@datadog-browser-core-npm-6.0.0-6d2c41f4eb-283097b37b.zip differ diff --git a/.yarn/cache/@datadog-browser-rum-core-npm-6.0.0-e328c70bce-92ff7c44a4.zip b/.yarn/cache/@datadog-browser-rum-core-npm-6.0.0-e328c70bce-92ff7c44a4.zip new file mode 100644 index 000000000..d668a4096 Binary files /dev/null and b/.yarn/cache/@datadog-browser-rum-core-npm-6.0.0-e328c70bce-92ff7c44a4.zip differ diff --git a/.yarn/cache/@datadog-browser-rum-npm-6.0.0-8404f49122-e50c3955af.zip b/.yarn/cache/@datadog-browser-rum-npm-6.0.0-8404f49122-e50c3955af.zip new file mode 100644 index 000000000..db766566c Binary files /dev/null and b/.yarn/cache/@datadog-browser-rum-npm-6.0.0-8404f49122-e50c3955af.zip differ diff --git a/.yarn/cache/@datadog-browser-rum-react-npm-6.0.0-c26f101832-f13aec89de.zip b/.yarn/cache/@datadog-browser-rum-react-npm-6.0.0-c26f101832-f13aec89de.zip new file mode 100644 index 000000000..03d6d5ef6 Binary files /dev/null and b/.yarn/cache/@datadog-browser-rum-react-npm-6.0.0-c26f101832-f13aec89de.zip differ diff --git a/.yarn/cache/@rollup-plugin-terser-npm-0.4.4-c6896dd264-a5e066ddea.zip b/.yarn/cache/@rollup-plugin-terser-npm-0.4.4-c6896dd264-a5e066ddea.zip new file mode 100644 index 000000000..4330a00fd Binary files /dev/null and b/.yarn/cache/@rollup-plugin-terser-npm-0.4.4-c6896dd264-a5e066ddea.zip differ diff --git a/.yarn/cache/smob-npm-1.5.0-acdaaf382d-a1ea453bce.zip b/.yarn/cache/smob-npm-1.5.0-acdaaf382d-a1ea453bce.zip new file mode 100644 index 000000000..4a65fc40d Binary files /dev/null and b/.yarn/cache/smob-npm-1.5.0-acdaaf382d-a1ea453bce.zip differ diff --git a/.yarn/cache/terser-npm-5.37.0-7dbdc43c6e-3afacf7c38.zip b/.yarn/cache/terser-npm-5.37.0-7dbdc43c6e-3afacf7c38.zip new file mode 100644 index 000000000..9ec2ef107 Binary files /dev/null and b/.yarn/cache/terser-npm-5.37.0-7dbdc43c6e-3afacf7c38.zip differ diff --git a/LICENSE b/LICENSE index 98d1c7c2f..6f944311d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Datadog +Copyright (c) 2025 Datadog Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/LICENSES-3rdparty.csv b/LICENSES-3rdparty.csv index 8405fc40f..62fd35ff7 100644 --- a/LICENSES-3rdparty.csv +++ b/LICENSES-3rdparty.csv @@ -115,6 +115,10 @@ Component,Origin,Licence,Copyright @babel/types,npm,MIT,The Babel Team (https://babel.dev/docs/en/next/babel-types) @bcoe/v8-coverage,npm,MIT,Charles Samborski (https://demurgos.github.io/v8-coverage) @cspotcode/source-map-support,npm,MIT,(https://www.npmjs.com/package/@cspotcode/source-map-support) +@datadog/browser-core,npm,Apache-2.0,(https://www.npmjs.com/package/@datadog/browser-core) +@datadog/browser-rum,virtual,Apache-2.0,(https://www.npmjs.com/package/@datadog/browser-rum) +@datadog/browser-rum-core,npm,Apache-2.0,(https://www.npmjs.com/package/@datadog/browser-rum-core) +@datadog/browser-rum-react,virtual,Apache-2.0,(https://www.npmjs.com/package/@datadog/browser-rum-react) @esbuild/darwin-arm64,npm,MIT,(https://www.npmjs.com/package/@esbuild/darwin-arm64) @esbuild/linux-x64,npm,MIT,(https://www.npmjs.com/package/@esbuild/linux-x64) @eslint-community/eslint-utils,virtual,MIT,Toru Nagashima (https://github.com/eslint-community/eslint-utils#readme) @@ -171,6 +175,7 @@ Component,Origin,Licence,Copyright @rollup/plugin-commonjs,virtual,MIT,Rich Harris (https://github.com/rollup/plugins/tree/master/packages/commonjs/#readme) @rollup/plugin-json,virtual,MIT,rollup (https://github.com/rollup/plugins/tree/master/packages/json#readme) @rollup/plugin-node-resolve,virtual,MIT,Rich Harris (https://github.com/rollup/plugins/tree/master/packages/node-resolve/#readme) +@rollup/plugin-terser,virtual,MIT,Peter Placzek (https://github.com/rollup/plugins/tree/master/packages/terser#readme) @rollup/pluginutils,virtual,MIT,Rich Harris (https://github.com/rollup/plugins/tree/master/packages/pluginutils#readme) @rollup/rollup-darwin-arm64,npm,MIT,Lukas Taegert-Atkinson (https://rollupjs.org/) @rspack/binding,npm,MIT,(https://rspack.dev) @@ -822,6 +827,7 @@ simple-git,npm,MIT,Steve King (https://www.npmjs.com/package/simple-git) sisteransi,npm,MIT,Terkel Gjervig (https://terkel.com) slash,npm,MIT,Sindre Sorhus (sindresorhus.com) slice-ansi,npm,MIT,(https://www.npmjs.com/package/slice-ansi) +smob,npm,MIT,Peter Placzek (https://github.com/Tada5hi/smob#readme) snapdragon,npm,MIT,Jon Schlinkert (https://github.com/jonschlinkert/snapdragon) snapdragon-node,npm,MIT,Jon Schlinkert (https://github.com/jonschlinkert/snapdragon-node) snapdragon-util,npm,MIT,Jon Schlinkert (https://github.com/jonschlinkert/snapdragon-util) diff --git a/NOTICE b/NOTICE index 982aa64b0..78aeaf14a 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ Datadog build-plugins -Copyright 2024-present Datadog, Inc. +Copyright 2025-present Datadog, Inc. This product includes software developed at Datadog (https://www.datadoghq.com/). diff --git a/README.md b/README.md index 382fdbf6d..b03e84f2b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # Datadog Build Plugins A set of bundler plugins for: - - Webpack Webpack - - Vite Vite - - ESBuild esbuild - - Rollup Rollup - - Rspack Rspack + +- [ESBuild esbuild `@datadog/esbuild-plugin`](/packages/published/esbuild-plugin#readme) +- [Rollup Rollup `@datadog/rollup-plugin`](/packages/published/rollup-plugin#readme) +- [Rspack Rspack `@datadog/rspack-plugin`](/packages/published/rspack-plugin#readme) +- [Vite Vite `@datadog/vite-plugin`](/packages/published/vite-plugin#readme) +- [Webpack Webpack `@datadog/webpack-plugin`](/packages/published/webpack-plugin#readme) + To interact with Datadog directly from your builds. @@ -17,208 +19,45 @@ To interact with Datadog directly from your builds. -- [Bundler Plugins](#bundler-plugins) - - [ESBuild](#-esbuild) - - [Rollup](#-rollup) - - [Rspack](#-rspack) - - [Vite](#-vite) - - [Webpack](#-webpack) -- [Features](#features) - - [RUM](#rum-----) - - [Telemetry](#telemetry-----) +- [Installation](#installation) +- [Usage](#usage) - [Configuration](#configuration) - [`auth.apiKey`](#authapikey) + - [`auth.appKey`](#authappkey) - [`logLevel`](#loglevel) - [`customPlugins`](#customplugins) +- [Features](#features) + - [Error Tracking](#error-tracking-----) + - [Rum](#rum-----) + - [Telemetry](#telemetry-----) - [Contributing](#contributing) - [License](#license) -## Bundler Plugins - - -### ESBuild ESBuild - -`@datadog/esbuild-plugin` - -#### Installation -- Yarn - -```bash -yarn add -D @datadog/esbuild-plugin -``` - -- NPM - -```bash -npm install --save-dev @datadog/esbuild-plugin -``` - - -#### Usage -```js -const { datadogEsbuildPlugin } = require('@datadog/esbuild-plugin'); - -require('esbuild').build({ - plugins: [ - datadogEsbuildPlugin({ - // Configuration - }), - ], -}); -``` - -> [!TIP] -> It is important to have the plugin in the first position in order to report every other plugins. - - -[📝 More details ➡️](/packages/published/esbuild-plugin#readme) - -### Rollup Rollup - -`@datadog/rollup-plugin` - -#### Installation -- Yarn - -```bash -yarn add -D @datadog/rollup-plugin -``` - -- NPM - -```bash -npm install --save-dev @datadog/rollup-plugin -``` - - -#### Usage -Inside your `rollup.config.js`. - -```js -import { datadogRollupPlugin } from '@datadog/rollup-plugin'; - -export default { - plugins: [ - datadogRollupPlugin({ - // Configuration - }), - ], -}; -``` - -> [!TIP] -> It is important to have the plugin in the first position in order to report every other plugins. - - -[📝 More details ➡️](/packages/published/rollup-plugin#readme) - -### Rspack Rspack - -`@datadog/rspack-plugin` - -#### Installation -- Yarn - -```bash -yarn add -D @datadog/rspack-plugin -``` - -- NPM - -```bash -npm install --save-dev @datadog/rspack-plugin -``` - - -#### Usage -Inside your `rspack.config.js`. - -```js -const { datadogRspackPlugin } = require('@datadog/rspack-plugin'); - -module.exports = { - plugins: [ - datadogRspackPlugin({ - // Configuration - }), - ], -}; -``` - -> [!TIP] -> It is important to have the plugin in the first position in order to report every other plugins. - - -[📝 More details ➡️](/packages/published/rspack-plugin#readme) - -### Vite Vite - -`@datadog/vite-plugin` - -#### Installation -- Yarn - -```bash -yarn add -D @datadog/vite-plugin -``` - -- NPM - -```bash -npm install --save-dev @datadog/vite-plugin -``` - - -#### Usage -Inside your `vite.config.js`. - -```js -import { datadogVitePlugin } from '@datadog/vite-plugin'; -import { defineConfig } from 'vite' - -export default defineConfig({ - plugins: [ - datadogVitePlugin({ - // Configuration - }), - ], -}; -``` - -> [!TIP] -> It is important to have the plugin in the first position in order to report every other plugins. - +## Installation -[📝 More details ➡️](/packages/published/vite-plugin#readme) - -### Webpack Webpack - -`@datadog/webpack-plugin` - -#### Installation - Yarn ```bash -yarn add -D @datadog/webpack-plugin +yarn add -D @datadog/{{bundler}}-plugin ``` - NPM ```bash -npm install --save-dev @datadog/webpack-plugin +npm install --save-dev @datadog/{{bundler}}-plugin ``` +## Usage -#### Usage -Inside your `webpack.config.js`. +In your bundler's configuration file: ```js -const { datadogWebpackPlugin } = require('@datadog/webpack-plugin'); +const { datadog{{Bundler}}Plugin } = require('@datadog/{{bundler}}-plugin'); -module.exports = { +export const config = { plugins: [ - datadogWebpackPlugin({ + datadog{{Bundler}}Plugin({ // Configuration }), ], @@ -226,67 +65,22 @@ module.exports = { ``` > [!TIP] -> It is important to have the plugin in the first position in order to report every other plugins. - +> It is best to have the plugin in the first position in order to report every other plugins. -[📝 More details ➡️](/packages/published/webpack-plugin#readme) +Follow the specific documentation for each bundler: + +- [ESBuild esbuild `@datadog/esbuild-plugin`](/packages/published/esbuild-plugin#readme) +- [Rollup Rollup `@datadog/rollup-plugin`](/packages/published/rollup-plugin#readme) +- [Rspack Rspack `@datadog/rspack-plugin`](/packages/published/rspack-plugin#readme) +- [Vite Vite `@datadog/vite-plugin`](/packages/published/vite-plugin#readme) +- [Webpack Webpack `@datadog/webpack-plugin`](/packages/published/webpack-plugin#readme) -## Features - - -### RUM ESBuild Rollup Rspack Vite Webpack - -> Interact with our Real User Monitoring product (RUM) in Datadog directly from your build system. - -```typescript -datadogWebpackPlugin({ - rum?: { - disabled?: boolean, - sourcemaps?: { - bailOnError?: boolean, - dryRun?: boolean, - intakeUrl?: string, - maxConcurrency?: number, - minifiedPathPrefix: string, - releaseVersion: string, - service: string, - }, - } -}); -``` - -[📝 Full documentation ➡️](/packages/plugins/rum#readme) - -### Telemetry ESBuild Rollup Rspack Vite Webpack - -> Display and send telemetry data as metrics to Datadog. - -```typescript -datadogWebpackPlugin({ - telemetry?: { - disabled?: boolean, - enableTracing?: boolean, - endPoint?: string, - output?: boolean - | string - | { - destination: string, - timings?: boolean, - metrics?: boolean, - }, - prefix?: string, - tags?: string[], - timestamp?: number, - filters?: ((metric: Metric) => Metric | null)[], - } -}); -``` +## Configuration -[📝 Full documentation ➡️](/packages/plugins/telemetry#readme) - +
-## Configuration +Full configuration object ```typescript @@ -296,7 +90,7 @@ datadogWebpackPlugin({ }; customPlugins?: (options: Options, context: GlobalContext, log: Logger) => UnpluginPlugin[]; logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'none'; - rum?: { + errorTracking?: { disabled?: boolean; sourcemaps?: { bailOnError?: boolean; @@ -308,6 +102,41 @@ datadogWebpackPlugin({ service: string; }; }; + rum?: { + disabled?: boolean; + react?: { + router?: boolean; + }; + sdk?: { + actionNameAttribute?: string; + allowedTracingUrls?: string[]; + allowUntrustedEvents?: boolean; + applicationId: string; + clientToken?: string; + compressIntakeRequests?: boolean; + defaultPrivacyLevel?: 'mask' | 'mask-user-input' | 'allow'; + enablePrivacyForActionName?: boolean; + env?: string; + excludedActivityUrls?: string[]; + proxy?: string; + service?: string; + sessionReplaySampleRate?: number; + sessionSampleRate?: number; + silentMultipleInit?: boolean; + site?: string; + startSessionReplayRecordingManually?: boolean; + storeContextsAcrossPages?: boolean; + telemetrySampleRate?: number; + traceSampleRate?: number; + trackingConsent?: 'granted' | 'not_granted'; + trackLongTasks?: boolean; + trackResources?: boolean; + trackUserInteractions?: boolean; + trackViewsManually?: boolean; + version?: string; + workerUrl?: string; + }; + }; telemetry?: { disabled?: boolean; enableTracing?: boolean; @@ -328,12 +157,24 @@ datadogWebpackPlugin({ ``` +
+ ### `auth.apiKey` > default `null` In order to interact with Datadog, you have to use [your own API Key](https://app.datadoghq.com/organization-settings/api-keys). +### `auth.appKey` + +> default `null` + +In order to interact with Datadog, you have to use [your own Application Key](https://app.datadoghq.com/organization-settings/application-keys). + +**Required permissions**: + +- `rum_apps_read` if you use `rum.sdk` without providing `rum.sdk.clientToken`. + ### `logLevel` > default: `'warn'` @@ -367,7 +208,11 @@ Your function will receive three arguments: - `context`: The global context shared accross our plugin. - `log`: A [logger](/packages/factory/README.md#logger) to display messages. -The `context` is a shared object that is mutated during the build process. It contains the following properties: +The `context` is a shared object that is mutated during the build process. + +
+ +Full context object ```typescript @@ -433,16 +278,129 @@ type GlobalContext = { version: string; } ``` + -> [!NOTE] -> Some parts of the context are only available after certain hooks: -> - `context.bundler.rawConfig` is added in the `buildStart` hook. -> - `context.build.*` is populated in the `writeBundle` hook. -> - `context.git.*` is populated in the `buildStart` hook. +
- +#### [📝 Full documentation ➡️](/packages/factory#global-context) + +## Features + + +### Error Tracking ESBuild Rollup Rspack Vite Webpack + +> Interact with Error Tracking directly from your build system. + +#### [📝 Full documentation ➡️](/packages/plugins/error-tracking#readme) + +
-Your function will need to return an array of [Unplugin Plugins definitions](https://unplugin.unjs.io/guide/#supported-hooks). +Configuration + +```typescript +datadogWebpackPlugin({ + errorTracking?: { + disabled?: boolean, + sourcemaps?: { + bailOnError?: boolean, + dryRun?: boolean, + intakeUrl?: string, + maxConcurrency?: number, + minifiedPathPrefix: string, + releaseVersion: string, + service: string, + }, + } +}); +``` + +
+ +### Rum ESBuild Rollup Rspack Vite Webpack + +> Interact with Real User Monitoring (RUM) directly from your build system. + +#### [📝 Full documentation ➡️](/packages/plugins/rum#readme) + +
+ +Configuration + +```typescript +datadogWebpackPlugin({ + rum?: { + disabled?: boolean, + react?: { + router?: boolean, + }, + sdk?: { + actionNameAttribute?: string, + allowedTracingUrls?: string[], + allowUntrustedEvents?: boolean, + applicationId: string, + clientToken?: string, + compressIntakeRequests?: boolean, + defaultPrivacyLevel?: 'mask' | 'mask-user-input' | 'allow', + enablePrivacyForActionName?: boolean, + env?: string, + excludedActivityUrls?: string[], + proxy?: string, + service?: string, + sessionReplaySampleRate?: number, + sessionSampleRate?: number, + silentMultipleInit?: boolean, + site?: string, + startSessionReplayRecordingManually?: boolean, + storeContextsAcrossPages?: boolean, + telemetrySampleRate?: number, + traceSampleRate?: number, + trackingConsent?: 'granted' | 'not_granted', + trackLongTasks?: boolean, + trackResources?: boolean, + trackUserInteractions?: boolean, + trackViewsManually?: boolean, + version?: string, + workerUrl?: string, + }, + } +}); +``` + +
+ +### Telemetry ESBuild Rollup Rspack Vite Webpack + +> Display and send telemetry data as metrics to Datadog. + +#### [📝 Full documentation ➡️](/packages/plugins/telemetry#readme) + +
+ +Configuration + +```typescript +datadogWebpackPlugin({ + telemetry?: { + disabled?: boolean, + enableTracing?: boolean, + endPoint?: string, + output?: boolean + | string + | { + destination: string, + timings?: boolean, + metrics?: boolean, + }, + prefix?: string, + tags?: string[], + timestamp?: number, + filters?: ((metric: Metric) => Metric | null)[], + } +}); +``` + +
+ ## Contributing @@ -452,4 +410,4 @@ Check out [CONTRIBUTING.md](/CONTRIBUTING.md) for more information about how to [MIT](/LICENSE) -[Back to top :arrow_up:](#top) +### [Back to top :arrow_up:](#top) diff --git a/package.json b/package.json index d4bbd2602..fbb2c2344 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,10 @@ "workspaces": [ "packages/*", "packages/plugins/*", - "packages/published/*", - "packages/tests/src/_jest/fixtures/project" + "packages/published/*" ], "volta": { - "node": "18.19.0", + "node": "18.20.5", "yarn": "1.22.19" }, "scripts": { @@ -27,8 +26,7 @@ "loop": "yarn workspaces foreach -Apti --include \"@datadog/*\" --exclude \"@datadog/build-plugins\"", "oss": "yarn cli oss -d packages -l mit", "publish:all": "yarn loop --no-private npm publish", - "test": "yarn build:all && yarn workspace @dd/tests test", - "test:noisy": "yarn workspace @dd/tests test:noisy", + "test": "yarn workspace @dd/tests test", "typecheck:all": "yarn workspaces foreach -Apti run typecheck", "version:all": "yarn loop version --deferred ${0} && yarn version apply --all", "watch:all": "yarn loop run watch" diff --git a/packages/core/package.json b/packages/core/package.json index f77ffaf15..abfafa166 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,12 +24,14 @@ }, "dependencies": { "async-retry": "1.3.3", - "chalk": "2.3.1" + "chalk": "2.3.1", + "glob": "11.0.0" }, "devDependencies": { "@types/async-retry": "1.4.8", "@types/chalk": "2.2.0", "@types/node": "^18", + "esbuild": "0.24.0", "typescript": "5.4.3", "unplugin": "1.16.0" } diff --git a/packages/core/src/helpers.ts b/packages/core/src/helpers.ts index 582719656..9c2c5abac 100644 --- a/packages/core/src/helpers.ts +++ b/packages/core/src/helpers.ts @@ -4,12 +4,14 @@ import { INJECTED_FILE } from '@dd/core/constants'; import retry from 'async-retry'; +import type { PluginBuild } from 'esbuild'; import fsp from 'fs/promises'; import fs from 'fs'; +import { glob } from 'glob'; import path from 'path'; import type { RequestInit } from 'undici-types'; -import type { RequestOpts } from './types'; +import type { GlobalContext, Logger, RequestOpts, ResolvedEntry } from './types'; // Format a duration 0h 0m 0s 0ms export const formatDuration = (duration: number) => { @@ -25,19 +27,79 @@ export const formatDuration = (duration: number) => { }${milliseconds ? `${milliseconds}ms` : ''}`.trim(); }; -export const getResolvedPath = (filepath: string) => { - try { - return require.resolve(filepath); - } catch (e) { - return filepath; +// https://esbuild.github.io/api/#glob-style-entry-points +const getAllEntryFiles = (filepath: string): string[] => { + if (!filepath.includes('*')) { + return [filepath]; } + + const files = glob.sync(filepath); + return files; +}; + +// Parse, resolve and return all the entries of esbuild. +export const getEsbuildEntries = async ( + build: PluginBuild, + context: GlobalContext, + log: Logger, +): Promise => { + const entries: { name?: string; resolved: string; original: string }[] = []; + const entryPoints = build.initialOptions.entryPoints; + const entryPaths: { name?: string; path: string }[] = []; + const resolutionErrors: string[] = []; + + if (Array.isArray(entryPoints)) { + for (const entry of entryPoints) { + const fullPath = entry && typeof entry === 'object' ? entry.in : entry; + entryPaths.push({ path: fullPath }); + } + } else if (typeof entryPoints === 'object') { + entryPaths.push( + ...Object.entries(entryPoints).map(([name, filepath]) => ({ name, path: filepath })), + ); + } + + // Resolve all the paths. + const proms = entryPaths + .flatMap((entry) => + getAllEntryFiles(entry.path).map<[{ name?: string; path: string }, string]>((p) => [ + entry, + p, + ]), + ) + .map(async ([entry, p]) => { + const result = await build.resolve(p, { + kind: 'entry-point', + resolveDir: context.cwd, + }); + + if (result.errors.length) { + resolutionErrors.push(...result.errors.map((e) => e.text)); + } + + if (result.path) { + // Store them for later use. + entries.push({ + name: entry.name, + resolved: result.path, + original: entry.path, + }); + } + }); + + for (const resolutionError of resolutionErrors) { + log.error(resolutionError); + } + + await Promise.all(proms); + return entries; }; export const ERROR_CODES_NO_RETRY = [400, 403, 413]; export const NB_RETRIES = 5; // Do a retriable fetch. export const doRequest = (opts: RequestOpts): Promise => { - const { url, method = 'GET', getData, onRetry, type = 'text' } = opts; + const { auth, url, method = 'GET', getData, onRetry, type = 'text' } = opts; return retry( async (bail: (e: Error) => void, attempt: number) => { let response: Response; @@ -48,14 +110,24 @@ export const doRequest = (opts: RequestOpts): Promise => { // https://github.com/nodejs/node/issues/46221 duplex: 'half', }; + let requestHeaders: RequestInit['headers'] = {}; + + // Do auth if present. + if (auth?.apiKey) { + requestHeaders['DD-API-KEY'] = auth.apiKey; + } + + if (auth?.appKey) { + requestHeaders['DD-APPLICATION-KEY'] = auth.appKey; + } if (typeof getData === 'function') { const { data, headers } = await getData(); requestInit.body = data; - requestInit.headers = headers; + requestHeaders = { ...requestHeaders, ...headers }; } - response = await fetch(url, requestInit); + response = await fetch(url, { ...requestInit, headers: requestHeaders }); } catch (error: any) { // We don't want to retry if there is a non-fetch related error. bail(error); @@ -169,3 +241,6 @@ export const readJsonSync = (filepath: string) => { const data = fs.readFileSync(filepath, { encoding: 'utf-8' }); return JSON.parse(data); }; + +let index = 0; +export const getUniqueId = () => `${Date.now()}.${performance.now()}.${++index}`; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 11e6a6b46..f94518e59 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,6 +9,8 @@ import type { TrackedFilesMatcher } from '@dd/internal-git-plugin/trackedFilesMatcher'; /* eslint-disable arca/import-ordering */ // #imports-injection-marker +import type { ErrorTrackingOptions } from '@dd/error-tracking-plugin/types'; +import type * as errorTracking from '@dd/error-tracking-plugin'; import type { RumOptions } from '@dd/rum-plugin/types'; import type * as rum from '@dd/rum-plugin'; import type { TelemetryOptions } from '@dd/telemetry-plugin/types'; @@ -41,9 +43,16 @@ export type SerializedInput = Assign; export type BuildReport = { + bundler: Omit; errors: string[]; warnings: string[]; - logs: { pluginName: string; type: LogLevel; message: string; time: number }[]; + logs: { + bundler: BundlerFullName; + pluginName: string; + type: LogLevel; + message: string; + time: number; + }[]; entries?: Entry[]; inputs?: Input[]; outputs?: Output[]; @@ -74,7 +83,18 @@ export type BundlerReport = { version: string; }; -export type ToInjectItem = { type: 'file' | 'code'; value: string; fallback?: ToInjectItem }; +export type InjectedValue = string | (() => Promise); +export enum InjectPosition { + BEFORE, + MIDDLE, + AFTER, +} +export type ToInjectItem = { + type: 'file' | 'code'; + value: InjectedValue; + position?: InjectPosition; + fallback?: ToInjectItem; +}; export type GetLogger = (name: string) => Logger; export type Logger = { @@ -116,6 +136,7 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none'; export type AuthOptions = { apiKey?: string; + appKey?: string; }; export interface BaseOptions { @@ -127,6 +148,7 @@ export interface BaseOptions { export interface Options extends BaseOptions { // Each product should have a unique entry. // #types-injection-marker + [errorTracking.CONFIG_KEY]?: ErrorTrackingOptions; [rum.CONFIG_KEY]?: RumOptions; [telemetry.CONFIG_KEY]?: TelemetryOptions; // #types-injection-marker @@ -138,11 +160,14 @@ export type OptionsWithDefaults = Assign; export type PluginName = `datadog-${Lowercase}-plugin`; -type Data = { data: BodyInit; headers?: Record }; +type Data = { data?: BodyInit; headers?: Record }; export type RequestOpts = { url: string; + auth?: AuthOptions; method?: string; getData?: () => Promise | Data; type?: 'json' | 'text'; onRetry?: (error: Error, attempt: number) => void; }; + +export type ResolvedEntry = { name?: string; resolved: string; original: string }; diff --git a/packages/factory/README.md b/packages/factory/README.md index 688963d77..ee2c5dae1 100644 --- a/packages/factory/README.md +++ b/packages/factory/README.md @@ -27,27 +27,35 @@ Most of the time they will interact via the global context. > This will populate `context.build` with a bunch of data coming from the build. -[📝 Full documentation ➡️](/packages/plugins/build-report#readme) +#### [📝 Full documentation ➡️](/packages/plugins/build-report#readme) + ### Bundler Report > A very basic report on the currently used bundler.
> It is useful to unify some configurations. -[📝 Full documentation ➡️](/packages/plugins/bundler-report#readme) +#### [📝 Full documentation ➡️](/packages/plugins/bundler-report#readme) + ### Git > Adds repository data to the global context from the `buildStart` hook. -[📝 Full documentation ➡️](/packages/plugins/git#readme) +#### [📝 Full documentation ➡️](/packages/plugins/git#readme) + ### Injection -> This is used to prepend some code to the produced bundle.
-> Particularly useful if you want to share some global context, or to automatically inject some SDK. +> This is used to inject some code to the produced bundle.
+> Particularly useful : +> - to share some global context. +> - to automatically inject some SDK. +> - to initialise some global dependencies. +> - ... + +#### [📝 Full documentation ➡️](/packages/plugins/injection#readme) -[📝 Full documentation ➡️](/packages/plugins/injection#readme) ## Logger @@ -166,3 +174,5 @@ type GlobalContext = { > - `context.bundler.rawConfig` is added in the `buildStart` hook. > - `context.build.*` is populated in the `writeBundle` hook. > - `context.git.*` is populated in the `buildStart` hook. + +Your function will need to return an array of [Unplugin Plugins definitions](https://unplugin.unjs.io/guide/#supported-hooks). diff --git a/packages/factory/package.json b/packages/factory/package.json index 8b63c6ded..3f8177e3e 100644 --- a/packages/factory/package.json +++ b/packages/factory/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@dd/core": "workspace:*", + "@dd/error-tracking-plugin": "workspace:*", "@dd/internal-build-report-plugin": "workspace:*", "@dd/internal-bundler-report-plugin": "workspace:*", "@dd/internal-git-plugin": "workspace:*", diff --git a/packages/factory/src/helpers.ts b/packages/factory/src/helpers.ts index 73c00828a..d8d1d3478 100644 --- a/packages/factory/src/helpers.ts +++ b/packages/factory/src/helpers.ts @@ -2,6 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { getUniqueId } from '@dd/core/helpers'; import type { BuildReport, BundlerFullName, @@ -28,6 +29,7 @@ const logPriority: Record = { export const getLoggerFactory = (build: BuildReport, logLevel: LogLevel = 'warn'): GetLogger => (name) => { + const cleanName = name.replace(/(^datadog-|-plugin$)/g, ''); const log = (text: any, type: LogLevel = 'debug') => { // By default (debug) we print dimmed. let color = c.dim; @@ -44,11 +46,17 @@ export const getLoggerFactory = logFn = console.log; } - const prefix = `[${type}|${name}]`; + const prefix = `[${type}|${build.bundler.fullName}|${cleanName}]`; // Keep a trace of the log in the build report. const content = typeof text === 'string' ? text : JSON.stringify(text, null, 2); - build.logs.push({ pluginName: name, type, message: content, time: Date.now() }); + build.logs.push({ + bundler: build.bundler.fullName, + pluginName: cleanName, + type, + message: content, + time: Date.now(), + }); if (type === 'error') { build.errors.push(content); } @@ -65,7 +73,7 @@ export const getLoggerFactory = return { getLogger: (subName: string) => { const logger = getLoggerFactory(build, logLevel); - return logger(`${name}:${subName}`); + return logger(`${cleanName}:${subName}`); }, error: (text: any) => log(text, 'error'), warn: (text: any) => log(text, 'warn'), @@ -84,7 +92,7 @@ export const getContext = ({ options: OptionsWithDefaults; bundlerName: BundlerName; bundlerVersion: string; - injections: ToInjectItem[]; + injections: Map; version: FactoryMeta['version']; }): GlobalContext => { const cwd = process.cwd(); @@ -93,21 +101,26 @@ export const getContext = ({ errors: [], warnings: [], logs: [], + bundler: { + name: bundlerName, + fullName: `${bundlerName}${variant}` as BundlerFullName, + variant, + version: bundlerVersion, + }, }; const context: GlobalContext = { auth: options.auth, pluginNames: [], bundler: { - name: bundlerName, - fullName: `${bundlerName}${variant}` as BundlerFullName, - variant, + ...build.bundler, + // This will be updated in the bundler-report plugin once we have the configuration. outDir: cwd, - version: bundlerVersion, }, build, + // This will be updated in the bundler-report plugin once we have the configuration. cwd, inject: (item: ToInjectItem) => { - injections.push(item); + injections.set(getUniqueId(), item); }, start: Date.now(), version, diff --git a/packages/factory/src/index.ts b/packages/factory/src/index.ts index 72043aa56..ac4596b4a 100644 --- a/packages/factory/src/index.ts +++ b/packages/factory/src/index.ts @@ -2,6 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* eslint-disable arca/import-ordering, arca/newline-after-import-section */ // This file is mostly generated. // Anything between // - #imports-injection-marker @@ -22,11 +23,13 @@ import type { } from '@dd/core/types'; import type { UnpluginContextMeta, UnpluginInstance, UnpluginOptions } from 'unplugin'; import { createUnplugin } from 'unplugin'; +import chalk from 'chalk'; import { getContext, getLoggerFactory, validateOptions } from './helpers'; -/* eslint-disable arca/import-ordering, arca/newline-after-import-section */ // #imports-injection-marker +import type { OptionsWithErrorTracking } from '@dd/error-tracking-plugin/types'; +import * as errorTracking from '@dd/error-tracking-plugin'; import type { OptionsWithRum } from '@dd/rum-plugin/types'; import * as rum from '@dd/rum-plugin'; import type { OptionsWithTelemetry } from '@dd/telemetry-plugin/types'; @@ -37,10 +40,10 @@ import { getGitPlugins } from '@dd/internal-git-plugin'; import { getInjectionPlugins } from '@dd/internal-injection-plugin'; // #imports-injection-marker // #types-export-injection-marker +export type { types as ErrorTrackingTypes } from '@dd/error-tracking-plugin'; export type { types as RumTypes } from '@dd/rum-plugin'; export type { types as TelemetryTypes } from '@dd/telemetry-plugin'; // #types-export-injection-marker -/* eslint-enable arca/import-ordering, arca/newline-after-import-section */ export const helpers = { // Each product should have a unique entry. @@ -68,7 +71,7 @@ export const buildPluginFactory = ({ } // Create the global context. - const injections: ToInjectItem[] = []; + const injections: Map = new Map(); const context: GlobalContext = getContext({ options, bundlerVersion: bundler.version || bundler.VERSION, @@ -91,6 +94,7 @@ export const buildPluginFactory = ({ ...getGitPlugins(options, context), ...getInjectionPlugins( bundler, + options, context, injections, getLogger('datadog-injection-plugin'), @@ -110,6 +114,18 @@ export const buildPluginFactory = ({ // Based on configuration add corresponding plugin. // #configs-injection-marker + if ( + options[errorTracking.CONFIG_KEY] && + options[errorTracking.CONFIG_KEY].disabled !== true + ) { + plugins.push( + ...errorTracking.getPlugins( + options as OptionsWithErrorTracking, + context, + getLogger(errorTracking.PLUGIN_NAME), + ), + ); + } if (options[rum.CONFIG_KEY] && options[rum.CONFIG_KEY].disabled !== true) { plugins.push( ...rum.getPlugins(options as OptionsWithRum, context, getLogger(rum.PLUGIN_NAME)), @@ -129,6 +145,18 @@ export const buildPluginFactory = ({ // List all our plugins in the context. context.pluginNames.push(...plugins.map((plugin) => plugin.name)); + // Verify we don't have plugins with the same name, as they would override each other. + const duplicates = new Set( + context.pluginNames.filter( + (name) => context.pluginNames.filter((n) => n === name).length > 1, + ), + ); + if (duplicates.size > 0) { + throw new Error( + `Duplicate plugin names: ${chalk.bold.red(Array.from(duplicates).join(', '))}`, + ); + } + return plugins; }); }; diff --git a/packages/plugins/build-report/package.json b/packages/plugins/build-report/package.json index 77579e3a0..7aecd4e94 100644 --- a/packages/plugins/build-report/package.json +++ b/packages/plugins/build-report/package.json @@ -19,7 +19,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@dd/core": "workspace:*", - "glob": "11.0.0" + "@dd/core": "workspace:*" } } diff --git a/packages/plugins/build-report/src/esbuild.ts b/packages/plugins/build-report/src/esbuild.ts index 709b54665..624d06bd4 100644 --- a/packages/plugins/build-report/src/esbuild.ts +++ b/packages/plugins/build-report/src/esbuild.ts @@ -2,9 +2,16 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { getResolvedPath, isInjectionFile } from '@dd/core/helpers'; -import type { Logger, Entry, GlobalContext, Input, Output, PluginOptions } from '@dd/core/types'; -import { glob } from 'glob'; +import { getEsbuildEntries, isInjectionFile } from '@dd/core/helpers'; +import type { + Logger, + Entry, + GlobalContext, + Input, + Output, + PluginOptions, + ResolvedEntry, +} from '@dd/core/types'; import { cleanName, getAbsolutePath, getType } from './helpers'; @@ -17,56 +24,27 @@ const reIndexMeta = (obj: Record, cwd: string) => }), ); -// https://esbuild.github.io/api/#glob-style-entry-points -const getAllEntryFiles = (filepath: string, cwd: string): string[] => { - if (!filepath.includes('*')) { - return [filepath]; - } - - const files = glob.sync(filepath); - return files; -}; - -// Exported for testing purposes. -export const getEntryNames = ( - entrypoints: string[] | Record | { in: string; out: string }[] | undefined, - context: GlobalContext, -): Map => { - const entryNames = new Map(); - if (Array.isArray(entrypoints)) { - // We don't have an indexed object as entry, so we can't get an entry name from it. - for (const entry of entrypoints) { - const fullPath = entry && typeof entry === 'object' ? entry.in : entry; - const allFiles = getAllEntryFiles(fullPath, context.cwd); - for (const file of allFiles) { - // Using getResolvedPath because entries can be written with unresolved paths. - const cleanedName = cleanName(context, getResolvedPath(file)); - entryNames.set(cleanedName, cleanedName); - } - } - } else if (typeof entrypoints === 'object') { - const entryList = entrypoints ? Object.entries(entrypoints) : []; - for (const [entryName, entryPath] of entryList) { - const allFiles = getAllEntryFiles(entryPath, context.cwd); - for (const file of allFiles) { - const cleanedName = cleanName(context, getResolvedPath(file)); - entryNames.set(cleanedName, entryName); - } - } - } - return entryNames; -}; - export const getEsbuildPlugin = (context: GlobalContext, log: Logger): PluginOptions['esbuild'] => { return { setup(build) { - const cwd = context.cwd; - - // Store entry names based on the configuration. - const entrypoints = build.initialOptions.entryPoints; - const entryNames = getEntryNames(entrypoints, context); + const entryNames = new Map(); + const resolvedEntries: ResolvedEntry[] = []; + + build.onStart(async () => { + // Store entry names based on the configuration. + resolvedEntries.push(...(await getEsbuildEntries(build, context, log))); + for (const entry of resolvedEntries) { + const cleanedName = cleanName(context, entry.resolved); + if (entry.name) { + entryNames.set(cleanedName, entry.name); + } else { + entryNames.set(cleanedName, cleanedName); + } + } + }); build.onEnd((result) => { + const cwd = context.cwd; for (const error of result.errors) { context.build.errors.push(error.text); } diff --git a/packages/plugins/build-report/src/helpers.ts b/packages/plugins/build-report/src/helpers.ts index 5c62745fb..449fce03c 100644 --- a/packages/plugins/build-report/src/helpers.ts +++ b/packages/plugins/build-report/src/helpers.ts @@ -50,6 +50,7 @@ export const serializeBuildReport = (report: BuildReport): SerializedBuildReport // To make it JSON serializable, we need to remove the self references // and replace them with strings, we'll use "filepath" to still have them uniquely identifiable. const jsonReport: SerializedBuildReport = { + bundler: report.bundler, errors: report.errors, warnings: report.warnings, logs: report.logs, @@ -103,6 +104,7 @@ export const serializeBuildReport = (report: BuildReport): SerializedBuildReport // Mostly useful for debugging and testing. export const unserializeBuildReport = (report: SerializedBuildReport): BuildReport => { const buildReport: BuildReport = { + bundler: report.bundler, errors: report.errors, warnings: report.warnings, logs: report.logs, diff --git a/packages/plugins/build-report/src/xpack.ts b/packages/plugins/build-report/src/xpack.ts index 62e6b8a63..e04d7ac30 100644 --- a/packages/plugins/build-report/src/xpack.ts +++ b/packages/plugins/build-report/src/xpack.ts @@ -2,6 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { isInjectionFile } from '@dd/core/helpers'; import type { Logger, Entry, @@ -45,13 +46,13 @@ export const getXpackPlugin = new Map(); const isModuleSupported = (moduleIdentifier?: string): boolean => { - // console.log('Module Identifier supported', moduleIdentifier); return ( // Ignore unidentified modules and runtimes. !!moduleIdentifier && !moduleIdentifier.startsWith('webpack/runtime') && !moduleIdentifier.includes('/webpack4/buildin/') && - !moduleIdentifier.startsWith('multi ') + !moduleIdentifier.startsWith('multi ') && + !isInjectionFile(moduleIdentifier) ); }; diff --git a/packages/plugins/bundler-report/src/index.ts b/packages/plugins/bundler-report/src/index.ts index 19c81fd08..7684fd8f4 100644 --- a/packages/plugins/bundler-report/src/index.ts +++ b/packages/plugins/bundler-report/src/index.ts @@ -7,20 +7,36 @@ import path from 'path'; export const PLUGIN_NAME = 'datadog-bundler-report-plugin'; -const rollupPlugin: (context: GlobalContext) => PluginOptions['rollup'] = (context) => ({ - options(options) { - context.bundler.rawConfig = options; - const outputOptions = (options as any).output; - if (outputOptions) { - context.bundler.outDir = outputOptions.dir; - } - }, - outputOptions(options) { - if (options.dir) { - context.bundler.outDir = options.dir; +// From a list of path, return the nearest common directory. +const getNearestCommonDirectory = (dirs: string[], cwd: string) => { + const splitPaths = dirs.map((dir) => { + const absolutePath = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir); + return absolutePath.split(path.sep); + }); + + // Use the shortest length for faster results. + const minLength = Math.min(...splitPaths.map((parts) => parts.length)); + const commonParts = []; + + for (let i = 0; i < minLength; i++) { + // We use the first path as our basis. + const component = splitPaths[0][i]; + if (splitPaths.every((parts) => parts[i] === component)) { + commonParts.push(component); + } else { + break; } - }, -}); + } + + return commonParts.length > 0 ? commonParts.join(path.sep) : path.sep; +}; + +const handleCwd = (dirs: string[], context: GlobalContext) => { + const nearestDir = getNearestCommonDirectory(dirs, context.cwd); + if (nearestDir !== path.sep) { + context.cwd = nearestDir; + } +}; const xpackPlugin: (context: GlobalContext) => PluginOptions['webpack'] & PluginOptions['rspack'] = (context) => (compiler) => { @@ -29,34 +45,106 @@ const xpackPlugin: (context: GlobalContext) => PluginOptions['webpack'] & Plugin if (compiler.options.output?.path) { context.bundler.outDir = compiler.options.output.path; } + + if (compiler.options.context) { + context.cwd = compiler.options.context; + } }; // TODO: Add universal config report with list of plugins (names), loaders. -export const getBundlerReportPlugins = (globalContext: GlobalContext): PluginOptions[] => { +export const getBundlerReportPlugins = (context: GlobalContext): PluginOptions[] => { + const directories: Set = new Set(); + const handleOutputOptions = (outputOptions: any) => { + if (!outputOptions) { + return; + } + + if (outputOptions.dir) { + context.bundler.outDir = outputOptions.dir; + directories.add(outputOptions.dir); + } else if (outputOptions.file) { + context.bundler.outDir = path.dirname(outputOptions.file); + directories.add(outputOptions.dir); + } + + // Vite has the "root" option we're using. + if (context.bundler.name === 'vite') { + return; + } + + handleCwd(Array.from(directories), context); + }; + + const rollupPlugin: () => PluginOptions['rollup'] & PluginOptions['vite'] = () => { + return { + options(options) { + context.bundler.rawConfig = options; + if (options.input) { + if (Array.isArray(options.input)) { + for (const input of options.input) { + directories.add(path.dirname(input)); + } + } else if (typeof options.input === 'object') { + for (const input of Object.values(options.input)) { + directories.add(path.dirname(input)); + } + } else if (typeof options.input === 'string') { + directories.add(path.dirname(options.input)); + } else { + throw new Error('Invalid input type'); + } + } + + if ('output' in options) { + handleOutputOptions(options.output); + } + }, + outputOptions(options) { + handleOutputOptions(options); + }, + }; + }; + const bundlerReportPlugin: PluginOptions = { name: PLUGIN_NAME, enforce: 'pre', esbuild: { setup(build) { - globalContext.bundler.rawConfig = build.initialOptions; + context.bundler.rawConfig = build.initialOptions; if (build.initialOptions.outdir) { - globalContext.bundler.outDir = build.initialOptions.outdir; + context.bundler.outDir = build.initialOptions.outdir; } if (build.initialOptions.outfile) { - globalContext.bundler.outDir = path.dirname(build.initialOptions.outfile); + context.bundler.outDir = path.dirname(build.initialOptions.outfile); + } + + if (build.initialOptions.absWorkingDir) { + context.cwd = build.initialOptions.absWorkingDir; } // We force esbuild to produce its metafile. build.initialOptions.metafile = true; }, }, - webpack: xpackPlugin(globalContext), - rspack: xpackPlugin(globalContext), - // Vite and Rollup have the same API. - vite: rollupPlugin(globalContext), - rollup: rollupPlugin(globalContext), + webpack: xpackPlugin(context), + rspack: xpackPlugin(context), + // Vite and Rollup have (almost) the same API. + // They don't really support the CWD concept, + // so we have to compute it based on existing configurations. + // The basic idea is to compare input vs output and keep the common part of the paths. + vite: { + config(config) { + if (config.root) { + context.cwd = config.root; + } else { + handleCwd(Array.from(directories), context); + } + }, + ...rollupPlugin(), + }, + rollup: rollupPlugin(), }; return [bundlerReportPlugin]; diff --git a/packages/plugins/error-tracking/README.md b/packages/plugins/error-tracking/README.md new file mode 100644 index 000000000..756fdf881 --- /dev/null +++ b/packages/plugins/error-tracking/README.md @@ -0,0 +1,92 @@ +# Error Tracking Plugin + +Interact with Error Tracking directly from your build system. + + + +## Table of content + + + + +- [Configuration](#configuration) +- [Sourcemaps Upload](#sourcemaps-upload) + - [errorTracking.sourcemaps.bailOnError](#errortrackingsourcemapsbailonerror) + - [errorTracking.sourcemaps.dryRun](#errortrackingsourcemapsdryrun) + - [errorTracking.sourcemaps.intakeUrl](#errortrackingsourcemapsintakeurl) + - [errorTracking.sourcemaps.maxConcurrency](#errortrackingsourcemapsmaxconcurrency) + - [errorTracking.sourcemaps.minifiedPathPrefix](#errortrackingsourcemapsminifiedpathprefix) + - [errorTracking.sourcemaps.releaseVersion](#errortrackingsourcemapsreleaseversion) + - [errorTracking.sourcemaps.service](#errortrackingsourcemapsservice) + + +## Configuration + +```ts +errorTracking?: { + disabled?: boolean; + sourcemaps?: { + bailOnError?: boolean; + dryRun?: boolean; + intakeUrl?: string; + maxConcurrency?: number; + minifiedPathPrefix: string; + releaseVersion: string; + service: string; + }; +} +``` + +## Sourcemaps Upload + +Upload JavaScript sourcemaps to Datadog to un-minify your errors. + +> [!NOTE] +> You can override the intake URL by setting the `DATADOG_SOURCEMAP_INTAKE_URL` environment variable (eg. `https://sourcemap-intake.datadoghq.com/v1/input`). +> Or only the domain with the `DATADOG_SITE` environment variable (eg. `datadoghq.com`). + +### errorTracking.sourcemaps.bailOnError + +> default: `false` + +Should the upload of sourcemaps fail the build on first error? + +### errorTracking.sourcemaps.dryRun + +> default: `false` + +It will not upload the sourcemaps to Datadog, but will do everything else. + +### errorTracking.sourcemaps.intakeUrl + +> default: `https://sourcemap-intake.datadoghq.com/api/v2/srcmap` + +Against which endpoint do you want to upload the sourcemaps. + +### errorTracking.sourcemaps.maxConcurrency + +> default: `20` + +Number of concurrent upload to the API. + +### errorTracking.sourcemaps.minifiedPathPrefix + +> required + +Should be a prefix common to all your JS source files, depending on the URL they are served from. + +The prefix can be a full URL or an absolute path. + +Example: if you're uploading `dist/file.js` to `https://example.com/static/file.js`, you can use `minifiedPathPrefix: 'https://example.com/static/'` or `minifiedPathPrefix: '/static/'`.`minifiedPathPrefix: '/'` is a valid input when you upload JS at the root directory of the server. + +### errorTracking.sourcemaps.releaseVersion + +> required + +Is similar and will be used to match the `version` tag set on the RUM SDK. + +### errorTracking.sourcemaps.service + +> required + +Should be set as the name of the service you're uploading sourcemaps for, and Datadog will use this service name to find the corresponding sourcemaps based on the `service` tag set on the RUM SDK. diff --git a/packages/plugins/error-tracking/package.json b/packages/plugins/error-tracking/package.json new file mode 100644 index 000000000..2c19e7e22 --- /dev/null +++ b/packages/plugins/error-tracking/package.json @@ -0,0 +1,28 @@ +{ + "name": "@dd/error-tracking-plugin", + "packageManager": "yarn@4.0.2", + "license": "MIT", + "private": true, + "author": "Datadog", + "description": "Interact with Error Tracking directly from your build system.", + "homepage": "https://github.com/DataDog/build-plugins/tree/main/packages/plugins/error-tracking#readme", + "repository": { + "type": "git", + "url": "https://github.com/DataDog/build-plugins", + "directory": "packages/plugins/error-tracking" + }, + "exports": { + ".": "./src/index.ts", + "./sourcemaps/*": "./src/sourcemaps/*.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dd/core": "workspace:*", + "chalk": "2.3.1", + "outdent": "0.8.0", + "p-queue": "6.6.2" + } +} diff --git a/packages/plugins/error-tracking/src/constants.ts b/packages/plugins/error-tracking/src/constants.ts new file mode 100644 index 000000000..2dde88b79 --- /dev/null +++ b/packages/plugins/error-tracking/src/constants.ts @@ -0,0 +1,8 @@ +// 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 { PluginName } from '@dd/core/types'; + +export const CONFIG_KEY = 'errorTracking' as const; +export const PLUGIN_NAME: PluginName = 'datadog-error-tracking-plugin' as const; diff --git a/packages/plugins/error-tracking/src/index.ts b/packages/plugins/error-tracking/src/index.ts new file mode 100644 index 000000000..e2604e16f --- /dev/null +++ b/packages/plugins/error-tracking/src/index.ts @@ -0,0 +1,51 @@ +// 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 { GlobalContext, GetPlugins, Logger } from '@dd/core/types'; + +import { PLUGIN_NAME } from './constants'; +import { uploadSourcemaps } from './sourcemaps'; +import type { + OptionsWithErrorTracking, + ErrorTrackingOptions, + ErrorTrackingOptionsWithSourcemaps, +} from './types'; +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; + OptionsWithErrorTracking: OptionsWithErrorTracking; +}; + +export const getPlugins: GetPlugins = ( + opts: OptionsWithErrorTracking, + context: GlobalContext, + log: Logger, +) => { + // Verify configuration. + const options = validateOptions(opts, log); + return [ + { + name: PLUGIN_NAME, + enforce: 'post', + async writeBundle() { + if (options.disabled) { + return; + } + + if (options.sourcemaps) { + // Need the "as" because Typescript doesn't understand that we've already checked for sourcemaps. + await uploadSourcemaps( + options as ErrorTrackingOptionsWithSourcemaps, + context, + log, + ); + } + }, + }, + ]; +}; diff --git a/packages/plugins/rum/src/sourcemaps/files.ts b/packages/plugins/error-tracking/src/sourcemaps/files.ts similarity index 91% rename from packages/plugins/rum/src/sourcemaps/files.ts rename to packages/plugins/error-tracking/src/sourcemaps/files.ts index 30aa4b4c9..8df981071 100644 --- a/packages/plugins/rum/src/sourcemaps/files.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/files.ts @@ -6,12 +6,12 @@ import type { GlobalContext } from '@dd/core/types'; import chalk from 'chalk'; import path from 'path'; -import type { RumSourcemapsOptionsWithDefaults, Sourcemap } from '../types'; +import type { SourcemapsOptionsWithDefaults, Sourcemap } from '../types'; type PartialSourcemap = Pick; const decomposePath = ( - options: RumSourcemapsOptionsWithDefaults, + options: SourcemapsOptionsWithDefaults, context: GlobalContext, sourcemapFilePath: string, ): PartialSourcemap => { @@ -33,7 +33,7 @@ const decomposePath = ( }; export const getSourcemapsFiles = ( - options: RumSourcemapsOptionsWithDefaults, + options: SourcemapsOptionsWithDefaults, context: GlobalContext, ): Sourcemap[] => { if (!context.build.outputs || context.build.outputs.length === 0) { diff --git a/packages/plugins/rum/src/sourcemaps/index.ts b/packages/plugins/error-tracking/src/sourcemaps/index.ts similarity index 90% rename from packages/plugins/rum/src/sourcemaps/index.ts rename to packages/plugins/error-tracking/src/sourcemaps/index.ts index 732a3b3c2..4101bdcdd 100644 --- a/packages/plugins/rum/src/sourcemaps/index.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/index.ts @@ -6,13 +6,13 @@ import type { Logger, GlobalContext } from '@dd/core/types'; import chalk from 'chalk'; import { outdent } from 'outdent'; -import type { RumOptionsWithSourcemaps } from '../types'; +import type { ErrorTrackingOptionsWithSourcemaps } from '../types'; import { getSourcemapsFiles } from './files'; import { sendSourcemaps } from './sender'; export const uploadSourcemaps = async ( - options: RumOptionsWithSourcemaps, + options: ErrorTrackingOptionsWithSourcemaps, context: GlobalContext, log: Logger, ) => { diff --git a/packages/plugins/rum/src/sourcemaps/payload.ts b/packages/plugins/error-tracking/src/sourcemaps/payload.ts similarity index 99% rename from packages/plugins/rum/src/sourcemaps/payload.ts rename to packages/plugins/error-tracking/src/sourcemaps/payload.ts index 7c83c5512..08b201389 100644 --- a/packages/plugins/rum/src/sourcemaps/payload.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/payload.ts @@ -66,6 +66,7 @@ export const prefixRepeat = (filePath: string, prefix: string): string => { let result = ''; for (let i = 0; i < prefixParts.length; i += 1) { + // TODO: Check compatibility with Windows paths. const partialPrefix = prefixParts.slice(-i).join('/'); if (normalizedPath.startsWith(partialPrefix)) { result = partialPrefix; diff --git a/packages/plugins/rum/src/sourcemaps/sender.ts b/packages/plugins/error-tracking/src/sourcemaps/sender.ts similarity index 97% rename from packages/plugins/rum/src/sourcemaps/sender.ts rename to packages/plugins/error-tracking/src/sourcemaps/sender.ts index 05c3c1442..e33debcdd 100644 --- a/packages/plugins/rum/src/sourcemaps/sender.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/sender.ts @@ -12,7 +12,7 @@ import { Readable } from 'stream'; import type { Gzip } from 'zlib'; import { createGzip } from 'zlib'; -import type { RumSourcemapsOptionsWithDefaults, Sourcemap } from '../types'; +import type { SourcemapsOptionsWithDefaults, Sourcemap } from '../types'; import type { LocalAppendOptions, Metadata, MultipartFileValue, Payload } from './payload'; import { getPayload } from './payload'; @@ -78,7 +78,7 @@ export const getData = export const upload = async ( payloads: Payload[], - options: RumSourcemapsOptionsWithDefaults, + options: SourcemapsOptionsWithDefaults, context: GlobalContext, log: Logger, ) => { @@ -153,7 +153,7 @@ export const upload = async ( export const sendSourcemaps = async ( sourcemaps: Sourcemap[], - options: RumSourcemapsOptionsWithDefaults, + options: SourcemapsOptionsWithDefaults, context: GlobalContext, log: Logger, ) => { diff --git a/packages/plugins/error-tracking/src/types.ts b/packages/plugins/error-tracking/src/types.ts new file mode 100644 index 000000000..3319b5740 --- /dev/null +++ b/packages/plugins/error-tracking/src/types.ts @@ -0,0 +1,48 @@ +// 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 { GetPluginsOptions } from '@dd/core/types'; + +import type { CONFIG_KEY } from './constants'; + +export type MinifiedPathPrefix = `http://${string}` | `https://${string}` | `/${string}`; + +export type SourcemapsOptions = { + bailOnError?: boolean; + dryRun?: boolean; + intakeUrl?: string; + maxConcurrency?: number; + minifiedPathPrefix: MinifiedPathPrefix; + releaseVersion: string; + service: string; +}; + +export type SourcemapsOptionsWithDefaults = Required; + +export type ErrorTrackingOptions = { + disabled?: boolean; + sourcemaps?: SourcemapsOptions; +}; + +export type ErrorTrackingOptionsWithDefaults = { + disabled?: boolean; + sourcemaps?: SourcemapsOptionsWithDefaults; +}; + +export type ErrorTrackingOptionsWithSourcemaps = { + disabled?: boolean; + sourcemaps: SourcemapsOptionsWithDefaults; +}; + +export interface OptionsWithErrorTracking extends GetPluginsOptions { + [CONFIG_KEY]: ErrorTrackingOptions; +} + +export type Sourcemap = { + minifiedFilePath: string; + minifiedPathPrefix: MinifiedPathPrefix; + minifiedUrl: string; + relativePath: string; + sourcemapFilePath: string; +}; diff --git a/packages/plugins/error-tracking/src/validate.ts b/packages/plugins/error-tracking/src/validate.ts new file mode 100644 index 000000000..ec7061502 --- /dev/null +++ b/packages/plugins/error-tracking/src/validate.ts @@ -0,0 +1,117 @@ +// 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/types'; +import chalk from 'chalk'; + +import { CONFIG_KEY, PLUGIN_NAME } from './constants'; +import type { + OptionsWithErrorTracking, + ErrorTrackingOptions, + ErrorTrackingOptionsWithDefaults, + SourcemapsOptionsWithDefaults, +} from './types'; + +export const defaultIntakeUrl = `https://sourcemap-intake.${process.env.DATADOG_SITE || 'datadoghq.com'}/api/v2/srcmap`; + +// Deal with validation and defaults here. +export const validateOptions = ( + config: Partial, + log: Logger, +): ErrorTrackingOptionsWithDefaults => { + const errors: string[] = []; + + // Validate and add defaults sub-options. + const sourcemapsResults = validateSourcemapsOptions(config); + errors.push(...sourcemapsResults.errors); + + // Throw if there are any errors. + if (errors.length) { + log.error(`\n - ${errors.join('\n - ')}`); + throw new Error(`Invalid configuration for ${PLUGIN_NAME}.`); + } + + // Build the final configuration. + const toReturn: ErrorTrackingOptionsWithDefaults = { + ...config[CONFIG_KEY], + sourcemaps: undefined, + }; + + // Fill in the defaults. + if (sourcemapsResults.config) { + toReturn.sourcemaps = sourcemapsResults.config; + } + + return toReturn; +}; + +type ToReturn = { + errors: string[]; + config?: T; +}; + +const validateMinifiedPathPrefix = (minifiedPathPrefix: string): boolean => { + let host; + try { + const objUrl = new URL(minifiedPathPrefix!); + host = objUrl.host; + } catch { + // Do nothing. + } + + if (!host && !minifiedPathPrefix!.startsWith('/')) { + return false; + } + + return true; +}; + +export const validateSourcemapsOptions = ( + config: Partial, +): ToReturn => { + const red = chalk.bold.red; + const validatedOptions: ErrorTrackingOptions = config[CONFIG_KEY] || {}; + const toReturn: ToReturn = { + errors: [], + }; + + if (validatedOptions.sourcemaps) { + // Validate the configuration. + if (!validatedOptions.sourcemaps.releaseVersion) { + toReturn.errors.push(`${red('sourcemaps.releaseVersion')} is required.`); + } + if (!validatedOptions.sourcemaps.service) { + toReturn.errors.push(`${red('sourcemaps.service')} is required.`); + } + if (!validatedOptions.sourcemaps.minifiedPathPrefix) { + toReturn.errors.push(`${red('sourcemaps.minifiedPathPrefix')} is required.`); + } + + // Validate the minifiedPathPrefix. + if (validatedOptions.sourcemaps.minifiedPathPrefix) { + if (!validateMinifiedPathPrefix(validatedOptions.sourcemaps.minifiedPathPrefix)) { + toReturn.errors.push( + `${red('sourcemaps.minifiedPathPrefix')} must be a valid URL or start with '/'.`, + ); + } + } + + // Add the defaults. + const sourcemapsWithDefaults: SourcemapsOptionsWithDefaults = { + bailOnError: false, + dryRun: false, + maxConcurrency: 20, + intakeUrl: + process.env.DATADOG_SOURCEMAP_INTAKE_URL || + validatedOptions.sourcemaps.intakeUrl || + defaultIntakeUrl, + ...validatedOptions.sourcemaps, + }; + + // Save the config. + toReturn.config = sourcemapsWithDefaults; + } + + return toReturn; +}; diff --git a/packages/plugins/error-tracking/tsconfig.json b/packages/plugins/error-tracking/tsconfig.json new file mode 100644 index 000000000..6c1d3065e --- /dev/null +++ b/packages/plugins/error-tracking/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./", + "outDir": "./dist" + }, + "include": ["**/*"], + "exclude": ["dist", "node_modules"] +} \ No newline at end of file diff --git a/packages/plugins/git/README.md b/packages/plugins/git/README.md index a6d964cca..132b5b8ee 100644 --- a/packages/plugins/git/README.md +++ b/packages/plugins/git/README.md @@ -18,4 +18,4 @@ Adds repository data to the global context from the `buildStart` hook. ``` > [!NOTE] -> This won't be added if `options.disabledGit = true` or `options.rum.sourcemaps.disabledGit = true`. +> This won't be added if `options.disabledGit = true` or `options.errorTracking.sourcemaps.disabledGit = true`. diff --git a/packages/plugins/git/package.json b/packages/plugins/git/package.json index 3a0bf5c86..b47e000b2 100644 --- a/packages/plugins/git/package.json +++ b/packages/plugins/git/package.json @@ -4,12 +4,12 @@ "license": "MIT", "private": true, "author": "Datadog", - "description": "Interact with our Real User Monitoring product (RUM) in Datadog directly from your build system.", - "homepage": "https://github.com/DataDog/build-plugins/tree/main/packages/features/build-report#readme", + "description": "", + "homepage": "https://github.com/DataDog/build-plugins/tree/main/packages/plugins/git#readme", "repository": { "type": "git", "url": "https://github.com/DataDog/build-plugins", - "directory": "packages/features/build-report" + "directory": "packages/plugins/git" }, "exports": { ".": "./src/index.ts", diff --git a/packages/plugins/git/src/index.ts b/packages/plugins/git/src/index.ts index 6c7c29b79..26c20f024 100644 --- a/packages/plugins/git/src/index.ts +++ b/packages/plugins/git/src/index.ts @@ -16,7 +16,8 @@ export const getGitPlugins = (options: Options, context: GlobalContext): PluginO async buildStart() { // Verify that we should get the git information based on the options. // Only get git information if sourcemaps are enabled and git is not disabled. - const shouldGetGitInfo = options.rum?.sourcemaps && options.disableGit !== true; + const shouldGetGitInfo = + options.errorTracking?.sourcemaps && options.disableGit !== true; if (!shouldGetGitInfo) { return; diff --git a/packages/plugins/injection/README.md b/packages/plugins/injection/README.md index 555c4e653..67316b895 100644 --- a/packages/plugins/injection/README.md +++ b/packages/plugins/injection/README.md @@ -1,14 +1,26 @@ # Injection Plugin -This is used to prepend some code to the produced bundle.
-Particularly useful if you want to share some global context, or to automatically inject some SDK. +This is used to inject some code to the produced bundle.
+Particularly useful : +- to share some global context. +- to automatically inject some SDK. +- to initialise some global dependencies. +- ... It gives you access to the `context.inject()` function. All the injections will be resolved during the `buildStart` hook,
-so you'll have to have submitted your injection prior to that.
+so you'll have to "submit" your injection(s) prior to that.
Ideally, you'd submit it during your plugin's initialization. +There are three positions to inject content: + +- `InjectPosition.START`: Added at the very beginning of the bundle, outside any closure. +- `InjectPosition.MIDDLE`: Added at the begining of the entry file, within the context of the bundle. +- `InjectPosition.END`: Added at the very end of the bundle, outside any closure. + +There are three types of injection: + ## Distant file You can give it a distant file.
@@ -18,6 +30,7 @@ Be mindful that a 5s timeout is enforced. context.inject({ type: 'file', value: 'https://example.com/my_file.js', + position: InjectPosition.START, }); ``` @@ -31,6 +44,7 @@ Remember that the plugins are also bundled before distribution. context.inject({ type: 'file', value: path.resolve(__dirname, '../my_file.js'), + position: InjectPosition.END, }); ``` @@ -43,5 +57,6 @@ Be mindful that the code needs to be executable, or the plugins will crash. context.inject({ type: 'code', value: 'console.log("My un-invasive code");', + position: InjectPosition.MIDDLE, }); ``` diff --git a/packages/plugins/injection/src/constants.ts b/packages/plugins/injection/src/constants.ts index 0d4746d5f..72561fa79 100644 --- a/packages/plugins/injection/src/constants.ts +++ b/packages/plugins/injection/src/constants.ts @@ -2,6 +2,7 @@ // 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 DISTANT_FILE_RX = /^https?:\/\//; +export const BEFORE_INJECTION = `// begin injection by Datadog build plugins`; +export const AFTER_INJECTION = `// end injection by Datadog build plugins`; diff --git a/packages/plugins/injection/src/esbuild.ts b/packages/plugins/injection/src/esbuild.ts new file mode 100644 index 000000000..2097cf388 --- /dev/null +++ b/packages/plugins/injection/src/esbuild.ts @@ -0,0 +1,122 @@ +// 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 { INJECTED_FILE } from '@dd/core/constants'; +import { getEsbuildEntries, getUniqueId, outputFile, rm } from '@dd/core/helpers'; +import type { Logger, PluginOptions, GlobalContext, ResolvedEntry } from '@dd/core/types'; +import { InjectPosition } from '@dd/core/types'; +import { getAbsolutePath } from '@dd/internal-build-report-plugin/helpers'; +import fsp from 'fs/promises'; +import path from 'path'; + +import { PLUGIN_NAME } from './constants'; +import { getContentToInject } from './helpers'; +import type { ContentsToInject } from './types'; + +export const getEsbuildPlugin = ( + log: Logger, + context: GlobalContext, + contentsToInject: ContentsToInject, +): PluginOptions['esbuild'] => ({ + setup(build) { + const { onStart, onLoad, onEnd, esbuild, initialOptions } = build; + const entries: ResolvedEntry[] = []; + const filePath = `${getUniqueId()}.${InjectPosition.MIDDLE}.${INJECTED_FILE}.js`; + const absoluteFilePath = path.resolve(context.bundler.outDir, filePath); + const injectionRx = new RegExp(`${filePath}$`); + + // InjectPosition.MIDDLE + // Inject the file in the build using the "inject" option. + // NOTE: This is made "safer" for sub-builds by actually creating the file. + initialOptions.inject = initialOptions.inject || []; + initialOptions.inject.push(absoluteFilePath); + + onStart(async () => { + // Get all the entry points for later reference. + entries.push(...(await getEsbuildEntries(build, context, log))); + + // Remove our injected file from the config, so we reduce our chances to leak our changes. + initialOptions.inject = + initialOptions.inject?.filter((file) => file !== absoluteFilePath) || []; + + try { + // Create the MIDDLE file because esbuild will crash if it doesn't exist. + // It seems to load entries outside of the onLoad hook once. + await outputFile(absoluteFilePath, ''); + } catch (e: any) { + log.error(`Could not create the files: ${e.message}`); + } + }); + + onLoad( + { + filter: injectionRx, + namespace: PLUGIN_NAME, + }, + async () => { + const content = getContentToInject(contentsToInject[InjectPosition.MIDDLE]); + + // Safe to delete the temp file now, the hook will take over. + await rm(absoluteFilePath); + + return { + // We can't use an empty string otherwise esbuild will crash. + contents: content || ' ', + // Resolve the imports from the project's root. + resolveDir: context.cwd, + loader: 'js', + }; + }, + ); + + // InjectPosition.START and InjectPosition.END + onEnd(async (result) => { + if (!result.metafile) { + log.warn('Missing metafile from build result.'); + return; + } + + const banner = getContentToInject(contentsToInject[InjectPosition.BEFORE]); + const footer = getContentToInject(contentsToInject[InjectPosition.AFTER]); + + if (!banner && !footer) { + // Nothing to inject. + return; + } + + // Rewrite outputs with the injected content. + // Only keep the entry files. + const outputs: string[] = Object.entries(result.metafile.outputs) + .map(([p, o]) => { + const entryPoint = o.entryPoint; + if (!entryPoint) { + return; + } + + const entry = entries.find((e) => e.resolved.endsWith(entryPoint)); + if (!entry) { + return; + } + + return getAbsolutePath(context.cwd, p); + }) + .filter(Boolean) as string[]; + + // Write the content. + const proms = outputs.map(async (output) => { + const source = await fsp.readFile(output, 'utf-8'); + const data = await esbuild.transform(source, { + loader: 'default', + banner, + footer, + }); + + // FIXME: Handle sourcemaps. + await fsp.writeFile(output, data.code); + }); + + await Promise.all(proms); + }); + }, +}); diff --git a/packages/plugins/injection/src/helpers.ts b/packages/plugins/injection/src/helpers.ts index d73871d1a..ed5b61dbe 100644 --- a/packages/plugins/injection/src/helpers.ts +++ b/packages/plugins/injection/src/helpers.ts @@ -4,20 +4,30 @@ import { doRequest, truncateString } from '@dd/core/helpers'; import type { Logger, ToInjectItem } from '@dd/core/types'; +import { InjectPosition } from '@dd/core/types'; import { getAbsolutePath } from '@dd/internal-build-report-plugin/helpers'; import { readFile } from 'fs/promises'; -import { DISTANT_FILE_RX } from './constants'; +import { AFTER_INJECTION, BEFORE_INJECTION, DISTANT_FILE_RX } from './constants'; +import type { ContentsToInject } from './types'; 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 ( - item: ToInjectItem, + url: string, timeout: number = MAX_TIMEOUT_IN_MS, ): Promise => { let timeoutId: ReturnType | undefined; return Promise.race([ - doRequest({ url: item.value }).finally(() => { + doRequest({ url }).finally(() => { if (timeout) { clearTimeout(timeoutId); } @@ -30,32 +40,36 @@ export const processDistantFile = async ( ]); }; -export const processLocalFile = async (item: ToInjectItem): Promise => { - const absolutePath = getAbsolutePath(process.cwd(), item.value); +export const processLocalFile = async ( + filepath: string, + cwd: string = process.cwd(), +): Promise => { + const absolutePath = getAbsolutePath(cwd, filepath); 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 => { +export const processItem = async ( + item: ToInjectItem, + log: Logger, + cwd: string = process.cwd(), +): Promise => { let result: string; + const value = await getInjectedValue(item); try { if (item.type === 'file') { - if (item.value.match(DISTANT_FILE_RX)) { - result = await processDistantFile(item); + if (value.match(DISTANT_FILE_RX)) { + result = await processDistantFile(value); } else { - result = await processLocalFile(item); + result = await processLocalFile(value, cwd); } } else if (item.type === 'code') { - result = await processRawCode(item); + // 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(item.value)}`; + const itemId = `${item.type} - ${truncateString(value)}`; if (item.fallback) { // In case of any error, we'll fallback to next item in queue. log.warn(`Fallback for "${itemId}": ${error.toString()}`); @@ -71,15 +85,46 @@ export const processItem = async (item: ToInjectItem, log: Logger): Promise, log: Logger, -): Promise => { - const proms: (Promise | string)[] = []; + cwd: string = process.cwd(), +): Promise> => { + const toReturn: Map = new Map(); - for (const item of toInject) { - proms.push(processItem(item, log)); + // 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) { + toReturn.set(id, { value, position: item.position || InjectPosition.BEFORE }); + } } - const results = await Promise.all(proms); - return results.filter(Boolean); + return toReturn; +}; + +export const getContentToInject = (contentToInject: Map) => { + if (contentToInject.size === 0) { + return ''; + } + + const stringToInject = Array.from(contentToInject.values()) + // Wrapping it in order to avoid variable name collisions. + .map((content) => `(() => {${content}})();`) + .join('\n\n'); + return `${BEFORE_INJECTION}\n${stringToInject}\n${AFTER_INJECTION}`; +}; + +// Prepare and fetch the content to inject. +export const addInjections = async ( + log: Logger, + toInject: Map, + contentsToInject: ContentsToInject, + cwd: string = process.cwd(), +) => { + const results = await processInjections(toInject, log, cwd); + // Redistribute the content to inject in the right place. + for (const [id, value] of results.entries()) { + contentsToInject[value.position].set(id, value.value); + } }; diff --git a/packages/plugins/injection/src/index.ts b/packages/plugins/injection/src/index.ts index f936b318c..5215a0637 100644 --- a/packages/plugins/injection/src/index.ts +++ b/packages/plugins/injection/src/index.ts @@ -2,197 +2,87 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { INJECTED_FILE } from '@dd/core/constants'; -import { outputFile, rm } from '@dd/core/helpers'; -import type { GlobalContext, Logger, PluginOptions, ToInjectItem } from '@dd/core/types'; -import fs from 'fs'; -import path from 'path'; - -import { PLUGIN_NAME, PREPARATION_PLUGIN_NAME } from './constants'; -import { processInjections } from './helpers'; +import { isInjectionFile } from '@dd/core/helpers'; +import { + InjectPosition, + type GlobalContext, + type Logger, + type Options, + type PluginOptions, + type ToInjectItem, +} from '@dd/core/types'; + +import { PLUGIN_NAME } from './constants'; +import { getEsbuildPlugin } from './esbuild'; +import { addInjections, getContentToInject } from './helpers'; +import { getRollupPlugin } from './rollup'; +import type { ContentsToInject } from './types'; +import { getXpackPlugin } from './xpack'; export { PLUGIN_NAME } from './constants'; export const getInjectionPlugins = ( bundler: any, + options: Options, context: GlobalContext, - toInject: ToInjectItem[], + toInject: Map, log: Logger, ): PluginOptions[] => { - const contentToInject: string[] = []; - - const getContentToInject = () => { - // Needs a non empty string otherwise ESBuild will throw 'Do not know how to load path'. - // Most likely because it tries to generate an empty file. - const before = ` -/********************************************/ -/* BEGIN INJECTION BY DATADOG BUILD PLUGINS */`; - const after = ` -/* END INJECTION BY DATADOG BUILD PLUGINS */ -/********************************************/`; - - return `${before}\n${contentToInject.join('\n\n')}\n${after}`; + // Storage for all the positional contents we want to inject. + const contentsToInject: ContentsToInject = { + [InjectPosition.BEFORE]: new Map(), + [InjectPosition.MIDDLE]: new Map(), + [InjectPosition.AFTER]: new Map(), }; - // Rollup uses its own banner hook. - // We use its native functionality. - const rollupInjectionPlugin: PluginOptions['rollup'] = { - banner(chunk) { - if (chunk.isEntry) { - return getContentToInject(); - } - return ''; - }, - }; - - // Create a unique filename to avoid conflicts. - const INJECTED_FILE_PATH = `${Date.now()}.${performance.now()}.${INJECTED_FILE}.js`; - - // This plugin happens in 2 steps in order to cover all bundlers: - // 1. Prepare the content to inject, fetching distant/local files and anything necessary. - // a. [esbuild] We also create the actual file for esbuild to avoid any resolution errors - // and keep the inject override safe. - // b. [esbuild] With a custom resolver, every client side sub-builds would fail to resolve - // the file when re-using the same config as the parent build (with the inject). - // 2. Inject a virtual file into the bundling, this file will be home of all injected content. const plugins: PluginOptions[] = [ - // Prepare and fetch the content to inject for all bundlers. { - name: PREPARATION_PLUGIN_NAME, - enforce: 'pre', - // We use buildStart as it is the first async hook. + name: PLUGIN_NAME, + enforce: 'post', + // Bundler specific part of the plugin. + // We use it to: + // - Inject the content in the right places, each bundler offers this differently. + esbuild: getEsbuildPlugin(log, context, contentsToInject), + webpack: getXpackPlugin(bundler, log, context, toInject, contentsToInject), + rspack: getXpackPlugin(bundler, log, context, toInject, contentsToInject), + rollup: getRollupPlugin(contentsToInject), + vite: { ...getRollupPlugin(contentsToInject), enforce: 'pre' }, + // Universal part of the plugin. + // We use it to: + // - Prepare the injections. + // - Handle the resolution of the injection file. async buildStart() { - const results = await processInjections(toInject, log); - contentToInject.push(...results); - - // Only esbuild needs the following. - if (context.bundler.name !== 'esbuild') { + // In xpack, we need to prepare the injections before the build starts. + // So we do it in their specific plugin. + if (['webpack', 'rspack'].includes(context.bundler.name)) { return; } - // We put it in the outDir to avoid impacting any other part of the build. - // While still being under esbuild's cwd. - const absolutePathInjectFile = path.resolve( - context.bundler.outDir, - INJECTED_FILE_PATH, - ); - - // Actually create the file to avoid any resolution errors. - // It needs to be within cwd. - try { - // Verify that the file doesn't already exist. - if (fs.existsSync(absolutePathInjectFile)) { - log.warn(`Temporary file "${INJECTED_FILE_PATH}" already exists.`); - } - await outputFile(absolutePathInjectFile, getContentToInject()); - } catch (e: any) { - log.error(`Could not create the file: ${e.message}`); - } + // Prepare the injections. + await addInjections(log, toInject, contentsToInject, context.cwd); }, - - async buildEnd() { - // Only esbuild needs the following. - if (context.bundler.name !== 'esbuild') { - return; + async resolveId(source) { + if (isInjectionFile(source)) { + return { id: source }; } - const absolutePathInjectFile = path.resolve( - context.bundler.outDir, - INJECTED_FILE_PATH, - ); - - // Remove our assets. - log.debug(`Removing temporary file "${INJECTED_FILE_PATH}".`); - await rm(absolutePathInjectFile); - }, - }, - // Inject the file that will be home of all injected content. - // Each bundler has its own way to inject a file. - { - name: PLUGIN_NAME, - esbuild: { - setup(build) { - const { initialOptions } = build; - const absolutePathInjectFile = path.resolve( - context.bundler.outDir, - INJECTED_FILE_PATH, - ); - - // Inject the file in the build. - // This is made safe for sub-builds by actually creating the file. - initialOptions.inject = initialOptions.inject || []; - initialOptions.inject.push(absolutePathInjectFile); - }, + return null; }, - webpack: (compiler) => { - const BannerPlugin = - compiler?.webpack?.BannerPlugin || - bundler?.BannerPlugin || - bundler?.default?.BannerPlugin; - - const ChunkGraph = - compiler?.webpack?.ChunkGraph || - bundler?.ChunkGraph || - bundler?.default?.ChunkGraph; - - if (!BannerPlugin) { - log.error('Missing BannerPlugin'); + loadInclude(id) { + if (isInjectionFile(id)) { + return true; } - // Intercept the compilation's ChunkGraph - let chunkGraph: InstanceType; - compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { - compilation.hooks.afterChunks.tap(PLUGIN_NAME, () => { - chunkGraph = compilation.chunkGraph; - }); - }); - - compiler.options.plugins = compiler.options.plugins || []; - compiler.options.plugins.push( - new BannerPlugin({ - // Not wrapped in comments. - raw: true, - // Doesn't seem to work, but it's supposed to only add - // the banner to entry modules. - entryOnly: true, - banner(data) { - // In webpack5 we HAVE to use the chunkGraph. - if (context.bundler.variant === '5') { - if ( - !chunkGraph || - chunkGraph.getNumberOfEntryModules(data.chunk) === 0 - ) { - return ''; - } - - return getContentToInject(); - } else { - if (!data.chunk?.hasEntryModule()) { - return ''; - } - - return getContentToInject(); - } - }, - }), - ); + return null; }, - rspack: (compiler) => { - compiler.options.plugins = compiler.options.plugins || []; - compiler.options.plugins.push( - new compiler.rspack.BannerPlugin({ - // Not wrapped in comments. - raw: true, - // Only entry modules. - entryOnly: true, - banner() { - return getContentToInject(); - }, - }), - ); + load(id) { + if (isInjectionFile(id)) { + return { + code: getContentToInject(contentsToInject[InjectPosition.MIDDLE]), + }; + } + return null; }, - rollup: rollupInjectionPlugin, - vite: rollupInjectionPlugin, }, ]; diff --git a/packages/plugins/injection/src/rollup.ts b/packages/plugins/injection/src/rollup.ts new file mode 100644 index 000000000..ad1902f5d --- /dev/null +++ b/packages/plugins/injection/src/rollup.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 { INJECTED_FILE } from '@dd/core/constants'; +import { isInjectionFile } from '@dd/core/helpers'; +import type { PluginOptions } from '@dd/core/types'; +import { InjectPosition } from '@dd/core/types'; + +import { getContentToInject } from './helpers'; +import type { ContentsToInject } from './types'; + +// Use "INJECTED_FILE" so it get flagged by isInjectionFile(). +const TO_INJECT_ID = INJECTED_FILE; +const TO_INJECT_SUFFIX = '?inject-proxy'; + +export const getRollupPlugin = (contentsToInject: ContentsToInject): PluginOptions['rollup'] => { + return { + banner(chunk) { + if (chunk.isEntry) { + // Can be empty. + return getContentToInject(contentsToInject[InjectPosition.BEFORE]); + } + return ''; + }, + async resolveId(source, importer, options) { + if (isInjectionFile(source)) { + // It is important that side effects are always respected for injections, otherwise using + // "treeshake.moduleSideEffects: false" may prevent the injection from being included. + return { id: source, moduleSideEffects: true }; + } + if (options.isEntry && getContentToInject(contentsToInject[InjectPosition.MIDDLE])) { + // Determine what the actual entry would have been. + const resolution = await this.resolve(source, importer, options); + // If it cannot be resolved or is external, just return it so that Rollup can display an error + if (!resolution || resolution.external) { + return resolution; + } + // In the load hook of the proxy, we need to know if the + // entry has a default export. There, however, we no longer + // have the full "resolution" object that may contain + // meta-data from other plugins that is only added on first + // load. Therefore we trigger loading here. + const moduleInfo = await this.load(resolution); + // We need to make sure side effects in the original entry + // point are respected even for + // treeshake.moduleSideEffects: false. "moduleSideEffects" + // is a writable property on ModuleInfo. + moduleInfo.moduleSideEffects = true; + // It is important that the new entry does not start with + // \0 and has the same directory as the original one to not + // mess up relative external import generation. Also + // keeping the name and just adding a "?query" to the end + // ensures that preserveModules will generate the original + // entry name for this entry. + return `${resolution.id}${TO_INJECT_SUFFIX}`; + } + return null; + }, + load(id) { + if (isInjectionFile(id)) { + // Replace with injection content. + return getContentToInject(contentsToInject[InjectPosition.MIDDLE]); + } + if (id.endsWith(TO_INJECT_SUFFIX)) { + const entryId = id.slice(0, -TO_INJECT_SUFFIX.length); + // We know ModuleInfo.hasDefaultExport is reliable because we awaited this.load in resolveId + const info = this.getModuleInfo(entryId); + let code = `import ${JSON.stringify(TO_INJECT_ID)};\nexport * from ${JSON.stringify(entryId)};`; + // Namespace reexports do not reexport default, so we need special handling here + if (info?.hasDefaultExport) { + code += `export { default } from ${JSON.stringify(entryId)};`; + } + return code; + } + return null; + }, + footer(chunk) { + if (chunk.isEntry) { + // Can be empty. + return getContentToInject(contentsToInject[InjectPosition.AFTER]); + } + return ''; + }, + }; +}; diff --git a/packages/plugins/injection/src/types.ts b/packages/plugins/injection/src/types.ts new file mode 100644 index 000000000..5b989b975 --- /dev/null +++ b/packages/plugins/injection/src/types.ts @@ -0,0 +1,14 @@ +// 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 { InjectPosition } from '@dd/core/types'; + +export type ContentsToInject = Record>; + +export type FileToInject = { + absolutePath: string; + filename: string; + toInject: Map; +}; +export type FilesToInject = Record; diff --git a/packages/plugins/injection/src/xpack.ts b/packages/plugins/injection/src/xpack.ts new file mode 100644 index 000000000..254ae35df --- /dev/null +++ b/packages/plugins/injection/src/xpack.ts @@ -0,0 +1,169 @@ +// 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 { INJECTED_FILE } from '@dd/core/constants'; +import { getUniqueId, outputFile, rm } from '@dd/core/helpers'; +import type { GlobalContext, Logger, PluginOptions, ToInjectItem } from '@dd/core/types'; +import { InjectPosition } from '@dd/core/types'; +import { createRequire } from 'module'; +import path from 'path'; + +import { PLUGIN_NAME } from './constants'; +import { getContentToInject, addInjections } from './helpers'; +import type { ContentsToInject } from './types'; + +// A way to get the correct ConcatSource from either the bundler (rspack and webpack 5) +// or from 'webpack-sources' for webpack 4. +const getConcatSource = (bundler: any): typeof import('webpack-sources').ConcatSource => { + if (!bundler?.sources?.ConcatSource) { + // We need to require it as if we were "webpack", hence the createRequire from 'webpack'. + // This way, we don't have to declare them in our (peer)dependencies and always use the one + // that is compatible with the 'webpack' we're currently using. + const webpackRequire = createRequire(require.resolve('webpack')); + return webpackRequire('webpack-sources').ConcatSource; + } + return bundler.sources.ConcatSource; +}; + +export const getXpackPlugin = + ( + bundler: any, + log: Logger, + context: GlobalContext, + toInject: Map, + contentsToInject: ContentsToInject, + ): PluginOptions['rspack'] & PluginOptions['webpack'] => + (compiler) => { + const cache = new WeakMap(); + const ConcatSource = getConcatSource(bundler); + const filePath = path.resolve( + context.bundler.outDir, + `${getUniqueId()}.${InjectPosition.MIDDLE}.${INJECTED_FILE}.js`, + ); + + // Handle the InjectPosition.MIDDLE. + type Entry = typeof compiler.options.entry; + // TODO: Move this into @dd/core, add rspack/webpack types and tests. + const injectEntry = (initialEntry: Entry): Entry => { + const isWebpack4 = context.bundler.fullName === 'webpack4'; + + // Webpack 4 doesn't support the "import" property. + const injectedEntry = isWebpack4 + ? filePath + : { + import: [filePath], + }; + + const objectInjection = (entry: Entry) => { + for (const [entryKey, entryValue] of Object.entries(entry)) { + if (typeof entryValue === 'object') { + entryValue.import = entryValue.import || []; + entryValue.import.unshift(filePath); + } else if (typeof entryValue === 'string') { + // @ts-expect-error - Badly typed for strings. + entry[entryKey] = [filePath, entryValue]; + } else if (Array.isArray(entryValue)) { + entryValue.unshift(filePath); + } else { + log.error(`Invalid entry type: ${typeof entryValue}`); + } + } + }; + + if (!initialEntry) { + return { + // @ts-expect-error - Badly typed for strings. + ddHelper: injectedEntry, + }; + } else if (typeof initialEntry === 'function') { + // @ts-expect-error - This is webpack / rspack typing conflict. + return async () => { + const originEntry = await initialEntry(); + objectInjection(originEntry); + return originEntry; + }; + } else if (typeof initialEntry === 'object') { + objectInjection(initialEntry); + } else if (typeof initialEntry === 'string') { + // @ts-expect-error - Badly typed for strings. + return [injectedEntry, initialEntry]; + } else { + log.error(`Invalid entry type: ${typeof initialEntry}`); + return initialEntry; + } + return initialEntry; + }; + + const newEntry = injectEntry(compiler.options.entry); + // We inject the new entry. + compiler.options.entry = newEntry; + + // We need to prepare the injections before the build starts. + // Otherwise they'll be empty once resolved. + compiler.hooks.beforeRun.tapPromise(PLUGIN_NAME, async () => { + // RSpack MAY try to resolve the entry points before the loader is ready. + // There must be some race condition around this, because it's not always the case. + if (context.bundler.name === 'rspack') { + await outputFile(filePath, ''); + } + // Prepare the injections. + await addInjections(log, toInject, contentsToInject, context.cwd); + }); + + if (context.bundler.name === 'rspack') { + compiler.hooks.done.tapPromise(PLUGIN_NAME, async () => { + // Delete the fake file we created. + await rm(filePath); + }); + } + + // Handle the InjectPosition.START and InjectPosition.END. + // This is a re-implementation of the BannerPlugin, + // that is compatible with all versions of webpack and rspack, + // with both banner and footer. + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + const hookCb = () => { + const banner = getContentToInject(contentsToInject[InjectPosition.BEFORE]); + const footer = getContentToInject(contentsToInject[InjectPosition.AFTER]); + + for (const chunk of compilation.chunks) { + if (!chunk.canBeInitial()) { + continue; + } + + for (const file of chunk.files) { + compilation.updateAsset(file, (old) => { + const cached = cache.get(old); + + // If anything changed, we need to re-create the source. + if (!cached || cached.banner !== banner || cached.footer !== footer) { + const source = new ConcatSource( + banner, + '\n', + // @ts-expect-error - This is webpack / rspack typing conflict. + old, + '\n', + footer, + ); + + // Cache the result. + cache.set(old, { source, banner, footer }); + return source; + } + + return cached.source; + }); + } + } + }; + + if (compilation.hooks.processAssets) { + const stage = bundler.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS; + compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage }, hookCb); + } else { + // @ts-expect-error - "optimizeChunkAssets" is for webpack 4. + compilation.hooks.optimizeChunkAssets.tap({ name: PLUGIN_NAME }, hookCb); + } + }); + }; diff --git a/packages/plugins/rum/README.md b/packages/plugins/rum/README.md index 601b50102..4f4df9018 100644 --- a/packages/plugins/rum/README.md +++ b/packages/plugins/rum/README.md @@ -1,6 +1,6 @@ -# RUM Plugin +# Rum Plugin -Interact with our Real User Monitoring product (RUM) in Datadog directly from your build system. +Interact with Real User Monitoring (RUM) directly from your build system. @@ -10,83 +10,278 @@ Interact with our Real User Monitoring product (RUM) in Datadog directly from yo - [Configuration](#configuration) -- [Sourcemaps Upload](#sourcemaps-upload) - - [`rum.sourcemaps.bailOnError`](#rumsourcemapsbailonerror) - - [`rum.sourcemaps.dryRun`](#rumsourcemapsdryrun) - - [`rum.sourcemaps.intakeUrl`](#rumsourcemapsintakeurl) - - [`rum.sourcemaps.maxConcurrency`](#rumsourcemapsmaxconcurrency) - - [`rum.sourcemaps.minifiedPathPrefix`](#rumsourcemapsminifiedpathprefix) - - [`rum.sourcemaps.releaseVersion`](#rumsourcemapsreleaseversion) - - [`rum.sourcemaps.service`](#rumsourcemapsservice) +- [React instrumentation](#react-instrumentation) + - [rum.react.router (alpha)](#rumreactrouter-alpha) +- [Browser SDK Injection](#browser-sdk-injection) + - [rum.sdk.applicationId](#rumsdkapplicationid) + - [rum.sdk.clientToken](#rumsdkclienttoken) + - [rum.sdk.site](#rumsdksite) + - [rum.sdk.service](#rumsdkservice) + - [rum.sdk.env](#rumsdkenv) + - [rum.sdk.version](#rumsdkversion) + - [rum.sdk.trackingConsent](#rumsdktrackingconsent) + - [rum.sdk.trackViewsManually](#rumsdktrackviewsmanually) + - [rum.sdk.trackUserInteractions](#rumsdktrackuserinteractions) + - [rum.sdk.trackResources](#rumsdktrackresources) + - [rum.sdk.trackLongTasks](#rumsdktracklongtasks) + - [rum.sdk.defaultPrivacyLevel](#rumsdkdefaultprivacylevel) + - [rum.sdk.enablePrivacyForActionName](#rumsdkenableprivacyforactionname) + - [rum.sdk.actionNameAttribute](#rumsdkactionnameattribute) + - [rum.sdk.sessionSampleRate](#rumsdksessionsamplerate) + - [rum.sdk.sessionReplaySampleRate](#rumsdksessionreplaysamplerate) + - [rum.sdk.startSessionReplayRecordingManually](#rumsdkstartsessionreplayrecordingmanually) + - [rum.sdk.silentMultipleInit](#rumsdksilentmultipleinit) + - [rum.sdk.proxy](#rumsdkproxy) + - [rum.sdk.allowedTracingUrls](#rumsdkallowedtracingurls) + - [rum.sdk.traceSampleRate](#rumsdktracesamplerate) + - [rum.sdk.telemetrySampleRate](#rumsdktelemetrysamplerate) + - [rum.sdk.excludedActivityUrls](#rumsdkexcludedactivityurls) + - [rum.sdk.workerUrl](#rumsdkworkerurl) + - [rum.sdk.compressIntakeRequests](#rumsdkcompressintakerequests) + - [rum.sdk.storeContextsAcrossPages](#rumsdkstorecontextsacrosspages) + - [rum.sdk.allowUntrustedEvents](#rumsdkallowuntrustedevents) ## Configuration +
+Full configuration + ```ts rum?: { disabled?: boolean; - sourcemaps?: { - bailOnError?: boolean; - dryRun?: boolean; - intakeUrl?: string; - maxConcurrency?: number; - minifiedPathPrefix: string; - releaseVersion: string; - service: string; + react?: { + router?: boolean; + }; + sdk?: { + actionNameAttribute?: string; + allowedTracingUrls?: string[]; + allowUntrustedEvents?: boolean; + applicationId: string; + clientToken?: string; + compressIntakeRequests?: boolean; + defaultPrivacyLevel?: 'mask' | 'mask-user-input' | 'allow'; + enablePrivacyForActionName?: boolean; + env?: string; + excludedActivityUrls?: string[]; + proxy?: string; + service?: string; + sessionReplaySampleRate?: number; + sessionSampleRate?: number; + silentMultipleInit?: boolean; + site?: string; + startSessionReplayRecordingManually?: boolean; + storeContextsAcrossPages?: boolean; + telemetrySampleRate?: number; + traceSampleRate?: number; + trackingConsent?: 'granted' | 'not_granted'; + trackLongTasks?: boolean; + trackResources?: boolean; + trackUserInteractions?: boolean; + trackViewsManually?: boolean; + version?: string; + workerUrl?: string; }; } ``` -## Sourcemaps Upload +
+ +**Minimal configuration**: + +```ts +rum: { + sdk: { + applicationId: 'your_application_id', + } +} +``` + +## React instrumentation + +Automatically inject and instrument [RUM's React and React Router integrations](https://github.com/DataDog/browser-sdk/tree/main/packages/rum-react#react-router-integration). + +### rum.react.router (alpha) + +> default: false + +It will: + +1. inject `@datadog/browser-rum-react` into your bundle. +2. enable the plugin in the RUM SDK. +3. automatically instrument your React Router routes. + a. For now, it only instruments `createBrowserRouter`. + +> [!IMPORTANT] +> - You need to have `react` and`react-router-dom` into your dependencies. +> - This feature is in alpha and may not work as expected in all cases. + +## Browser SDK Injection + +Automatically inject the RUM SDK into your application. + +### rum.sdk.applicationId + +> required + +The RUM application ID. [Create a new application if necessary](https://app.datadoghq.com/rum/list/create). + +### rum.sdk.clientToken + +> optional, will be fetched if missing -Upload JavaScript sourcemaps to Datadog to un-minify your errors. +A [Datadog client token](https://docs.datadoghq.com/account_management/api-app-keys/#client-tokens). > [!NOTE] -> You can override the intake URL by setting the `DATADOG_SOURCEMAP_INTAKE_URL` environment variable (eg. `https://sourcemap-intake.datadoghq.com/v1/input`). -> Or only the domain with the `DATADOG_SITE` environment variable (eg. `datadoghq.com`). +> If not provided, the plugin will attempt to fetch the client token using the API. +> You need to provide both `auth.apiKey` and `auth.appKey` with the `rum_apps_read` permission. -### `rum.sourcemaps.bailOnError` +### rum.sdk.site + +> default: `"datadoghq.com"` + +[The Datadog site parameter of your organization](https://docs.datadoghq.com/getting_started/site/). + +### rum.sdk.service + +> optional + +The service name for your application. Follows the [tag syntax requirements](https://docs.datadoghq.com/getting_started/tagging/#define-tags). + +### rum.sdk.env + +> optional + +The application’s environment, for example: `prod`, `pre-prod`, and `staging`. Follows the [tag syntax requirements](https://docs.datadoghq.com/getting_started/tagging/#define-tags). + +### rum.sdk.version + +> optional + +The application’s version, for example: `1.2.3`, `6c44da20`, and `2020.02.13`. Follows the [tag syntax requirements](https://docs.datadoghq.com/getting_started/tagging/#define-tags). + +### rum.sdk.trackingConsent + +> default: `"granted"` + +Set the initial user tracking consent state. See [User Tracking Consent](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#user-tracking-consent). + +### rum.sdk.trackViewsManually + +> default: `false` + +Allows you to control RUM views creation. See [override default RUM view names](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#override-default-rum-view-names). + +### rum.sdk.trackUserInteractions > default: `false` -Should the upload of sourcemaps fail the build on first error? +Enables [automatic collection of users actions](https://docs.datadoghq.com/real_user_monitoring/browser/tracking_user_actions/). -### `rum.sourcemaps.dryRun` +### rum.sdk.trackResources > default: `false` -It will not upload the sourcemaps to Datadog, but will do everything else. +Enables collection of resource events. -### `rum.sourcemaps.intakeUrl` +### rum.sdk.trackLongTasks + +> default: `false` -> default: `https://sourcemap-intake.datadoghq.com/api/v2/srcmap` +Enables collection of long task events. -Against which endpoint do you want to upload the sourcemaps. +### rum.sdk.defaultPrivacyLevel -### `rum.sourcemaps.maxConcurrency` +> default: `"mask"` + +See [Session Replay Privacy Options](https://docs.datadoghq.com/real_user_monitoring/session_replay/browser/privacy_options/). + +### rum.sdk.enablePrivacyForActionName + +> default: `false` + +See [Mask Action Names](https://docs.datadoghq.com/data_security/real_user_monitoring/#mask-action-names). + +### rum.sdk.actionNameAttribute + +> optional + +Specify your own attribute to be used to [name actions](https://docs.datadoghq.com/real_user_monitoring/browser/tracking_user_actions/#declare-a-name-for-click-actions). + +### rum.sdk.sessionSampleRate + +> default: `100` + +The percentage of sessions to track: `100` for all, `0` for none. Only tracked sessions send RUM events. For more details about `sessionSampleRate`, see the [sampling configuration](https://docs.datadoghq.com/real_user_monitoring/guide/sampling-browser-plans/). + +### rum.sdk.sessionReplaySampleRate + +> default: `0` + +The percentage of tracked sessions with [Browser RUM & Session Replay pricing](https://www.datadoghq.com/pricing/?product=real-user-monitoring--session-replay#products) features: `100` for all, `0` for none. For more details about `sessionReplaySampleRate`, see the [sampling configuration](https://docs.datadoghq.com/real_user_monitoring/guide/sampling-browser-plans/). + +### rum.sdk.startSessionReplayRecordingManually + +> default: `false` + +If the session is sampled for Session Replay, only start the recording when `startSessionReplayRecording()` is called, instead of at the beginning of the session. See [Session Replay Usage](https://docs.datadoghq.com/real_user_monitoring/session_replay/browser/#usage) for details. + +### rum.sdk.silentMultipleInit + +> default: `false` + +Initialization fails silently if the RUM Browser SDK is already initialized on the page. + +### rum.sdk.proxy + +> optional + +Proxy URL, for example: `https://www.proxy.com/path`. For more information, see the full [proxy setup guide](https://docs.datadoghq.com/real_user_monitoring/guide/proxy-rum-data/). + +### rum.sdk.allowedTracingUrls + +> optional + +A list of request URLs used to inject tracing headers. For more information, see [Connect RUM and Traces](https://docs.datadoghq.com/real_user_monitoring/platform/connect_rum_and_traces/). + +### rum.sdk.traceSampleRate + +> default: `100` + +The percentage of requests to trace: `100` for all, `0` for none. For more information, see [Connect RUM and Traces](https://docs.datadoghq.com/real_user_monitoring/platform/connect_rum_and_traces/). + +### rum.sdk.telemetrySampleRate > default: `20` -Number of concurrent upload to the API. +Telemetry data (such as errors and debug logs) about SDK execution is sent to Datadog to detect and solve potential issues. Set this option to `0` to opt out from telemetry collection. -### `rum.sourcemaps.minifiedPathPrefix` +### rum.sdk.excludedActivityUrls -> required +> optional -Should be a prefix common to all your JS source files, depending on the URL they are served from. +A list of request origins ignored when computing the page activity. See [How page activity is calculated](https://docs.datadoghq.com/real_user_monitoring/browser/monitoring_page_performance/#how-page-activity-is-calculated). -The prefix can be a full URL or an absolute path. +### rum.sdk.workerUrl -Example: if you're uploading `dist/file.js` to `https://example.com/static/file.js`, you can use `minifiedPathPrefix: 'https://example.com/static/'` or `minifiedPathPrefix: '/static/'`.`minifiedPathPrefix: '/'` is a valid input when you upload JS at the root directory of the server. +> optional -### `rum.sourcemaps.releaseVersion` +URL pointing to the Datadog Browser SDK Worker JavaScript file. The URL can be relative or absolute, but is required to have the same origin as the web application. See [Content Security Policy guidelines](https://docs.datadoghq.com/integrations/content_security_policy_logs/?tab=firefox#use-csp-with-real-user-monitoring-and-session-replay) for more information. -> required +### rum.sdk.compressIntakeRequests -Is similar and will be used to match the `version` tag set on the RUM SDK. +> default: `false` -### `rum.sourcemaps.service` +Compress requests sent to the Datadog intake to reduce bandwidth usage when sending large amounts of data. The compression is done in a Worker thread. See [Content Security Policy guidelines](https://docs.datadoghq.com/integrations/content_security_policy_logs/?tab=firefox#use-csp-with-real-user-monitoring-and-session-replay) for more information. -> required +### rum.sdk.storeContextsAcrossPages + +> default: `false` + +Store global context and user context in `localStorage` to preserve them along the user navigation. See [Contexts life cycle](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#contexts-life-cycle) for more details and specific limitations. + +### rum.sdk.allowUntrustedEvents + +> default: `false` -Should be set as the name of the service you're uploading sourcemaps for, and Datadog will use this service name to find the corresponding sourcemaps based on the `service` tag set on the RUM SDK. +Allow capture of [untrusted events](https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted), for example in automated UI tests. diff --git a/packages/plugins/rum/package.json b/packages/plugins/rum/package.json index 031685db4..d9235088b 100644 --- a/packages/plugins/rum/package.json +++ b/packages/plugins/rum/package.json @@ -4,16 +4,27 @@ "license": "MIT", "private": true, "author": "Datadog", - "description": "Interact with our Real User Monitoring product (RUM) in Datadog directly from your build system.", + "description": "Interact with Real User Monitoring (RUM) directly from your build system.", "homepage": "https://github.com/DataDog/build-plugins/tree/main/packages/plugins/rum#readme", "repository": { "type": "git", "url": "https://github.com/DataDog/build-plugins", "directory": "packages/plugins/rum" }, + "toBuild": { + "rum-browser-sdk": { + "entry": "./src/built/rum-browser-sdk.ts" + }, + "rum-react-plugin": { + "entry": "./src/built/rum-react-plugin.ts", + "external": [ + "react", + "react-router-dom" + ] + } + }, "exports": { ".": "./src/index.ts", - "./sourcemaps/*": "./src/sourcemaps/*.ts", "./*": "./src/*.ts" }, "scripts": { @@ -21,8 +32,10 @@ }, "dependencies": { "@dd/core": "workspace:*", - "chalk": "2.3.1", - "outdent": "0.8.0", - "p-queue": "6.6.2" + "chalk": "2.3.1" + }, + "devDependencies": { + "@datadog/browser-rum": "6.0.0", + "@datadog/browser-rum-react": "6.0.0" } } diff --git a/packages/plugins/rum/src/built/rum-browser-sdk.ts b/packages/plugins/rum/src/built/rum-browser-sdk.ts new file mode 100644 index 000000000..d2973445e --- /dev/null +++ b/packages/plugins/rum/src/built/rum-browser-sdk.ts @@ -0,0 +1,15 @@ +// 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 { datadogRum } from '@datadog/browser-rum'; + +// To please TypeScript. +const globalAny: any = global; + +// Also them to the global DD_RUM object. +globalAny.DD_RUM = globalAny.DD_RUM || {}; +globalAny.DD_RUM = { + ...globalAny.DD_RUM, + ...datadogRum, +}; diff --git a/packages/plugins/rum/src/built/rum-react-plugin.ts b/packages/plugins/rum/src/built/rum-react-plugin.ts new file mode 100644 index 000000000..431a110e2 --- /dev/null +++ b/packages/plugins/rum/src/built/rum-react-plugin.ts @@ -0,0 +1,18 @@ +// 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 { createBrowserRouter } from '@datadog/browser-rum-react/react-router-v6'; +import { reactPlugin } from '@datadog/browser-rum-react'; + +// To please TypeScript. +const globalAny: any = global; + +// Have them globally available. +globalAny.reactPlugin = reactPlugin; +globalAny.createBrowserRouter = createBrowserRouter; + +// Also them to the global DD_RUM object. +globalAny.DD_RUM = globalAny.DD_RUM || {}; +globalAny.DD_RUM.reactPlugin = reactPlugin; +globalAny.DD_RUM.createBrowserRouter = createBrowserRouter; diff --git a/packages/plugins/rum/src/index.ts b/packages/plugins/rum/src/index.ts index 6cb5ddd7b..d1f1b9af0 100644 --- a/packages/plugins/rum/src/index.ts +++ b/packages/plugins/rum/src/index.ts @@ -2,13 +2,21 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { GlobalContext, GetPlugins, Logger } from '@dd/core/types'; +import type { PluginOptions, GetPlugins, GlobalContext, Logger } from '@dd/core/types'; +import { InjectPosition } from '@dd/core/types'; +import path from 'path'; -import { uploadSourcemaps } from './sourcemaps'; -import type { OptionsWithRum, RumOptions, RumOptionsWithSourcemaps } from './types'; +import { CONFIG_KEY, PLUGIN_NAME } from './constants'; +import { getReactPlugin } from './react'; +import { getInjectionValue } from './sdk'; +import type { OptionsWithRum, RumOptions, RumOptionsWithSdk } from './types'; import { validateOptions } from './validate'; -export { CONFIG_KEY, PLUGIN_NAME } from './constants'; +export { CONFIG_KEY, PLUGIN_NAME }; + +export const helpers = { + // Add the helpers you'd like to expose here. +}; export type types = { // Add the types you'd like to expose here. @@ -21,22 +29,42 @@ export const getPlugins: GetPlugins = ( context: GlobalContext, log: Logger, ) => { + const plugins: PluginOptions[] = []; // Verify configuration. - const rumOptions = validateOptions(opts, log); - return [ - { - name: 'datadog-rum-sourcemaps-plugin', - enforce: 'post', - async writeBundle() { - if (rumOptions.disabled) { - return; - } - - if (rumOptions.sourcemaps) { - // Need the "as" because Typescript doesn't understand that we've already checked for sourcemaps. - await uploadSourcemaps(rumOptions as RumOptionsWithSourcemaps, context, log); - } - }, - }, - ]; + const options = validateOptions(opts, log); + + // NOTE: These files are built from "@dd/tools/rollupConfig.mjs" and available in the distributed package. + if (options.sdk) { + // Inject the SDK from the CDN. + context.inject({ + type: 'file', + // Using MIDDLE otherwise it's not executed before the rum react plugin injection. + position: InjectPosition.MIDDLE, + // This file is being built alongside the bundler plugin. + value: path.join(__dirname, './rum-browser-sdk.js'), + }); + + if (options.react?.router) { + // Inject the rum-react-plugin. + context.inject({ + type: 'file', + // It's MIDDLE in order to be able to import "react", "react-dom" and "react-router-dom". + // If put in BEFORE, it would not have access to the dependencies of the user's project. + position: InjectPosition.MIDDLE, + // This file is being built alongside the bundler plugin. + value: path.join(__dirname, './rum-react-plugin.js'), + }); + + plugins.push(getReactPlugin()); + } + + // Inject the SDK Initialization. + context.inject({ + type: 'code', + position: InjectPosition.MIDDLE, + value: getInjectionValue(options as RumOptionsWithSdk, context), + }); + } + + return plugins; }; diff --git a/packages/plugins/rum/src/react.ts b/packages/plugins/rum/src/react.ts new file mode 100644 index 000000000..4eedb067b --- /dev/null +++ b/packages/plugins/rum/src/react.ts @@ -0,0 +1,37 @@ +// 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 { PluginOptions } from '@dd/core/types'; + +export const getReactPlugin = (): PluginOptions => { + return { + name: 'datadog-rum-react-plugin', + transform(code) { + let updatedCode = code; + const createBrowserRouterImportRegExp = new RegExp( + /(import \{.*)createBrowserRouter[,]?(.*\} from "react-router-dom")/g, + ); + const hasCreateBrowserRouterImport = + code.match(createBrowserRouterImportRegExp) !== null; + + if (hasCreateBrowserRouterImport) { + // Remove the import of createBrowserRouter + updatedCode = updatedCode.replace(createBrowserRouterImportRegExp, (_, p1, p2) => { + return `${p1}${p2}`; + }); + + // replace all occurences of `createBrowserRouter` with `DD_RUM.createBrowserRouter` + updatedCode = updatedCode.replace( + new RegExp(/createBrowserRouter/g), + 'DD_RUM.createBrowserRouter', + ); + } + + return updatedCode; + }, + transformInclude(id) { + return id.match(new RegExp(/.*\.(js|jsx|ts|tsx)$/)) !== null; + }, + }; +}; diff --git a/packages/plugins/rum/src/sdk.ts b/packages/plugins/rum/src/sdk.ts new file mode 100644 index 000000000..550da9240 --- /dev/null +++ b/packages/plugins/rum/src/sdk.ts @@ -0,0 +1,72 @@ +// 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 } from '@dd/core/helpers'; +import type { GlobalContext, InjectedValue } from '@dd/core/types'; + +import type { RumOptionsWithDefaults, RumOptionsWithSdk } from './types'; + +type RumAppResponse = { + data: { + attributes: { + client_token: string; + }; + }; +}; + +const getContent = (opts: RumOptionsWithDefaults) => { + const pluginContent = opts.react?.router ? ',plugins:[reactPlugin({router:true})]' : ''; + return `global.DD_RUM.init({${JSON.stringify(opts.sdk).replace(/(^{|}$)/g, '')}${pluginContent}}); +`; +}; + +export const getInjectionValue = ( + options: RumOptionsWithSdk, + context: GlobalContext, +): InjectedValue => { + const sdkOpts = options.sdk; + // We already have the clientToken, we can inject it directly. + if (sdkOpts.clientToken) { + return getContent(options); + } + + // Let's try and fetch the clientToken from the API. + if (!context.auth?.apiKey || !context.auth?.appKey) { + throw new Error( + 'Missing "auth.apiKey" and/or "auth.appKey" to fetch "rum.sdk.clientToken".', + ); + } + + // Return the value as an async function so it gets resolved during buildStart. + return async () => { + let clientToken: string; + try { + // Fetch the client token from the API. + const appResponse = await doRequest({ + url: `https://api.datadoghq.com/api/v2/rum/applications/${sdkOpts.applicationId}`, + type: 'json', + auth: context.auth, + }); + + clientToken = appResponse.data?.attributes?.client_token; + } catch (e: any) { + // Could not fetch the clientToken. + // Let's crash the build. + throw new Error(`Could not fetch the clientToken: ${e.message}`); + } + + // Still no clientToken. + if (!clientToken) { + throw new Error('Missing clientToken in the API response.'); + } + + return getContent({ + ...options, + sdk: { + clientToken, + ...sdkOpts, + }, + }); + }; +}; diff --git a/packages/plugins/rum/src/types.ts b/packages/plugins/rum/src/types.ts index ac70fac15..5f6f73d75 100644 --- a/packages/plugins/rum/src/types.ts +++ b/packages/plugins/rum/src/types.ts @@ -2,47 +2,82 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { GetPluginsOptions } from '@dd/core/types'; +import type { Assign, GetPluginsOptions } from '@dd/core/types'; import type { CONFIG_KEY } from './constants'; -export type MinifiedPathPrefix = `http://${string}` | `https://${string}` | `/${string}`; +export type RumOptions = { + disabled?: boolean; + sdk?: SDKOptions; + react?: ReactOptions; +}; -export type RumSourcemapsOptions = { - bailOnError?: boolean; - dryRun?: boolean; - intakeUrl?: string; - maxConcurrency?: number; - minifiedPathPrefix: MinifiedPathPrefix; - releaseVersion: string; - service: string; +export type SDKOptions = { + actionNameAttribute?: string; + allowedTracingUrls?: string[]; + allowUntrustedEvents?: boolean; + applicationId: string; + clientToken?: string; + compressIntakeRequests?: boolean; + defaultPrivacyLevel?: 'mask' | 'mask-user-input' | 'allow'; + enablePrivacyForActionName?: boolean; + env?: string; + excludedActivityUrls?: string[]; + proxy?: string; + service?: string; + sessionReplaySampleRate?: number; + sessionSampleRate?: number; + silentMultipleInit?: boolean; + site?: string; + startSessionReplayRecordingManually?: boolean; + storeContextsAcrossPages?: boolean; + telemetrySampleRate?: number; + traceSampleRate?: number; + trackingConsent?: 'granted' | 'not_granted'; + trackLongTasks?: boolean; + trackResources?: boolean; + trackUserInteractions?: boolean; + trackViewsManually?: boolean; + version?: string; + workerUrl?: string; }; -export type RumOptions = { - disabled?: boolean; - sourcemaps?: RumSourcemapsOptions; +export type SDKOptionsWithDefaults = Assign< + Required, + { + // This one, we'll try to fetch it via API. + clientToken?: string; + } & { + // These have no default and are trully optional. + actionNameAttribute?: string; + allowedTracingUrls?: string[]; + env?: string; + excludedActivityUrls?: string[]; + proxy?: string; + service?: string; + version?: string; + workerUrl?: string; + } +>; + +export type ReactOptions = { + router?: boolean; }; -export type RumSourcemapsOptionsWithDefaults = Required; +export type ReactOptionsWithDefaults = Required; export type RumOptionsWithDefaults = { disabled?: boolean; - sourcemaps?: RumSourcemapsOptionsWithDefaults; + sdk?: SDKOptionsWithDefaults; + react?: ReactOptionsWithDefaults; }; -export type RumOptionsWithSourcemaps = { - disabled?: boolean; - sourcemaps: RumSourcemapsOptionsWithDefaults; -}; +export type RumOptionsWithSdk = Assign; +export type RumOptionsWithReact = Assign< + RumOptionsWithDefaults, + { react: ReactOptionsWithDefaults } +>; export interface OptionsWithRum extends GetPluginsOptions { [CONFIG_KEY]: RumOptions; } - -export type Sourcemap = { - minifiedFilePath: string; - minifiedPathPrefix: MinifiedPathPrefix; - minifiedUrl: string; - relativePath: string; - sourcemapFilePath: string; -}; diff --git a/packages/plugins/rum/src/validate.ts b/packages/plugins/rum/src/validate.ts index 89448ab92..eeffb4d2e 100644 --- a/packages/plugins/rum/src/validate.ts +++ b/packages/plugins/rum/src/validate.ts @@ -8,23 +8,23 @@ import chalk from 'chalk'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; import type { OptionsWithRum, + ReactOptionsWithDefaults, RumOptions, RumOptionsWithDefaults, - RumSourcemapsOptionsWithDefaults, + SDKOptionsWithDefaults, } from './types'; -export const defaultIntakeUrl = `https://sourcemap-intake.${process.env.DATADOG_SITE || 'datadoghq.com'}/api/v2/srcmap`; - -// Deal with validation and defaults here. export const validateOptions = ( - config: Partial, + options: Partial, log: Logger, ): RumOptionsWithDefaults => { const errors: string[] = []; // Validate and add defaults sub-options. - const sourcemapsResults = validateSourcemapsOptions(config); - errors.push(...sourcemapsResults.errors); + const sdkResults = validateSDKOptions(options); + const reactResults = validateReactOptions(options); + + errors.push(...sdkResults.errors, ...reactResults.errors); // Throw if there are any errors. if (errors.length) { @@ -34,13 +34,18 @@ export const validateOptions = ( // Build the final configuration. const toReturn: RumOptionsWithDefaults = { - ...config[CONFIG_KEY], - sourcemaps: undefined, + ...options[CONFIG_KEY], + sdk: undefined, + react: undefined, }; // Fill in the defaults. - if (sourcemapsResults.config) { - toReturn.sourcemaps = sourcemapsResults.config; + if (sdkResults.config) { + toReturn.sdk = sdkResults.config; + } + + if (reactResults.config) { + toReturn.react = reactResults.config; } return toReturn; @@ -51,66 +56,85 @@ type ToReturn = { config?: T; }; -const validateMinifiedPathPrefix = (minifiedPathPrefix: string): boolean => { - let host; - try { - const objUrl = new URL(minifiedPathPrefix!); - host = objUrl.host; - } catch { - // Do nothing. - } +export const validateReactOptions = ( + options: Partial, +): ToReturn => { + const red = chalk.bold.red; + const validatedOptions: RumOptions = options[CONFIG_KEY] || {}; + const toReturn: ToReturn = { + errors: [], + }; + + if (validatedOptions.react) { + if (!options.rum?.sdk?.applicationId && options.rum?.react?.router) { + toReturn.errors.push( + `You must provide ${red('"rum.sdk.applicationId"')} to use ${red('"rum.react.router"')}.`, + ); + } + + const reactWithDefault: ReactOptionsWithDefaults = { + router: false, + ...validatedOptions.react, + }; - if (!host && !minifiedPathPrefix!.startsWith('/')) { - return false; + // Save the config. + toReturn.config = { + ...reactWithDefault, + ...validatedOptions.react, + }; } - return true; + return toReturn; }; -export const validateSourcemapsOptions = ( - config: Partial, -): ToReturn => { +export const validateSDKOptions = ( + options: Partial, +): ToReturn => { const red = chalk.bold.red; - const validatedOptions: RumOptions = config[CONFIG_KEY] || {}; - const toReturn: ToReturn> = { + const validatedOptions: RumOptions = options[CONFIG_KEY] || {}; + const toReturn: ToReturn = { errors: [], }; - if (validatedOptions.sourcemaps) { + if (validatedOptions.sdk) { // Validate the configuration. - if (!validatedOptions.sourcemaps.releaseVersion) { - toReturn.errors.push(`${red('sourcemaps.releaseVersion')} is required.`); - } - if (!validatedOptions.sourcemaps.service) { - toReturn.errors.push(`${red('sourcemaps.service')} is required.`); - } - if (!validatedOptions.sourcemaps.minifiedPathPrefix) { - toReturn.errors.push(`${red('sourcemaps.minifiedPathPrefix')} is required.`); + if (!validatedOptions.sdk.applicationId) { + toReturn.errors.push(`Missing ${red('applicationId')} in the SDK configuration.`); } - // Validate the minifiedPathPrefix. - if (validatedOptions.sourcemaps.minifiedPathPrefix) { - if (!validateMinifiedPathPrefix(validatedOptions.sourcemaps.minifiedPathPrefix)) { - toReturn.errors.push( - `${red('sourcemaps.minifiedPathPrefix')} must be a valid URL or start with '/'.`, - ); - } + // Check if we have all we need to fetch the client token if necessary. + if ((!options.auth?.apiKey || !options.auth?.appKey) && !validatedOptions.sdk.clientToken) { + toReturn.errors.push( + `Missing ${red('"auth.apiKey"')} and/or ${red('"auth.appKey"')} to fetch missing client token.`, + ); } - // Add the defaults. - const sourcemapsWithDefaults: RumSourcemapsOptionsWithDefaults = { - bailOnError: false, - dryRun: false, - maxConcurrency: 20, - intakeUrl: - process.env.DATADOG_SOURCEMAP_INTAKE_URL || - validatedOptions.sourcemaps.intakeUrl || - defaultIntakeUrl, - ...validatedOptions.sourcemaps, + const sdkWithDefault: SDKOptionsWithDefaults = { + applicationId: 'unknown_application_id', + allowUntrustedEvents: false, + compressIntakeRequests: false, + defaultPrivacyLevel: 'mask', + enablePrivacyForActionName: false, + sessionReplaySampleRate: 0, + sessionSampleRate: 100, + silentMultipleInit: false, + site: 'datadoghq.com', + startSessionReplayRecordingManually: false, + storeContextsAcrossPages: false, + telemetrySampleRate: 20, + traceSampleRate: 100, + trackingConsent: 'granted', + trackLongTasks: false, + trackResources: false, + trackUserInteractions: false, + trackViewsManually: false, }; // Save the config. - toReturn.config = sourcemapsWithDefaults; + toReturn.config = { + ...sdkWithDefault, + ...validatedOptions.sdk, + }; } return toReturn; diff --git a/packages/published/esbuild-plugin/README.md b/packages/published/esbuild-plugin/README.md index d90e66944..fca41be44 100644 --- a/packages/published/esbuild-plugin/README.md +++ b/packages/published/esbuild-plugin/README.md @@ -1,6 +1,6 @@ -# Datadog ESBuild Plugin +# Datadog esbuild Plugin -A ESBuild plugin to interact with Datadog from your ESBuild builds. +A esbuild plugin to interact with Datadog from your ESBuild builds. ## Installation diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index 5656ea06b..b771b7f77 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -65,6 +65,7 @@ "@rollup/plugin-commonjs": "28.0.1", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-terser": "0.4.4", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", "esbuild": "0.24.0", diff --git a/packages/published/esbuild-plugin/src/index.ts b/packages/published/esbuild-plugin/src/index.ts index c61f759f4..6ff523630 100644 --- a/packages/published/esbuild-plugin/src/index.ts +++ b/packages/published/esbuild-plugin/src/index.ts @@ -6,24 +6,32 @@ // Anything between #types-export-injection-marker // will be updated using the 'yarn cli integrity' command. +import type { Options } from '@dd/core/types'; +import type { + // #types-export-injection-marker + ErrorTrackingTypes, + RumTypes, + TelemetryTypes, + // #types-export-injection-marker +} from '@dd/factory'; import * as factory from '@dd/factory'; import esbuild from 'esbuild'; import pkg from '../package.json'; -export const datadogEsbuildPlugin = factory.buildPluginFactory({ - bundler: esbuild, - version: pkg.version, -}).esbuild; - -export type { Options as EsbuildPluginOptions } from '@dd/core/types'; - +export type EsbuildPluginOptions = Options; export type { // #types-export-injection-marker + ErrorTrackingTypes, RumTypes, TelemetryTypes, // #types-export-injection-marker -} from '@dd/factory'; +}; + +export const datadogEsbuildPlugin = factory.buildPluginFactory({ + bundler: esbuild, + version: pkg.version, +}).esbuild; export const version = pkg.version; export const helpers = factory.helpers; diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index e2f467f8a..f148c4567 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -65,6 +65,7 @@ "@rollup/plugin-commonjs": "28.0.1", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-terser": "0.4.4", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", "esbuild": "0.24.0", diff --git a/packages/published/rollup-plugin/src/index.ts b/packages/published/rollup-plugin/src/index.ts index a2ef6359d..59c46cba7 100644 --- a/packages/published/rollup-plugin/src/index.ts +++ b/packages/published/rollup-plugin/src/index.ts @@ -6,24 +6,32 @@ // Anything between #types-export-injection-marker // will be updated using the 'yarn cli integrity' command. +import type { Options } from '@dd/core/types'; +import type { + // #types-export-injection-marker + ErrorTrackingTypes, + RumTypes, + TelemetryTypes, + // #types-export-injection-marker +} from '@dd/factory'; import * as factory from '@dd/factory'; import rollup from 'rollup'; import pkg from '../package.json'; -export const datadogRollupPlugin = factory.buildPluginFactory({ - bundler: rollup, - version: pkg.version, -}).rollup; - -export type { Options as RollupPluginOptions } from '@dd/core/types'; - +export type RollupPluginOptions = Options; export type { // #types-export-injection-marker + ErrorTrackingTypes, RumTypes, TelemetryTypes, // #types-export-injection-marker -} from '@dd/factory'; +}; + +export const datadogRollupPlugin = factory.buildPluginFactory({ + bundler: rollup, + version: pkg.version, +}).rollup; export const version = pkg.version; export const helpers = factory.helpers; diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index 83ac23d80..7cb95e202 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -65,6 +65,7 @@ "@rollup/plugin-commonjs": "28.0.1", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-terser": "0.4.4", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", "esbuild": "0.24.0", diff --git a/packages/published/rspack-plugin/src/index.ts b/packages/published/rspack-plugin/src/index.ts index 516570b8c..aec0805e7 100644 --- a/packages/published/rspack-plugin/src/index.ts +++ b/packages/published/rspack-plugin/src/index.ts @@ -6,24 +6,32 @@ // Anything between #types-export-injection-marker // will be updated using the 'yarn cli integrity' command. +import type { Options } from '@dd/core/types'; +import type { + // #types-export-injection-marker + ErrorTrackingTypes, + RumTypes, + TelemetryTypes, + // #types-export-injection-marker +} from '@dd/factory'; import * as factory from '@dd/factory'; import rspack from '@rspack/core'; import pkg from '../package.json'; -export const datadogRspackPlugin = factory.buildPluginFactory({ - bundler: rspack, - version: pkg.version, -}).rspack; - -export type { Options as RspackPluginOptions } from '@dd/core/types'; - +export type RspackPluginOptions = Options; export type { // #types-export-injection-marker + ErrorTrackingTypes, RumTypes, TelemetryTypes, // #types-export-injection-marker -} from '@dd/factory'; +}; + +export const datadogRspackPlugin = factory.buildPluginFactory({ + bundler: rspack, + version: pkg.version, +}).rspack; export const version = pkg.version; export const helpers = factory.helpers; diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index 82cf1c26b..848c78f99 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -65,6 +65,7 @@ "@rollup/plugin-commonjs": "28.0.1", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-terser": "0.4.4", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", "esbuild": "0.24.0", diff --git a/packages/published/vite-plugin/src/index.ts b/packages/published/vite-plugin/src/index.ts index 67b281c2e..d7925a4e3 100644 --- a/packages/published/vite-plugin/src/index.ts +++ b/packages/published/vite-plugin/src/index.ts @@ -6,24 +6,32 @@ // Anything between #types-export-injection-marker // will be updated using the 'yarn cli integrity' command. +import type { Options } from '@dd/core/types'; +import type { + // #types-export-injection-marker + ErrorTrackingTypes, + RumTypes, + TelemetryTypes, + // #types-export-injection-marker +} from '@dd/factory'; import * as factory from '@dd/factory'; import vite from 'vite'; import pkg from '../package.json'; -export const datadogVitePlugin = factory.buildPluginFactory({ - bundler: vite, - version: pkg.version, -}).vite; - -export type { Options as VitePluginOptions } from '@dd/core/types'; - +export type VitePluginOptions = Options; export type { // #types-export-injection-marker + ErrorTrackingTypes, RumTypes, TelemetryTypes, // #types-export-injection-marker -} from '@dd/factory'; +}; + +export const datadogVitePlugin = factory.buildPluginFactory({ + bundler: vite, + version: pkg.version, +}).vite; export const version = pkg.version; export const helpers = factory.helpers; diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index 902221583..61e814a09 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -65,6 +65,7 @@ "@rollup/plugin-commonjs": "28.0.1", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-terser": "0.4.4", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", "esbuild": "0.24.0", diff --git a/packages/published/webpack-plugin/src/index.ts b/packages/published/webpack-plugin/src/index.ts index f6a57ace9..24a29bbfd 100644 --- a/packages/published/webpack-plugin/src/index.ts +++ b/packages/published/webpack-plugin/src/index.ts @@ -6,24 +6,32 @@ // Anything between #types-export-injection-marker // will be updated using the 'yarn cli integrity' command. +import type { Options } from '@dd/core/types'; +import type { + // #types-export-injection-marker + ErrorTrackingTypes, + RumTypes, + TelemetryTypes, + // #types-export-injection-marker +} from '@dd/factory'; import * as factory from '@dd/factory'; import webpack from 'webpack'; import pkg from '../package.json'; -export const datadogWebpackPlugin = factory.buildPluginFactory({ - bundler: webpack, - version: pkg.version, -}).webpack; - -export type { Options as WebpackPluginOptions } from '@dd/core/types'; - +export type WebpackPluginOptions = Options; export type { // #types-export-injection-marker + ErrorTrackingTypes, RumTypes, TelemetryTypes, // #types-export-injection-marker -} from '@dd/factory'; +}; + +export const datadogWebpackPlugin = factory.buildPluginFactory({ + bundler: webpack, + version: pkg.version, +}).webpack; export const version = pkg.version; export const helpers = factory.helpers; diff --git a/packages/tests/README.md b/packages/tests/README.md index 46c15d778..edde36c76 100644 --- a/packages/tests/README.md +++ b/packages/tests/README.md @@ -11,7 +11,6 @@ Especially useful for having mock projects, built with specific bundlers and run - [Run all the tests](#run-all-the-tests) -- [Run all the tests with all the logs](#run-all-the-tests-with-all-the-logs) - [Debug a test](#debug-a-test) - [Test a plugin](#test-a-plugin) - [Bootstrapping your test](#bootstrapping-your-test) @@ -26,14 +25,6 @@ Especially useful for having mock projects, built with specific bundlers and run yarn test ``` -## Run all the tests with all the logs - -By default, jest is in silent mode and won't show any logs. - -```bash -yarn test:noisy -``` - ## Debug a test You can target a single file the same as if you were using Jest's CLI. @@ -41,7 +32,7 @@ You can target a single file the same as if you were using Jest's CLI. Within your test you can then use `.only` or `.skip` to target a single test in particular. ```bash -yarn test:noisy packages/tests/... +yarn test packages/tests/... ``` ## Test a plugin @@ -91,26 +82,26 @@ describe('My very awesome plugin', () => { We currently support `webpack4`, `webpack5`, `esbuild`, `rollup` and `vite`.
So we need to ensure that our plugin works everywhere. -When you use `runBundlers()` in your setup (usually `beforeAll()`), it will run the build of [a very basic default mock project](/packages/tests/src/_jest/fixtures/main.js).
+When you use `runBundlers()` in your setup (usually `beforeAll()`), it will run the build of [a very basic default mock project](/packages/tests/src/_jest/fixtures/easy_project/main.js).
Since it's building in a seeded directory, to avoid any collision, it will also return a cleanup function, that you'll need to use in your teardown (usually `afterAll()`). During development, you may want to target a specific bundler, to reduce noise from the others.
For this, you can use the `--bundlers=,` flag when running your tests: ```bash -yarn test:noisy packages/tests/... --bundlers=webpack4,esbuild +yarn test packages/tests/... --bundlers=webpack4,esbuild ``` If you want to keep the built files for debugging purpose, you can use the `--cleanup=0` parameter: ```bash -yarn test:noisy packages/tests/... --cleanup=0 +yarn test packages/tests/... --cleanup=0 ``` If you want to also build the bundlers you're targeting, you can use the `--build=1` parameter: ```bash -yarn test:noisy packages/tests/... --build=1 +yarn test packages/tests/... --build=1 ``` ### More complex projects @@ -133,7 +124,6 @@ It will return the array of entries it created. Here's how you'd go with it: ```typescript -import { getWebpack4Entries } from '@dd/tests/_jest/helpers/xpackConfigs'; import { generateProject } from '@dd/tests/_jest/helpers/generateMassiveProject'; import { defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks'; import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; @@ -154,11 +144,7 @@ describe('Some very massive project', () => { }, // Mode production makes the build waaaaayyyyy too slow. webpack5: { mode: 'none', entry: entries }, - webpack4: { - mode: 'none', - // Webpack4 needs some help for pnp resolutions. - entry: getWebpack4Entries(entries), - }, + webpack4: { mode: 'none', entry: entries }, }; cleanup = await runBundlers(defaultPluginOptions, bundlerOverrides); diff --git a/packages/tests/jest.config.js b/packages/tests/jest.config.js index 57fd27844..2b2598680 100644 --- a/packages/tests/jest.config.js +++ b/packages/tests/jest.config.js @@ -7,7 +7,6 @@ module.exports = { clearMocks: true, globalSetup: '/src/_jest/globalSetup.ts', preset: 'ts-jest/presets/js-with-ts', - reporters: [['default', { summaryThreshold: 2 }]], // Without it, vite import is silently crashing the process with code SIGHUP 129 resetModules: true, roots: ['./src/'], diff --git a/packages/tests/package.json b/packages/tests/package.json index 422c50efd..b7db57e06 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -20,21 +20,21 @@ "scripts": { "build": "yarn clean && tsc", "clean": "rm -rf dist", - "test": "yarn test:noisy --silent", - "test:noisy": "JEST_CONFIG_TRANSPILE_ONLY=true VITE_CJS_IGNORE_WARNING=true NODE_OPTIONS=\"--openssl-legacy-provider --experimental-vm-modules ${NODE_OPTIONS:-}\" jest --verbose", + "test": "JEST_CONFIG_TRANSPILE_ONLY=true VITE_CJS_IGNORE_WARNING=true NODE_OPTIONS=\"--openssl-legacy-provider --experimental-vm-modules ${NODE_OPTIONS:-}\" jest", "typecheck": "tsc --noEmit" }, "dependencies": { "@datadog/esbuild-plugin": "workspace:*", "@datadog/rollup-plugin": "workspace:*", + "@datadog/rspack-plugin": "workspace:*", "@datadog/vite-plugin": "workspace:*", "@datadog/webpack-plugin": "workspace:*", "@dd/core": "workspace:*", + "@dd/error-tracking-plugin": "workspace:*", "@dd/internal-build-report-plugin": "workspace:*", "@dd/internal-bundler-report-plugin": "workspace:*", "@dd/internal-git-plugin": "workspace:*", "@dd/internal-injection-plugin": "workspace:*", - "@dd/rum-plugin": "workspace:*", "@dd/telemetry-plugin": "workspace:*", "@rollup/plugin-commonjs": "28.0.1", "clipanion": "4.0.0-rc.3", diff --git a/packages/tests/src/_jest/fixtures/.gitignore b/packages/tests/src/_jest/fixtures/.gitignore index 38ce858cb..5e2c6aa88 100644 --- a/packages/tests/src/_jest/fixtures/.gitignore +++ b/packages/tests/src/_jest/fixtures/.gitignore @@ -1 +1,11 @@ massiveProject +yarn-error.log +.yarn/* +!.yarn/cache +!.yarn/patches +!.yarn/releases +!.yarn/plugins +!.vscode + +node_modules/ +dist/ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/@remix-run-router-npm-1.21.0-22ebfe59d7-cf0fb69d19.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/@remix-run-router-npm-1.21.0-22ebfe59d7-cf0fb69d19.zip new file mode 100644 index 000000000..d57ee4e82 Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/@remix-run-router-npm-1.21.0-22ebfe59d7-cf0fb69d19.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/ansi-styles-npm-3.2.1-8cb8107983-d85ade01c1.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/ansi-styles-npm-3.2.1-8cb8107983-d85ade01c1.zip new file mode 100644 index 000000000..4ffdcc494 Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/ansi-styles-npm-3.2.1-8cb8107983-d85ade01c1.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/chalk-npm-2.3.1-f10c7b2b06-53f7346b01.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/chalk-npm-2.3.1-f10c7b2b06-53f7346b01.zip new file mode 100644 index 000000000..34fc41f20 Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/chalk-npm-2.3.1-f10c7b2b06-53f7346b01.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/color-convert-npm-1.9.3-1fe690075e-ffa3190250.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/color-convert-npm-1.9.3-1fe690075e-ffa3190250.zip new file mode 100644 index 000000000..c4d6feded Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/color-convert-npm-1.9.3-1fe690075e-ffa3190250.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/color-name-npm-1.1.3-728b7b5d39-09c5d3e33d.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/color-name-npm-1.1.3-728b7b5d39-09c5d3e33d.zip new file mode 100644 index 000000000..f158de9e2 Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/color-name-npm-1.1.3-728b7b5d39-09c5d3e33d.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/escape-string-regexp-npm-1.0.5-3284de402f-6092fda75c.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/escape-string-regexp-npm-1.0.5-3284de402f-6092fda75c.zip new file mode 100644 index 000000000..b7ea3be14 Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/escape-string-regexp-npm-1.0.5-3284de402f-6092fda75c.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/has-flag-npm-3.0.0-16ac11fe05-4a15638b45.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/has-flag-npm-3.0.0-16ac11fe05-4a15638b45.zip new file mode 100644 index 000000000..60eafa65f Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/has-flag-npm-3.0.0-16ac11fe05-4a15638b45.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/react-dom-npm-19.0.0-b7981c573e-aa64a2f199.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/react-dom-npm-19.0.0-b7981c573e-aa64a2f199.zip new file mode 100644 index 000000000..6ccb14bd6 Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/react-dom-npm-19.0.0-b7981c573e-aa64a2f199.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/react-npm-19.0.0-e33c9aa1c0-2490969c50.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/react-npm-19.0.0-e33c9aa1c0-2490969c50.zip new file mode 100644 index 000000000..93d264328 Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/react-npm-19.0.0-e33c9aa1c0-2490969c50.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/react-router-dom-npm-6.28.0-3bd3cd7fc0-e637825132.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/react-router-dom-npm-6.28.0-3bd3cd7fc0-e637825132.zip new file mode 100644 index 000000000..6fde01be5 Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/react-router-dom-npm-6.28.0-3bd3cd7fc0-e637825132.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/react-router-npm-6.28.0-8611821701-f021a64451.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/react-router-npm-6.28.0-8611821701-f021a64451.zip new file mode 100644 index 000000000..bc89de389 Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/react-router-npm-6.28.0-8611821701-f021a64451.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/scheduler-npm-0.25.0-f89e6cad04-e661e38503.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/scheduler-npm-0.25.0-f89e6cad04-e661e38503.zip new file mode 100644 index 000000000..05d831f8c Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/scheduler-npm-0.25.0-f89e6cad04-e661e38503.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarn/cache/supports-color-npm-5.5.0-183ac537bc-5f505c6fa3.zip b/packages/tests/src/_jest/fixtures/.yarn/cache/supports-color-npm-5.5.0-183ac537bc-5f505c6fa3.zip new file mode 100644 index 000000000..55a34c67d Binary files /dev/null and b/packages/tests/src/_jest/fixtures/.yarn/cache/supports-color-npm-5.5.0-183ac537bc-5f505c6fa3.zip differ diff --git a/packages/tests/src/_jest/fixtures/.yarnrc.yml b/packages/tests/src/_jest/fixtures/.yarnrc.yml new file mode 100644 index 000000000..2aa2d15c7 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/.yarnrc.yml @@ -0,0 +1,4 @@ +compressionLevel: mixed +defaultSemverRangePrefix: "" +enableGlobalCache: false +nodeLinker: node-modules diff --git a/packages/tests/src/_jest/fixtures/main.js b/packages/tests/src/_jest/fixtures/easy_project/main.js similarity index 100% rename from packages/tests/src/_jest/fixtures/main.js rename to packages/tests/src/_jest/fixtures/easy_project/main.js diff --git a/packages/tests/src/_jest/fixtures/easy_project/package.json b/packages/tests/src/_jest/fixtures/easy_project/package.json new file mode 100644 index 000000000..fe57ee635 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/easy_project/package.json @@ -0,0 +1,13 @@ +{ + "name": "@tests/easy_project", + "private": true, + "license": "MIT", + "author": "Datadog", + "packageManager": "yarn@4.2.1", + "devDependencies": { + "chalk": "2.3.1", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-router-dom": "6.28.0" + } +} diff --git a/packages/tests/src/_jest/fixtures/project/empty.js b/packages/tests/src/_jest/fixtures/empty.js similarity index 100% rename from packages/tests/src/_jest/fixtures/project/empty.js rename to packages/tests/src/_jest/fixtures/empty.js diff --git a/packages/tests/src/_jest/fixtures/file-to-inject.js b/packages/tests/src/_jest/fixtures/fake-file-to-inject-after.js similarity index 80% rename from packages/tests/src/_jest/fixtures/file-to-inject.js rename to packages/tests/src/_jest/fixtures/fake-file-to-inject-after.js index 8c9d16b3e..2645ec5a0 100644 --- a/packages/tests/src/_jest/fixtures/file-to-inject.js +++ b/packages/tests/src/_jest/fixtures/fake-file-to-inject-after.js @@ -2,4 +2,4 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -console.log("Hello injection from local file."); +console.log("Hello injection from local file in after."); \ No newline at end of file diff --git a/packages/tests/src/_jest/fixtures/fake-file-to-inject-before.js b/packages/tests/src/_jest/fixtures/fake-file-to-inject-before.js new file mode 100644 index 000000000..6162e3d88 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/fake-file-to-inject-before.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 in before."); \ No newline at end of file diff --git a/packages/tests/src/_jest/fixtures/fake-file-to-inject-middle.js b/packages/tests/src/_jest/fixtures/fake-file-to-inject-middle.js new file mode 100644 index 000000000..99815a1c5 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/fake-file-to-inject-middle.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 in middle."); \ No newline at end of file diff --git a/packages/tests/src/_jest/fixtures/project/main1.js b/packages/tests/src/_jest/fixtures/hard_project/main1.js similarity index 91% rename from packages/tests/src/_jest/fixtures/project/main1.js rename to packages/tests/src/_jest/fixtures/hard_project/main1.js index 2c2c0ff17..05793a717 100644 --- a/packages/tests/src/_jest/fixtures/project/main1.js +++ b/packages/tests/src/_jest/fixtures/hard_project/main1.js @@ -10,7 +10,7 @@ import fn2 from './workspaces/app/workspaceFile1.js'; // Add a third party dependency. import * as chalk from 'chalk'; -console.log(chalk.cyan('Hello world!')); +console.log(chalk.cyan('Hello World!')); fn(); fn2(); diff --git a/packages/tests/src/_jest/fixtures/project/main2.js b/packages/tests/src/_jest/fixtures/hard_project/main2.js similarity index 100% rename from packages/tests/src/_jest/fixtures/project/main2.js rename to packages/tests/src/_jest/fixtures/hard_project/main2.js diff --git a/packages/tests/src/_jest/fixtures/hard_project/package.json b/packages/tests/src/_jest/fixtures/hard_project/package.json new file mode 100644 index 000000000..ac25238f5 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/hard_project/package.json @@ -0,0 +1,15 @@ +{ + "name": "@tests/hard_project", + "private": true, + "license": "MIT", + "author": "Datadog", + "packageManager": "yarn@4.2.1", + "dependencies": { + "chalk": "2.3.1" + }, + "devDependencies": { + "react": "19.0.0", + "react-dom": "19.0.0", + "react-router-dom": "6.28.0" + } +} diff --git a/packages/tests/src/_jest/fixtures/project/src/srcFile0.js b/packages/tests/src/_jest/fixtures/hard_project/src/srcFile0.js similarity index 100% rename from packages/tests/src/_jest/fixtures/project/src/srcFile0.js rename to packages/tests/src/_jest/fixtures/hard_project/src/srcFile0.js diff --git a/packages/tests/src/_jest/fixtures/project/src/srcFile1.js b/packages/tests/src/_jest/fixtures/hard_project/src/srcFile1.js similarity index 100% rename from packages/tests/src/_jest/fixtures/project/src/srcFile1.js rename to packages/tests/src/_jest/fixtures/hard_project/src/srcFile1.js diff --git a/packages/tests/src/_jest/fixtures/project/workspaces/app/workspaceFile0.js b/packages/tests/src/_jest/fixtures/hard_project/workspaces/app/workspaceFile0.js similarity index 100% rename from packages/tests/src/_jest/fixtures/project/workspaces/app/workspaceFile0.js rename to packages/tests/src/_jest/fixtures/hard_project/workspaces/app/workspaceFile0.js diff --git a/packages/tests/src/_jest/fixtures/project/workspaces/app/workspaceFile1.js b/packages/tests/src/_jest/fixtures/hard_project/workspaces/app/workspaceFile1.js similarity index 100% rename from packages/tests/src/_jest/fixtures/project/workspaces/app/workspaceFile1.js rename to packages/tests/src/_jest/fixtures/hard_project/workspaces/app/workspaceFile1.js diff --git a/packages/tests/src/_jest/fixtures/project/package.json b/packages/tests/src/_jest/fixtures/package.json similarity index 50% rename from packages/tests/src/_jest/fixtures/project/package.json rename to packages/tests/src/_jest/fixtures/package.json index 77cc3b8b7..baafdfc02 100644 --- a/packages/tests/src/_jest/fixtures/project/package.json +++ b/packages/tests/src/_jest/fixtures/package.json @@ -1,10 +1,11 @@ { - "name": "project", + "name": "@tests/fixtures", "private": true, "license": "MIT", "author": "Datadog", "packageManager": "yarn@4.2.1", - "dependencies": { - "chalk": "2.3.1" - } + "workspaces": [ + "hard_project", + "easy_project" + ] } diff --git a/packages/tests/src/_jest/fixtures/yarn.lock b/packages/tests/src/_jest/fixtures/yarn.lock new file mode 100644 index 000000000..e8d79974f --- /dev/null +++ b/packages/tests/src/_jest/fixtures/yarn.lock @@ -0,0 +1,149 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10 + +"@remix-run/router@npm:1.21.0": + version: 1.21.0 + resolution: "@remix-run/router@npm:1.21.0" + checksum: 10/cf0fb69d19c1b79095ff67c59cea89086f3982a9a54c8a993818a60fc76e0ebab5a8db647c1a96a662729fad8e806ddd0a96622adf473f5a9f0b99998b2dbad4 + languageName: node + linkType: hard + +"@tests/easy_project@workspace:easy_project": + version: 0.0.0-use.local + resolution: "@tests/easy_project@workspace:easy_project" + dependencies: + chalk: "npm:2.3.1" + react: "npm:19.0.0" + react-dom: "npm:19.0.0" + react-router-dom: "npm:6.28.0" + languageName: unknown + linkType: soft + +"@tests/fixtures@workspace:.": + version: 0.0.0-use.local + resolution: "@tests/fixtures@workspace:." + languageName: unknown + linkType: soft + +"@tests/hard_project@workspace:hard_project": + version: 0.0.0-use.local + resolution: "@tests/hard_project@workspace:hard_project" + dependencies: + chalk: "npm:2.3.1" + react: "npm:19.0.0" + react-dom: "npm:19.0.0" + react-router-dom: "npm:6.28.0" + languageName: unknown + linkType: soft + +"ansi-styles@npm:^3.2.0": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10/d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665 + languageName: node + linkType: hard + +"chalk@npm:2.3.1": + version: 2.3.1 + resolution: "chalk@npm:2.3.1" + dependencies: + ansi-styles: "npm:^3.2.0" + escape-string-regexp: "npm:^1.0.5" + supports-color: "npm:^5.2.0" + checksum: 10/53f7346b01d5bd93cceb1645bf3858ef4a211b4c69be152e391cdbe386038308e227c14f5518c4f437cbca72054f0593c19f3ebc75b042892c79f46b0605f60b + languageName: node + linkType: hard + +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10/ffa319025045f2973919d155f25e7c00d08836b6b33ea2d205418c59bd63a665d713c52d9737a9e0fe467fb194b40fbef1d849bae80d674568ee220a31ef3d10 + languageName: node + linkType: hard + +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10/09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d + languageName: node + linkType: hard + +"escape-string-regexp@npm:^1.0.5": + version: 1.0.5 + resolution: "escape-string-regexp@npm:1.0.5" + checksum: 10/6092fda75c63b110c706b6a9bfde8a612ad595b628f0bd2147eea1d3406723020810e591effc7db1da91d80a71a737a313567c5abb3813e8d9c71f4aa595b410 + languageName: node + linkType: hard + +"has-flag@npm:^3.0.0": + version: 3.0.0 + resolution: "has-flag@npm:3.0.0" + checksum: 10/4a15638b454bf086c8148979aae044dd6e39d63904cd452d970374fa6a87623423da485dfb814e7be882e05c096a7ccf1ebd48e7e7501d0208d8384ff4dea73b + languageName: node + linkType: hard + +"react-dom@npm:19.0.0": + version: 19.0.0 + resolution: "react-dom@npm:19.0.0" + dependencies: + scheduler: "npm:^0.25.0" + peerDependencies: + react: ^19.0.0 + checksum: 10/aa64a2f1991042f516260e8b0eca0ae777b6c8f1aa2b5ae096e80bbb6ac9b005aef2bca697969841d34f7e1819556263476bdfea36c35092e8d9aefde3de2d9a + languageName: node + linkType: hard + +"react-router-dom@npm:6.28.0": + version: 6.28.0 + resolution: "react-router-dom@npm:6.28.0" + dependencies: + "@remix-run/router": "npm:1.21.0" + react-router: "npm:6.28.0" + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 10/e637825132ea96c3514ef7b8322f9bf0b752a942d6b4ffc4c20e389b5911726adf3dba8208ed4b97bf5b9c3bd465d9d1a1db1a58a610a8d528f18d890e0b143f + languageName: node + linkType: hard + +"react-router@npm:6.28.0": + version: 6.28.0 + resolution: "react-router@npm:6.28.0" + dependencies: + "@remix-run/router": "npm:1.21.0" + peerDependencies: + react: ">=16.8" + checksum: 10/f021a644513144884a567d9c2dcc432e8e3233f931378c219c5a3b5b842340f0faca86225a708bafca1e9010965afe1a7dada28aef5b7b6138c885c0552d9a7d + languageName: node + linkType: hard + +"react@npm:19.0.0": + version: 19.0.0 + resolution: "react@npm:19.0.0" + checksum: 10/2490969c503f644703c88990d20e4011fa6119ddeca451e9de48f6d7ab058d670d2852a5fcd3aa3cd90a923ab2815d532637bd4a814add402ae5c0d4f129ee71 + languageName: node + linkType: hard + +"scheduler@npm:^0.25.0": + version: 0.25.0 + resolution: "scheduler@npm:0.25.0" + checksum: 10/e661e38503ab29a153429a99203fefa764f28b35c079719eb5efdd2c1c1086522f6653d8ffce388209682c23891a6d1d32fa6badf53c35fb5b9cd0c55ace42de + languageName: node + linkType: hard + +"supports-color@npm:^5.2.0": + version: 5.5.0 + resolution: "supports-color@npm:5.5.0" + dependencies: + has-flag: "npm:^3.0.0" + checksum: 10/5f505c6fa3c6e05873b43af096ddeb22159831597649881aeb8572d6fe3b81e798cc10840d0c9735e0026b250368851b7f77b65e84f4e4daa820a4f69947f55b + languageName: node + linkType: hard diff --git a/packages/tests/src/_jest/globalSetup.ts b/packages/tests/src/_jest/globalSetup.ts index e87dd784a..81fd24ca6 100644 --- a/packages/tests/src/_jest/globalSetup.ts +++ b/packages/tests/src/_jest/globalSetup.ts @@ -2,11 +2,89 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { logTips } from './helpers/tips'; +import chalk from 'chalk'; +import { execFileSync } from 'child_process'; +import type { ExecFileSyncOptionsWithStringEncoding } from 'child_process'; +import path from 'path'; + +import { getEnv, logEnv, setupEnv } from './helpers/env'; + +const c = chalk.bold.dim; + +const setupGit = (execOptions: ExecFileSyncOptionsWithStringEncoding) => { + const setupSteps: { name: string; commands: string[]; fallbacks?: string[] }[] = [ + { + // Initialize a git repository. + name: 'Init', + commands: ['git init'], + }, + { + // Ensure we have a local user. + name: 'Git user', + commands: ['git config --local user.email'], + fallbacks: [ + 'git config --local user.email fake@example.com', + 'git config --local user.name fakeuser', + ], + }, + { + // Ensure origin exists + name: 'Origin', + commands: ['git ls-remote --get-url'], + fallbacks: ['git remote add origin fake_origin'], + }, + { + // Ensure HEAD exists + name: 'HEAD', + commands: ['git rev-parse --verify HEAD'], + // Fake HEAD. + fallbacks: ['git commit --allow-empty -n -m "abc"'], + }, + ]; + + const runCmds = (commands: string[]) => { + for (const command of commands) { + const args = command.split(' '); + execFileSync(args[0], args.slice(1), execOptions); + } + }; + for (const { name, commands, fallbacks } of setupSteps) { + try { + runCmds(commands); + } catch (e) { + if (!fallbacks || fallbacks.length === 0) { + throw e; + } + console.log(c.yellow(` - ${name} does not exist, creating it.`)); + runCmds(fallbacks); + } + } +}; const globalSetup = () => { + const timeId = `[${c.cyan('Test environment setup duration')}]`; + console.time(timeId); + const env = getEnv(process.argv); + // Setup the environment. + setupEnv(env); // Log some tips to the console. - logTips(); + logEnv(env); + + // Setup fixtures. + const execOptions: ExecFileSyncOptionsWithStringEncoding = { + cwd: path.resolve(__dirname, './fixtures'), + encoding: 'utf-8', + stdio: [], + }; + + try { + // Install dependencies. + execFileSync('yarn', ['install'], execOptions); + setupGit(execOptions); + } catch (e) { + console.error('Fixtures setup failed:', e); + } + console.timeEnd(timeId); }; export default globalSetup; diff --git a/packages/tests/src/_jest/helpers/configBundlers.ts b/packages/tests/src/_jest/helpers/configBundlers.ts index 3de7b2368..365d4723f 100644 --- a/packages/tests/src/_jest/helpers/configBundlers.ts +++ b/packages/tests/src/_jest/helpers/configBundlers.ts @@ -19,14 +19,15 @@ import webpack4 from 'webpack4'; import type { Configuration } from 'webpack5'; import webpack5 from 'webpack5'; -import { defaultDestination, defaultEntry, defaultPluginOptions } from './mocks'; -import type { BundlerOverrides } from './types'; -import { getBaseXpackConfig, getWebpack4Entries, getWebpackPlugin } from './xpackConfigs'; +import { getOutDir } from './env'; +import { defaultEntry, defaultPluginOptions } from './mocks'; +import type { BundlerOptionsOverrides } from './types'; +import { getBaseXpackConfig, getWebpackPlugin } from './xpackConfigs'; export const getRspackOptions = ( - seed: string, + workingDir: string, pluginOverrides: Partial = {}, - bundlerOverrides: BundlerOverrides['rspack'] = {}, + bundlerOverrides: BundlerOptionsOverrides['rspack'] = {}, ): RspackOptions => { const newPluginOptions = { ...defaultPluginOptions, @@ -34,16 +35,16 @@ export const getRspackOptions = ( }; return { - ...(getBaseXpackConfig(seed, 'rspack') as RspackOptions), + ...(getBaseXpackConfig(workingDir, 'rspack') as RspackOptions), plugins: [datadogRspackPlugin(newPluginOptions)], ...bundlerOverrides, }; }; export const getWebpack5Options = ( - seed: string, + workingDir: string, pluginOverrides: Partial = {}, - bundlerOverrides: BundlerOverrides['webpack5'] = {}, + bundlerOverrides: BundlerOptionsOverrides['webpack5'] = {}, ): Configuration => { const newPluginOptions = { ...defaultPluginOptions, @@ -53,16 +54,16 @@ export const getWebpack5Options = ( const plugin = getWebpackPlugin(newPluginOptions, webpack5); return { - ...getBaseXpackConfig(seed, 'webpack5'), + ...getBaseXpackConfig(workingDir, 'webpack5'), plugins: [plugin], ...bundlerOverrides, }; }; export const getWebpack4Options = ( - seed: string, + workingDir: string, pluginOverrides: Partial = {}, - bundlerOverrides: BundlerOverrides['webpack4'] = {}, + bundlerOverrides: BundlerOptionsOverrides['webpack4'] = {}, ): Configuration4 => { const newPluginOptions = { ...defaultPluginOptions, @@ -72,8 +73,7 @@ export const getWebpack4Options = ( const plugin = getWebpackPlugin(newPluginOptions, webpack4); return { - ...getBaseXpackConfig(seed, 'webpack4'), - entry: getWebpack4Entries(defaultEntry), + ...getBaseXpackConfig(workingDir, 'webpack4'), plugins: [plugin as unknown as Plugin], node: false, ...bundlerOverrides, @@ -81,9 +81,9 @@ export const getWebpack4Options = ( }; export const getEsbuildOptions = ( - seed: string, + workingDir: string, pluginOverrides: Partial = {}, - bundlerOverrides: BundlerOverrides['esbuild'] = {}, + bundlerOverrides: BundlerOptionsOverrides['esbuild'] = {}, ): BuildOptions => { const newPluginOptions = { ...defaultPluginOptions, @@ -91,12 +91,13 @@ export const getEsbuildOptions = ( }; return { + absWorkingDir: workingDir, bundle: true, chunkNames: 'chunk.[hash]', entryPoints: { main: defaultEntry }, entryNames: '[name]', format: 'esm', - outdir: path.join(defaultDestination, seed, 'esbuild'), + outdir: getOutDir(workingDir, 'esbuild'), plugins: [datadogEsbuildPlugin(newPluginOptions)], sourcemap: true, splitting: true, @@ -104,9 +105,10 @@ export const getEsbuildOptions = ( }; }; -export const getRollupBaseConfig = (seed: string, bundlerName: string): RollupOptions => { +export const getRollupBaseConfig = (workingDir: string, bundlerName: string): RollupOptions => { + const outDir = getOutDir(workingDir, bundlerName); return { - input: defaultEntry, + input: path.resolve(workingDir, defaultEntry), onwarn: (warning, handler) => { if ( !/Circular dependency:/.test(warning.message) && @@ -118,7 +120,7 @@ export const getRollupBaseConfig = (seed: string, bundlerName: string): RollupOp output: { chunkFileNames: 'chunk.[hash].js', compact: false, - dir: path.join(defaultDestination, seed, bundlerName), + dir: outDir, entryFileNames: '[name].js', sourcemap: true, }, @@ -126,16 +128,16 @@ export const getRollupBaseConfig = (seed: string, bundlerName: string): RollupOp }; export const getRollupOptions = ( - seed: string, + workingDir: string, pluginOverrides: Partial = {}, - bundlerOverrides: BundlerOverrides['rollup'] = {}, + bundlerOverrides: BundlerOptionsOverrides['rollup'] = {}, ): RollupOptions => { const newPluginOptions = { ...defaultPluginOptions, ...pluginOverrides, }; - const baseConfig = getRollupBaseConfig(seed, 'rollup'); + const baseConfig = getRollupBaseConfig(workingDir, 'rollup'); return { ...baseConfig, @@ -153,18 +155,19 @@ export const getRollupOptions = ( }; export const getViteOptions = ( - seed: string, + workingDir: string, pluginOverrides: Partial = {}, - bundlerOverrides: BundlerOverrides['vite'] = {}, + bundlerOverrides: BundlerOptionsOverrides['vite'] = {}, ): UserConfig => { const newPluginOptions = { ...defaultPluginOptions, ...pluginOverrides, }; - const baseConfig = getRollupBaseConfig(seed, 'vite'); + const baseConfig = getRollupBaseConfig(workingDir, 'vite'); return { + root: workingDir, build: { assetsDir: '', // Disable assets dir to simplify the test. minify: false, diff --git a/packages/tests/src/_jest/helpers/constants.ts b/packages/tests/src/_jest/helpers/constants.ts index 0e70787be..345b43b73 100644 --- a/packages/tests/src/_jest/helpers/constants.ts +++ b/packages/tests/src/_jest/helpers/constants.ts @@ -20,17 +20,3 @@ export const BUNDLER_VERSIONS: Record = { webpack4: require('webpack4').version, webpack5: require('webpack5').version, }; - -// Handle --cleanup flag. -export const NO_CLEANUP = process.argv.includes('--cleanup=0'); - -// Handle --build flag. -export const NEED_BUILD = process.argv.includes('--build=1'); - -// Handle --bundlers flag. -export const REQUESTED_BUNDLERS = process.argv.includes('--bundlers') - ? process.argv[process.argv.indexOf('--bundlers') + 1].split(',') - : process.argv - .find((arg) => arg.startsWith('--bundlers=')) - ?.split('=')[1] - .split(',') ?? []; diff --git a/packages/tests/src/_jest/helpers/env.ts b/packages/tests/src/_jest/helpers/env.ts new file mode 100644 index 000000000..61b0e7bbf --- /dev/null +++ b/packages/tests/src/_jest/helpers/env.ts @@ -0,0 +1,135 @@ +// 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 { FULL_NAME_BUNDLERS } from '@dd/core/constants'; +import { mkdir } from '@dd/core/helpers'; +import type { BundlerFullName } from '@dd/core/types'; +import { bgYellow, dim, green, red } from '@dd/tools/helpers'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const fsp = fs.promises; + +type TestEnv = { + NO_CLEANUP: boolean; + NEED_BUILD: boolean; + REQUESTED_BUNDLERS: string[]; +}; + +export const getEnv = (argv: string[]): TestEnv => { + // Handle --cleanup flag. + const NO_CLEANUP = argv.includes('--cleanup=0'); + + // Handle --build flag. + const NEED_BUILD = argv.includes('--build=1'); + + // Handle --bundlers flag. + const REQUESTED_BUNDLERS = argv.includes('--bundlers') + ? argv[argv.indexOf('--bundlers') + 1].split(',') + : argv + .find((arg) => arg.startsWith('--bundlers=')) + ?.split('=')[1] + .split(',') ?? []; + + return { + NO_CLEANUP, + NEED_BUILD, + REQUESTED_BUNDLERS, + }; +}; + +export const setupEnv = (env: TestEnv): void => { + const { NO_CLEANUP, NEED_BUILD, REQUESTED_BUNDLERS } = env; + + if (NO_CLEANUP) { + process.env.NO_CLEANUP = '1'; + } + + if (NEED_BUILD) { + process.env.NEED_BUILD = '1'; + } + + if (REQUESTED_BUNDLERS.length) { + process.env.REQUESTED_BUNDLERS = REQUESTED_BUNDLERS.join(','); + } +}; + +export const logEnv = (env: TestEnv) => { + const { NO_CLEANUP, NEED_BUILD, REQUESTED_BUNDLERS } = env; + const envLogs = []; + if (NO_CLEANUP) { + envLogs.push(bgYellow(" Won't clean up ")); + } + + if (NEED_BUILD) { + envLogs.push(bgYellow(' Will also build used plugins ')); + } + + if (REQUESTED_BUNDLERS.length) { + if ( + !(REQUESTED_BUNDLERS as BundlerFullName[]).every((bundler) => + FULL_NAME_BUNDLERS.includes(bundler), + ) + ) { + throw new Error( + `Invalid "${red(`--bundlers ${REQUESTED_BUNDLERS.join(',')}`)}".\nValid bundlers are ${FULL_NAME_BUNDLERS.map( + (b) => green(b), + ) + .sort() + .join(', ')}.`, + ); + } + const bundlersList = REQUESTED_BUNDLERS.map((bundler) => green(bundler)).join(', '); + envLogs.push(`Running ${bgYellow(' ONLY ')} for ${bundlersList}.`); + } + + if (!NO_CLEANUP || !NEED_BUILD || REQUESTED_BUNDLERS.length) { + const tips: string[] = []; + if (!NO_CLEANUP) { + tips.push(` ${green('--cleanup=0')} to keep the built artifacts.`); + } + if (!NEED_BUILD) { + tips.push(` ${green('--build=1')} to force the build of the used plugins.`); + } + if (!REQUESTED_BUNDLERS.length) { + tips.push(` ${green('--bundlers=webpack4,esbuild')} to only use specified bundlers.`); + } + envLogs.push(dim(`\nYou can also use : \n${tips.join('\n')}\n`)); + } + + if (envLogs.length) { + console.log(`\n${envLogs.join('\n')}\n`); + } +}; + +export const getOutDir = (workingDir: string, folderName: string): string => { + return path.resolve(workingDir, `./dist/${folderName}`); +}; + +const FIXTURE_DIR = path.resolve(__dirname, '../fixtures'); +export const prepareWorkingDir = async (seed: string) => { + const timeId = `[${dim.cyan('Preparing working directory duration')}]`; + console.time(timeId); + const tmpDir = os.tmpdir(); + const workingDir = path.resolve(tmpDir, seed); + + // Create the directory. + await mkdir(workingDir); + + // Need to use realpathSync to avoid issues with symlinks on macos (prefix with /private). + // cf: https://github.com/nodejs/node/issues/11422 + const realWorkingDir = await fsp.realpath(workingDir); + + // Copy mock projects into it. + await fsp.cp(`${FIXTURE_DIR}/`, `${realWorkingDir}/`, { + recursive: true, + errorOnExist: true, + force: true, + }); + + console.timeEnd(timeId); + + return realWorkingDir; +}; diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index fb71e71e8..2cd96b71a 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -4,6 +4,7 @@ import { outputJsonSync } from '@dd/core/helpers'; import type { + BuildReport, File, GetCustomPlugins, GetPluginsOptions, @@ -13,35 +14,28 @@ import type { LogLevel, Options, } from '@dd/core/types'; -import { serializeBuildReport } from '@dd/internal-build-report-plugin/helpers'; -import { getSourcemapsConfiguration } from '@dd/tests/plugins/rum/testHelpers'; +import { getAbsolutePath, serializeBuildReport } from '@dd/internal-build-report-plugin/helpers'; +import { getSourcemapsConfiguration } from '@dd/tests/plugins/error-tracking/testHelpers'; import { getTelemetryConfiguration } from '@dd/tests/plugins/telemetry/testHelpers'; +import type { PluginBuild } from 'esbuild'; import path from 'path'; -import type { Configuration as Configuration4 } from 'webpack4'; -import type { BundlerOverrides } from './types'; -import { getBaseXpackConfig, getWebpack4Entries } from './xpackConfigs'; - -if (!process.env.PROJECT_CWD) { - throw new Error('Please update the usage of `process.env.PROJECT_CWD`.'); -} -const ROOT = process.env.PROJECT_CWD!; +import type { BundlerOptionsOverrides, BundlerOverrides } from './types'; +import { getBaseXpackConfig } from './xpackConfigs'; export const FAKE_URL = 'https://example.com'; export const API_PATH = '/v2/srcmap'; export const INTAKE_URL = `${FAKE_URL}${API_PATH}`; -export const defaultEntry = '@dd/tests/_jest/fixtures/main.js'; +export const defaultEntry = './easy_project/main.js'; export const defaultEntries = { - app1: '@dd/tests/_jest/fixtures/project/main1.js', - app2: '@dd/tests/_jest/fixtures/project/main2.js', + app1: './hard_project/main1.js', + app2: './hard_project/main2.js', }; -export const defaultDestination = path.resolve(ROOT, 'packages/tests/src/_jest/fixtures/dist'); +export const defaultAuth = { apiKey: '123', appKey: '123' }; export const defaultPluginOptions: GetPluginsOptions = { - auth: { - apiKey: '123', - }, + auth: defaultAuth, disableGit: false, logLevel: 'debug', }; @@ -64,20 +58,67 @@ const logFn: Logger = { }; export const mockLogger: Logger = logFn; +export const getEsbuildMock = (options: Partial = {}): PluginBuild => { + return { + resolve: async (filepath) => { + return { + errors: [], + warnings: [], + external: false, + sideEffects: false, + namespace: '', + suffix: '', + pluginData: {}, + path: getAbsolutePath(process.cwd(), filepath), + }; + }, + onStart: jest.fn(), + onEnd: jest.fn(), + onResolve: jest.fn(), + onLoad: jest.fn(), + onDispose: jest.fn(), + ...options, + esbuild: { + context: jest.fn(), + build: jest.fn(), + buildSync: jest.fn(), + transform: jest.fn(), + transformSync: jest.fn(), + formatMessages: jest.fn(), + formatMessagesSync: jest.fn(), + analyzeMetafile: jest.fn(), + analyzeMetafileSync: jest.fn(), + initialize: jest.fn(), + version: '1.0.0', + ...(options.esbuild || {}), + }, + initialOptions: { + ...(options.initialOptions || {}), + }, + }; +}; + +export const getMockBuild = (overrides: Partial = {}): BuildReport => ({ + errors: [], + warnings: [], + logs: [], + ...overrides, + bundler: { + name: 'esbuild', + fullName: 'esbuild', + version: 'FAKE_VERSION', + ...(overrides.bundler || {}), + }, +}); + export const getContextMock = (options: Partial = {}): GlobalContext => { return { - auth: { apiKey: 'FAKE_API_KEY' }, + auth: defaultAuth, bundler: { - name: 'esbuild', - fullName: 'esbuild', + ...getMockBuild().bundler, outDir: '/cwd/path', - version: 'FAKE_VERSION', - }, - build: { - warnings: [], - errors: [], - logs: [], }, + build: getMockBuild(), cwd: '/cwd/path', inject: jest.fn(), pluginNames: [], @@ -87,55 +128,69 @@ export const getContextMock = (options: Partial = {}): GlobalCont }; }; -export const getComplexBuildOverrides = ( - overrides: BundlerOverrides = {}, -): Required => { - const bundlerOverrides = { - rollup: { - input: defaultEntries, - ...overrides.rollup, - }, - vite: { - input: defaultEntries, - ...overrides.vite, - }, - esbuild: { - entryPoints: defaultEntries, - ...overrides.esbuild, - }, - rspack: { entry: defaultEntries, ...overrides.rspack }, - webpack5: { entry: defaultEntries, ...overrides.webpack5 }, - webpack4: { - entry: getWebpack4Entries(defaultEntries), - ...overrides.webpack4, - }, - }; +export const getComplexBuildOverrides = + (overrides?: BundlerOverrides) => + (workingDir: string): Required => { + const overridesResolved = + typeof overrides === 'function' ? overrides(workingDir) : overrides || {}; - return bundlerOverrides; -}; + // Using a function to avoid mutation of the same object later down the line. + const entries = () => + Object.fromEntries( + Object.entries(defaultEntries).map(([key, value]) => [ + key, + path.resolve(workingDir, value), + ]), + ); + + const bundlerOverrides = { + rollup: { + input: entries(), + ...overridesResolved.rollup, + }, + vite: { + input: entries(), + ...overridesResolved.vite, + }, + esbuild: { + entryPoints: entries(), + ...overridesResolved.esbuild, + }, + rspack: { entry: entries(), ...overridesResolved.rspack }, + webpack5: { entry: entries(), ...overridesResolved.webpack5 }, + webpack4: { entry: entries(), ...overridesResolved.webpack4 }, + }; + + return bundlerOverrides; + }; // To get a node safe build. export const getNodeSafeBuildOverrides = ( - overrides: BundlerOverrides = {}, -): Required => { + workingDir: string, + overrides?: BundlerOverrides, +): Required => { + const overridesResolved = + typeof overrides === 'function' ? overrides(workingDir) : overrides || {}; // We don't care about the seed and the bundler name // as we won't use the output config here. - const baseWebpack = getBaseXpackConfig('fake_seed', 'fake_bundler'); - const bundlerOverrides: Required = { + const baseWebpack = getBaseXpackConfig('fake_seed/dist', 'fake_bundler'); + const bundlerOverrides: Required = { rollup: { + ...overridesResolved.rollup, output: { + ...overridesResolved.rollup?.output, format: 'cjs', }, - ...overrides.rollup, }, vite: { + ...overridesResolved.vite, output: { + ...overridesResolved.vite?.output, format: 'cjs', }, - ...overrides.vite, }, esbuild: { - ...overrides.esbuild, + ...overridesResolved.esbuild, }, rspack: { target: 'node', @@ -143,7 +198,7 @@ export const getNodeSafeBuildOverrides = ( ...baseWebpack.optimization, splitChunks: false, }, - ...overrides.rspack, + ...overridesResolved.rspack, }, webpack5: { target: 'node', @@ -151,15 +206,15 @@ export const getNodeSafeBuildOverrides = ( ...baseWebpack.optimization, splitChunks: false, }, - ...overrides.webpack5, + ...overridesResolved.webpack5, }, webpack4: { target: 'node', optimization: { - ...(baseWebpack.optimization as Configuration4['optimization']), + ...baseWebpack.optimization, splitChunks: false, }, - ...overrides.webpack4, + ...overridesResolved.webpack4, }, }; @@ -170,9 +225,16 @@ export const getNodeSafeBuildOverrides = ( export const getFullPluginConfig = (overrides: Partial = {}): Options => { return { ...defaultPluginOptions, - rum: { + errorTracking: { sourcemaps: getSourcemapsConfiguration(), }, + rum: { + sdk: { + applicationId: '123', + clientToken: '123', + }, + react: { router: true }, + }, telemetry: getTelemetryConfiguration(), ...overrides, }; @@ -203,10 +265,10 @@ export const debugFilesPlugins = (context: GlobalContext): CustomPlugins => { const xpackPlugin: IterableElement['webpack'] & IterableElement['rspack'] = (compiler) => { - type Compilation = Parameters[1]>[0]; + type Stats = Parameters[1]>[0]; - compiler.hooks.afterEmit.tap('bundler-outputs', (compilation: Compilation) => { - const stats = compilation.getStats().toJson({ + compiler.hooks.done.tap('bundler-outputs', (stats: Stats) => { + const statsJson = stats.toJson({ all: false, assets: true, children: true, @@ -227,7 +289,7 @@ export const debugFilesPlugins = (context: GlobalContext): CustomPlugins => { }); outputJsonSync( path.resolve(context.bundler.outDir, `output.${context.bundler.fullName}.json`), - stats, + statsJson, ); }); }; @@ -274,4 +336,4 @@ export const filterOutParticularities = (input: File) => // Exclude webpack buildin modules, which are webpack internal dependencies. !input.filepath.includes('webpack4/buildin') && // Exclude webpack's fake entry point. - !input.filepath.includes('fixtures/project/empty.js'); + !input.filepath.includes('fixtures/empty.js'); diff --git a/packages/tests/src/_jest/helpers/runBundlers.ts b/packages/tests/src/_jest/helpers/runBundlers.ts index ada0b3d96..59f8fb7b0 100644 --- a/packages/tests/src/_jest/helpers/runBundlers.ts +++ b/packages/tests/src/_jest/helpers/runBundlers.ts @@ -2,12 +2,11 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { rm } from '@dd/core/helpers'; +import { getUniqueId, rm } from '@dd/core/helpers'; import type { Options } from '@dd/core/types'; import { executeSync, green } from '@dd/tools/helpers'; import type { RspackOptions, Stats as RspackStats } from '@rspack/core'; import type { BuildOptions } from 'esbuild'; -import path from 'path'; import type { RollupOptions } from 'rollup'; import type { Configuration as Configuration4, Stats as Stats4 } from 'webpack4'; import type { Configuration, Stats } from 'webpack5'; @@ -20,9 +19,18 @@ import { getWebpack4Options, getWebpack5Options, } from './configBundlers'; -import { NEED_BUILD, NO_CLEANUP, PLUGIN_VERSIONS, REQUESTED_BUNDLERS } from './constants'; -import { defaultDestination } from './mocks'; -import type { Bundler, BundlerRunFunction, CleanupFn } from './types'; +import { PLUGIN_VERSIONS } from './constants'; +import { prepareWorkingDir } from './env'; +import type { + Bundler, + BundlerRunFunction, + CleanupFn, + BundlerOverrides, + CleanupEverythingFn, +} from './types'; + +// Get the environment variables. +const { NO_CLEANUP, NEED_BUILD, REQUESTED_BUNDLERS } = process.env; const xpackCallback = ( err: Error | null, @@ -37,7 +45,7 @@ const xpackCallback = ( } if (!stats) { - reject('No stats returned from webpack.'); + reject('No stats returned.'); return; } @@ -82,11 +90,11 @@ const getCleanupFunction = }; export const runRspack: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial = {}, ) => { - const bundlerConfigs = getRspackOptions(seed, pluginOverrides, bundlerOverrides); + const bundlerConfigs = getRspackOptions(workingDir, pluginOverrides, bundlerOverrides); const { rspack } = await import('@rspack/core'); const errors = []; @@ -105,11 +113,11 @@ export const runRspack: BundlerRunFunction = async ( }; export const runWebpack5: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial = {}, ) => { - const bundlerConfigs = getWebpack5Options(seed, pluginOverrides, bundlerOverrides); + const bundlerConfigs = getWebpack5Options(workingDir, pluginOverrides, bundlerOverrides); const { webpack } = await import('webpack5'); const errors = []; @@ -128,11 +136,11 @@ export const runWebpack5: BundlerRunFunction = async ( }; export const runWebpack4: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial = {}, ) => { - const bundlerConfigs = getWebpack4Options(seed, pluginOverrides, bundlerOverrides); + const bundlerConfigs = getWebpack4Options(workingDir, pluginOverrides, bundlerOverrides); const webpack = (await import('webpack4')).default; const errors = []; @@ -151,11 +159,11 @@ export const runWebpack4: BundlerRunFunction = async ( }; export const runEsbuild: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial = {}, ) => { - const bundlerConfigs = getEsbuildOptions(seed, pluginOverrides, bundlerOverrides); + const bundlerConfigs = getEsbuildOptions(workingDir, pluginOverrides, bundlerOverrides); const { build } = await import('esbuild'); const errors = []; @@ -170,11 +178,11 @@ export const runEsbuild: BundlerRunFunction = async ( }; export const runVite: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial = {}, ) => { - const bundlerConfigs = getViteOptions(seed, pluginOverrides, bundlerOverrides); + const bundlerConfigs = getViteOptions(workingDir, pluginOverrides, bundlerOverrides); const vite = await import('vite'); const errors = []; try { @@ -195,11 +203,11 @@ export const runVite: BundlerRunFunction = async ( }; export const runRollup: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial = {}, ) => { - const bundlerConfigs = getRollupOptions(seed, pluginOverrides, bundlerOverrides); + const bundlerConfigs = getRollupOptions(workingDir, pluginOverrides, bundlerOverrides); const { rollup } = await import('rollup'); const errors = []; @@ -272,8 +280,9 @@ const allBundlers: Bundler[] = [ }, ]; +const requestedBundlers = REQUESTED_BUNDLERS ? REQUESTED_BUNDLERS.split(',') : []; export const BUNDLERS: Bundler[] = allBundlers.filter( - (bundler) => REQUESTED_BUNDLERS.length === 0 || REQUESTED_BUNDLERS.includes(bundler.name), + (bundler) => requestedBundlers.length === 0 || requestedBundlers.includes(bundler.name), ); // Build only if needed. @@ -284,35 +293,39 @@ if (NEED_BUILD) { for (const bundler of bundlersToBuild) { console.log(`Building ${green(bundler)}...`); + // Can't do parallel builds because no await at root. executeSync('yarn', ['workspace', bundler, 'run', 'build']); } } export const runBundlers = async ( pluginOverrides: Partial = {}, - bundlerOverrides: Record = {}, + bundlerOverrides?: BundlerOverrides, bundlers?: string[], -): Promise => { - const cleanups: CleanupFn[] = []; +): Promise => { const errors: string[] = []; // Generate a seed to avoid collision of builds. - const seed: string = `${Date.now()}-${jest.getSeed()}`; + const seed: string = `${jest.getSeed()}.${getUniqueId()}`; const bundlersToRun = BUNDLERS.filter( (bundler) => !bundlers || bundlers.includes(bundler.name), ); + const workingDir = await prepareWorkingDir(seed); + + const bundlerOverridesResolved = + typeof bundlerOverrides === 'function' + ? bundlerOverrides(workingDir) + : bundlerOverrides || {}; + const runBundlerFunction = async (bundler: Bundler) => { - let bundlerOverride = {}; - if (bundlerOverrides[bundler.name]) { - bundlerOverride = bundlerOverrides[bundler.name]; - } + const bundlerOverride = bundlerOverridesResolved[bundler.name] || {}; let result: Awaited>; // Isolate each runs to avoid conflicts between tests. await jest.isolateModulesAsync(async () => { - result = await bundler.run(seed, pluginOverrides, bundlerOverride); + result = await bundler.run(workingDir, pluginOverrides, bundlerOverride); }); return result!; }; @@ -323,25 +336,19 @@ export const runBundlers = async ( // eslint-disable-next-line no-await-in-loop results.push(await runBundlerFunction(bundler)); } - cleanups.push(...results.map((result) => result.cleanup)); errors.push(...results.map((result) => result.errors).flat()); - // Add a cleanup for the root seeded directory. - cleanups.push(getCleanupFunction('Root', [path.resolve(defaultDestination, seed)])); - const cleanupEverything = async () => { try { - await Promise.all(cleanups.map((cleanup) => cleanup())); + // Cleanup working directory. + await getCleanupFunction('Root', [workingDir])(); } catch (e) { console.error('Error during cleanup', e); } }; - if (errors.length) { - // We'll throw, so clean everything first. - await cleanupEverything(); - throw new Error(errors.join('\n')); - } + cleanupEverything.errors = errors; + cleanupEverything.workingDir = workingDir; // Return a cleanUp function. return cleanupEverything; diff --git a/packages/tests/src/_jest/helpers/tips.ts b/packages/tests/src/_jest/helpers/tips.ts deleted file mode 100644 index 7af7394fb..000000000 --- a/packages/tests/src/_jest/helpers/tips.ts +++ /dev/null @@ -1,51 +0,0 @@ -// 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 { FULL_NAME_BUNDLERS } from '@dd/core/constants'; -import type { BundlerFullName } from '@dd/core/types'; -import { bgYellow, dim, green, red } from '@dd/tools/helpers'; - -import { NEED_BUILD, NO_CLEANUP, REQUESTED_BUNDLERS } from './constants'; - -export const logTips = () => { - if (NO_CLEANUP) { - console.log(bgYellow(" Won't clean up ")); - } - - if (NEED_BUILD) { - console.log(bgYellow(' Will also build used plugins ')); - } - - if (REQUESTED_BUNDLERS.length) { - if ( - !(REQUESTED_BUNDLERS as BundlerFullName[]).every((bundler) => - FULL_NAME_BUNDLERS.includes(bundler), - ) - ) { - throw new Error( - `Invalid "${red(`--bundlers ${REQUESTED_BUNDLERS.join(',')}`)}".\nValid bundlers are ${FULL_NAME_BUNDLERS.map( - (b) => green(b), - ) - .sort() - .join(', ')}.`, - ); - } - const bundlersList = REQUESTED_BUNDLERS.map((bundler) => green(bundler)).join(', '); - console.log(`Running ${bgYellow(' ONLY ')} for ${bundlersList}.`); - } - - if (!NO_CLEANUP || !NEED_BUILD || REQUESTED_BUNDLERS.length) { - const tips: string[] = []; - if (!NO_CLEANUP) { - tips.push(` ${green('--cleanup=0')} to keep the built artifacts.`); - } - if (!NEED_BUILD) { - tips.push(` ${green('--build=1')} to force the build of the used plugins.`); - } - if (!REQUESTED_BUNDLERS.length) { - tips.push(` ${green('--bundlers=webpack4,esbuild')} to only use specified bundlers.`); - } - console.log(dim(`\nYou can also use : \n${tips.join('\n')}\n`)); - } -}; diff --git a/packages/tests/src/_jest/helpers/types.ts b/packages/tests/src/_jest/helpers/types.ts index 62935f3a6..30c0ce84a 100644 --- a/packages/tests/src/_jest/helpers/types.ts +++ b/packages/tests/src/_jest/helpers/types.ts @@ -9,7 +9,7 @@ import type { RollupOptions } from 'rollup'; import type { Configuration as Configuration4 } from 'webpack4'; import type { Configuration } from 'webpack5'; -export type BundlerOverrides = { +export type BundlerOptionsOverrides = { rollup?: Partial; vite?: Partial; esbuild?: Partial; @@ -18,6 +18,10 @@ export type BundlerOverrides = { webpack4?: Partial; }; +export type BundlerOverrides = + | BundlerOptionsOverrides + | ((workingDir: string) => BundlerOptionsOverrides); + export type Bundler = { name: BundlerFullName; // TODO: Better type this without "any". @@ -27,6 +31,10 @@ export type Bundler = { }; export type CleanupFn = () => Promise; +export type CleanupEverythingFn = CleanupFn & { + errors: string[]; + workingDir: string; +}; export type BundlerRunFunction = ( seed: string, pluginOverrides: Options, diff --git a/packages/tests/src/_jest/helpers/xpackConfigs.ts b/packages/tests/src/_jest/helpers/xpackConfigs.ts index d6e16efee..877442eb8 100644 --- a/packages/tests/src/_jest/helpers/xpackConfigs.ts +++ b/packages/tests/src/_jest/helpers/xpackConfigs.ts @@ -2,7 +2,6 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { getResolvedPath } from '@dd/core/helpers'; import type { Options } from '@dd/core/types'; import { buildPluginFactory } from '@dd/factory'; import type { RspackOptions } from '@rspack/core'; @@ -13,37 +12,25 @@ import type { Configuration as Configuration5 } from 'webpack5'; import type webpack5 from 'webpack5'; import { PLUGIN_VERSIONS } from './constants'; -import { defaultDestination, defaultEntry } from './mocks'; +import { getOutDir } from './env'; +import { defaultEntry } from './mocks'; export const getBaseXpackConfig = ( - seed: string, + workingDir: string, bundlerName: string, ): Configuration5 & Configuration4 & RspackOptions => { + const outDir = getOutDir(workingDir, bundlerName); return { - entry: defaultEntry, + context: workingDir, + entry: path.resolve(workingDir, defaultEntry), mode: 'production', output: { - path: path.join(defaultDestination, seed, bundlerName), + path: outDir, filename: `[name].js`, }, devtool: 'source-map', optimization: { minimize: false, - splitChunks: { - chunks: 'initial', - minSize: 1, - minChunks: 1, - name: (...args: any[]) => { - // This is supposedly not available on rspack (based on types). - // But it is. - if (args[2]) { - return `chunk.${args[2]}`; - } - - // This is never reached. - return `chunk.shouldNeverHappen`; - }, - }, }, }; }; @@ -59,26 +46,3 @@ export const getWebpackPlugin = ( version: PLUGIN_VERSIONS.webpack, }).webpack(pluginOptions); }; - -// Webpack 4 doesn't support pnp resolution OOTB. -export const getWebpack4Entries = ( - entries: NonNullable, - cwd: string = process.cwd(), -): Configuration4['entry'] => { - const getTrueRelativePath = (filepath: string) => { - return `./${path.relative(cwd, getResolvedPath(filepath))}`; - }; - - if (typeof entries === 'string') { - return getTrueRelativePath(entries); - } - - return Object.fromEntries( - Object.entries(entries).map(([name, filepath]) => [ - name, - Array.isArray(filepath) - ? filepath.map(getTrueRelativePath) - : getTrueRelativePath(filepath), - ]), - ); -}; diff --git a/packages/tests/src/_jest/setupAfterEnv.ts b/packages/tests/src/_jest/setupAfterEnv.ts index 32e1b7dd7..c48729407 100644 --- a/packages/tests/src/_jest/setupAfterEnv.ts +++ b/packages/tests/src/_jest/setupAfterEnv.ts @@ -6,26 +6,22 @@ import console from 'console'; import nock from 'nock'; import { toBeWithinRange } from './toBeWithinRange.ts'; -import { toRepeatStringRange } from './toRepeatStringRange.ts'; import { toRepeatStringTimes } from './toRepeatStringTimes.ts'; // Extend Jest's expect with custom matchers. expect.extend({ toBeWithinRange, toRepeatStringTimes, - toRepeatStringRange, }); interface CustomMatchers { toBeWithinRange(floor: number, ceiling: number): R; - toRepeatStringTimes(st: string, occurences: number): R; - toRepeatStringRange(st: string, range: [number, number]): R; + toRepeatStringTimes(st: string | RegExp, occurences: number | [number, number]): R; } interface NonCustomMatchers { toBeWithinRange(floor: number, ceiling: number): number; - toRepeatStringTimes(st: string, occurences: number): string; - toRepeatStringRange(st: string, range: [number, number]): string; + toRepeatStringTimes(st: string | RegExp, occurences: number | [number, number]): string; } declare global { @@ -41,4 +37,5 @@ declare global { nock.disableNetConnect(); // Have a simpler, less verbose, console.log output. +// This bypasses Jest's --silent flag though. global.console = console; diff --git a/packages/tests/src/_jest/toRepeatStringRange.ts b/packages/tests/src/_jest/toRepeatStringRange.ts deleted file mode 100644 index f0b5cf863..000000000 --- a/packages/tests/src/_jest/toRepeatStringRange.ts +++ /dev/null @@ -1,35 +0,0 @@ -// 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 { MatcherFunction } from 'expect'; - -export const toRepeatStringRange: MatcherFunction<[st: string, range: [number, number]]> = - // `st` and `occurences` get types from the line above - function toRepeatStringRange(actual, st, range) { - if (typeof actual !== 'string' || typeof st !== 'string') { - throw new TypeError('Only works with strings.'); - } - if (!Array.isArray(range) || range.length !== 2) { - throw new TypeError('Need an array of two numbers for "range".'); - } - - const { truncateString } = jest.requireActual('@dd/core/helpers'); - const result = actual.split(st).length - 1; - const pass = result <= range[1] && result >= range[0]; - - const time = (num: number) => (num > 1 ? 'times' : 'time'); - const failure = !pass - ? `\nBut got it ${this.utils.printReceived(result)} ${time(result)}.` - : '.'; - const expected = this.utils.printReceived(truncateString(actual).replace(/\n/g, ' ')); - - const message = `Expected: ${expected} -To repeat ${this.utils.printExpected(st)} -Between ${this.utils.printExpected(`${range[0]} and ${range[1]}`)} times${failure}`; - - return { - message: () => message, - pass, - }; - }; diff --git a/packages/tests/src/_jest/toRepeatStringTimes.ts b/packages/tests/src/_jest/toRepeatStringTimes.ts index 053fb3e65..d54cbbeea 100644 --- a/packages/tests/src/_jest/toRepeatStringTimes.ts +++ b/packages/tests/src/_jest/toRepeatStringTimes.ts @@ -4,29 +4,38 @@ import type { MatcherFunction } from 'expect'; -export const toRepeatStringTimes: MatcherFunction<[st: string, occurences: number]> = +export const toRepeatStringTimes: MatcherFunction< + [st: string | RegExp, occurences: number | [number, number]] +> = // `st` and `occurences` get types from the line above function toRepeatStringTimes(actual, st, occurences) { - if (typeof actual !== 'string' || typeof st !== 'string') { - throw new TypeError('Only works with strings.'); + if (typeof actual !== 'string' || (typeof st !== 'string' && !(st instanceof RegExp))) { + throw new TypeError('Only works with strings or RegExp.'); } - if (typeof occurences !== 'number') { - throw new TypeError('Need a number here.'); + if ( + typeof occurences !== 'number' && + (!Array.isArray(occurences) || occurences.length !== 2) + ) { + throw new TypeError('Need a number or an array of two numbers.'); } const { truncateString } = jest.requireActual('@dd/core/helpers'); const result = actual.split(st).length - 1; - const pass = result === occurences; + const isRange = Array.isArray(occurences); + const pass = isRange + ? result <= occurences[1] && result >= occurences[0] + : result === occurences; const time = (num: number) => (num > 1 ? 'times' : 'time'); const failure = !pass - ? `\nBut got it ${this.utils.printReceived(result)} ${time(result)}.` - : ''; + ? `\n\nBut got it ${this.utils.printReceived(result)} ${time(result)}.` + : '.'; const expected = this.utils.printReceived(truncateString(actual).replace(/\n/g, ' ')); + const expectedSt = isRange + ? `Between ${this.utils.printExpected(`${occurences[0]} and ${occurences[1]}`)} times${failure}` + : `Exactly ${this.utils.printExpected(occurences)} ${time(occurences)}${failure}`; - const message = `Expected: ${expected} -To repeat ${this.utils.printExpected(st)} -Exactly ${this.utils.printExpected(occurences)} ${time(occurences)}${failure}.`; + const message = `Expected: ${expected}\nTo repeat ${this.utils.printExpected(st)}\n${expectedSt}`; return { message: () => message, diff --git a/packages/tests/src/core/helpers.test.ts b/packages/tests/src/core/helpers.test.ts index 94f6d4313..d76a1a197 100644 --- a/packages/tests/src/core/helpers.test.ts +++ b/packages/tests/src/core/helpers.test.ts @@ -2,9 +2,20 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { RequestOpts } from '@dd/core/types'; -import { API_PATH, FAKE_URL, INTAKE_URL } from '@dd/tests/_jest/helpers/mocks'; +import { getEsbuildEntries } from '@dd/core/helpers'; +import type { RequestOpts, ResolvedEntry } from '@dd/core/types'; +import { + API_PATH, + FAKE_URL, + INTAKE_URL, + getContextMock, + getEsbuildMock, + mockLogger, +} from '@dd/tests/_jest/helpers/mocks'; +import type { BuildOptions } from 'esbuild'; +import { vol } from 'memfs'; import nock from 'nock'; +import path from 'path'; import { Readable } from 'stream'; import { createGzip } from 'zlib'; @@ -20,6 +31,9 @@ jest.mock('async-retry', () => { }); }); +// Use mock files. +jest.mock('fs', () => require('memfs').fs); + describe('Core Helpers', () => { describe('formatDuration', () => { test.each([ @@ -34,6 +48,165 @@ describe('Core Helpers', () => { }); }); + describe('getEsbuildEntries', () => { + beforeEach(() => { + // Emulate some fixtures. + vol.fromJSON({ + 'fixtures/main.js': '', + 'fixtures/in/main2.js': '', + 'fixtures/in/main3.js': '', + 'fixtures/main4.js': '', + }); + }); + + afterEach(() => { + vol.reset(); + }); + + const expectations: [string, BuildOptions['entryPoints'], ResolvedEntry[]][] = [ + [ + 'Array of strings', + [path.join(process.cwd(), 'fixtures/main.js')], + [ + { + original: path.join(process.cwd(), 'fixtures/main.js'), + resolved: path.join(process.cwd(), 'fixtures/main.js'), + }, + ], + ], + [ + 'Object with entry names', + { + app1: path.join(process.cwd(), 'fixtures/main.js'), + app2: path.join(process.cwd(), 'fixtures/main4.js'), + }, + [ + { + name: 'app1', + original: path.join(process.cwd(), 'fixtures/main.js'), + resolved: path.join(process.cwd(), 'fixtures/main.js'), + }, + { + name: 'app2', + original: path.join(process.cwd(), 'fixtures/main4.js'), + resolved: path.join(process.cwd(), 'fixtures/main4.js'), + }, + ], + ], + [ + 'Array of objects with in and out', + [ + { + in: 'fixtures/main.js', + out: 'outdir/main.js', + }, + ], + [ + { + original: 'fixtures/main.js', + resolved: path.join(process.cwd(), 'fixtures/main.js'), + }, + ], + ], + ['undefined', undefined, []], + [ + 'Array of strings with glob', + [path.join(process.cwd(), 'fixtures/*.js')], + [ + { + original: path.join(process.cwd(), 'fixtures/*.js'), + resolved: path.join(process.cwd(), 'fixtures/main4.js'), + }, + { + original: path.join(process.cwd(), 'fixtures/*.js'), + resolved: path.join(process.cwd(), 'fixtures/main.js'), + }, + ], + ], + [ + 'Object with entry names with glob', + { + app1: path.join(process.cwd(), 'fixtures/*.js'), + app2: path.join(process.cwd(), 'fixtures/**/*.js'), + }, + [ + { + name: 'app1', + original: path.join(process.cwd(), 'fixtures/*.js'), + resolved: path.join(process.cwd(), 'fixtures/main4.js'), + }, + { + name: 'app1', + original: path.join(process.cwd(), 'fixtures/*.js'), + resolved: path.join(process.cwd(), 'fixtures/main.js'), + }, + { + name: 'app2', + original: path.join(process.cwd(), 'fixtures/**/*.js'), + resolved: path.join(process.cwd(), 'fixtures/main4.js'), + }, + { + name: 'app2', + original: path.join(process.cwd(), 'fixtures/**/*.js'), + resolved: path.join(process.cwd(), 'fixtures/main.js'), + }, + { + name: 'app2', + original: path.join(process.cwd(), 'fixtures/**/*.js'), + resolved: path.join(process.cwd(), 'fixtures/in/main3.js'), + }, + { + name: 'app2', + original: path.join(process.cwd(), 'fixtures/**/*.js'), + resolved: path.join(process.cwd(), 'fixtures/in/main2.js'), + }, + ], + ], + [ + 'Array of objects with in and out with globs', + [ + { + in: 'fixtures/*.js', + out: 'outdir/main.js', + }, + { + in: 'fixtures/main4.js', + out: 'outdir/main4.js', + }, + ], + [ + { + original: 'fixtures/*.js', + resolved: path.join(process.cwd(), 'fixtures/main4.js'), + }, + { + original: 'fixtures/*.js', + resolved: path.join(process.cwd(), 'fixtures/main.js'), + }, + { + original: 'fixtures/main4.js', + resolved: path.join(process.cwd(), 'fixtures/main4.js'), + }, + ], + ], + ]; + test.each(expectations)( + 'Should return the right map of entrynames for "%s".', + async (name, entryPoints, entryNames) => { + const result = await getEsbuildEntries( + getEsbuildMock({ + initialOptions: { + entryPoints, + }, + }), + getContextMock(), + mockLogger, + ); + expect(result).toEqual(entryNames); + }, + ); + }); + describe('doRequest', () => { const getDataStream = () => { const gz = createGzip(); @@ -122,6 +295,33 @@ describe('Core Helpers', () => { }).rejects.toThrow('Response body object should not be disturbed or locked'); expect(scope.isDone()).toBe(true); }); + + test('Should add authentication headers when needed.', async () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve(new Response('{}'))); + const { doRequest } = await import('@dd/core/helpers'); + await doRequest({ + ...requestOpts, + auth: { + apiKey: 'api_key', + appKey: 'app_key', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + INTAKE_URL, + expect.objectContaining({ + headers: { + // Coming from the getDataMock. + 'Content-Encoding': 'gzip', + // Coming from the requestOpts.auth. + 'DD-API-KEY': 'api_key', + 'DD-APPLICATION-KEY': 'app_key', + }, + }), + ); + }); }); describe('truncateString', () => { diff --git a/packages/tests/src/factory/helpers.test.ts b/packages/tests/src/factory/helpers.test.ts index 890b533f5..7b782a4e6 100644 --- a/packages/tests/src/factory/helpers.test.ts +++ b/packages/tests/src/factory/helpers.test.ts @@ -5,7 +5,7 @@ import type { BuildReport, GlobalContext, Logger, Options, ToInjectItem } from '@dd/core/types'; import { getContext, getLoggerFactory } from '@dd/factory/helpers'; import { BUNDLER_VERSIONS } from '@dd/tests/_jest/helpers/constants'; -import { defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks'; +import { defaultPluginOptions, getMockBuild } from '@dd/tests/_jest/helpers/mocks'; import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; import type { CleanupFn } from '@dd/tests/_jest/helpers/types'; import stripAnsi from 'strip-ansi'; @@ -71,7 +71,7 @@ describe('Factory Helpers', () => { }); test('Should inject items for the injection plugin.', () => { - const injections: ToInjectItem[] = []; + const injections: Map = new Map(); const context = getContext({ options: defaultPluginOptions, bundlerName: 'webpack', @@ -81,13 +81,13 @@ describe('Factory Helpers', () => { }); const injectedItem: ToInjectItem = { type: 'code', value: 'injected' }; context.inject(injectedItem); - expect(injections).toEqual([injectedItem]); + expect(Array.from(injections.entries())).toEqual([[expect.any(String), injectedItem]]); }); }); describe('getLoggerFactory', () => { const setupLogger = (name: string): [Logger, BuildReport] => { - const mockBuild = { errors: [], warnings: [], logs: [] }; + const mockBuild = getMockBuild(); const loggerFactory = getLoggerFactory(mockBuild, 'debug'); const logger = loggerFactory(name); @@ -111,41 +111,42 @@ describe('Factory Helpers', () => { const assessLogs = (name: string) => { expect(logMock).toHaveBeenCalledTimes(2); - expect(getOutput(logMock, 0)).toBe(`[info|${name}] An info message.`); - expect(getOutput(logMock, 1)).toBe(`[debug|${name}] A debug message.`); + expect(getOutput(logMock, 0)).toBe(`[info|esbuild|${name}] An info message.`); + expect(getOutput(logMock, 1)).toBe(`[debug|esbuild|${name}] A debug message.`); expect(errorMock).toHaveBeenCalledTimes(1); - expect(getOutput(errorMock, 0)).toBe(`[error|${name}] An error occurred.`); + expect(getOutput(errorMock, 0)).toBe(`[error|esbuild|${name}] An error occurred.`); expect(warnMock).toHaveBeenCalledTimes(1); - expect(getOutput(warnMock, 0)).toBe(`[warn|${name}] A warning message.`); + expect(getOutput(warnMock, 0)).toBe(`[warn|esbuild|${name}] A warning message.`); }; const assessReport = (name: string, buildReport: BuildReport) => { expect(buildReport.logs).toHaveLength(4); - expect(buildReport.logs[0]).toEqual({ + const baseLog = { + bundler: 'esbuild', pluginName: name, + time: expect.any(Number), + }; + expect(buildReport.logs[0]).toEqual({ + ...baseLog, type: 'error', message: 'An error occurred.', - time: expect.any(Number), }); expect(buildReport.logs[1]).toEqual({ - pluginName: name, + ...baseLog, type: 'warn', message: 'A warning message.', - time: expect.any(Number), }); expect(buildReport.logs[2]).toEqual({ - pluginName: name, + ...baseLog, type: 'info', message: 'An info message.', - time: expect.any(Number), }); expect(buildReport.logs[3]).toEqual({ - pluginName: name, + ...baseLog, type: 'debug', message: 'A debug message.', - time: expect.any(Number), }); expect(buildReport.errors).toEqual(['An error occurred.']); diff --git a/packages/tests/src/plugins/build-report/esbuild.test.ts b/packages/tests/src/plugins/build-report/esbuild.test.ts deleted file mode 100644 index 56792b59f..000000000 --- a/packages/tests/src/plugins/build-report/esbuild.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// 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 { getEntryNames } from '@dd/internal-build-report-plugin/esbuild'; -import { getContextMock } from '@dd/tests/_jest/helpers/mocks'; -import { vol } from 'memfs'; -import path from 'path'; - -jest.mock('fs', () => require('memfs').fs); - -describe('Build report plugin esbuild', () => { - describe('getEntrynames', () => { - beforeEach(() => { - // Emulate some fixtures. - vol.fromJSON({ - 'fixtures/main.js': '', - 'fixtures/in/main2.js': '', - 'fixtures/in/main3.js': '', - 'fixtures/main4.js': '', - }); - }); - - afterEach(() => { - vol.reset(); - }); - const expectations: [string, Parameters[0], Map][] = [ - [ - 'Array of strings', - [path.join(process.cwd(), 'fixtures/main.js')], - new Map([['fixtures/main.js', 'fixtures/main.js']]), - ], - [ - 'Object with entry names', - { - app1: path.join(process.cwd(), 'fixtures/main.js'), - app2: path.join(process.cwd(), 'fixtures/main4.js'), - }, - new Map([ - ['fixtures/main.js', 'app1'], - ['fixtures/main4.js', 'app2'], - ]), - ], - [ - 'Array of objects with in and out', - [ - { - in: 'fixtures/main.js', - out: 'outdir/main.js', - }, - ], - new Map([['fixtures/main.js', 'fixtures/main.js']]), - ], - ['undefined', undefined, new Map()], - [ - 'Array of strings with glob', - [path.join(process.cwd(), 'fixtures/*.js')], - new Map([ - ['fixtures/main.js', 'fixtures/main.js'], - ['fixtures/main4.js', 'fixtures/main4.js'], - ]), - ], - [ - 'Object with entry names with glob', - { - app1: path.join(process.cwd(), 'fixtures/*.js'), - app2: path.join(process.cwd(), 'fixtures/**/*.js'), - }, - new Map([ - ['fixtures/main.js', 'app1'], - ['fixtures/in/main2.js', 'app2'], - ['fixtures/in/main3.js', 'app2'], - ['fixtures/main.js', 'app2'], - // We expect the latest entry to take precendence. - ['fixtures/main4.js', 'app2'], - ]), - ], - [ - 'Array of objects with in and out with globs', - [ - { - in: 'fixtures/*.js', - out: 'outdir/main.js', - }, - { - in: 'fixtures/main4.js', - out: 'outdir/main4.js', - }, - ], - new Map([ - ['fixtures/main.js', 'fixtures/main.js'], - ['fixtures/main4.js', 'fixtures/main4.js'], - ]), - ], - ]; - test.each(expectations)( - 'Should return the right map of entrynames for "%s".', - (name, entryPoints, entryNames) => { - const result = getEntryNames( - entryPoints, - getContextMock({ - cwd: process.cwd(), - bundler: { - name: 'esbuild', - fullName: 'esbuild', - outDir: path.join(process.cwd(), 'outdir'), - version: '1.0.0', - }, - }), - ); - expect(result).toEqual(entryNames); - }, - ); - }); -}); diff --git a/packages/tests/src/plugins/build-report/index.test.ts b/packages/tests/src/plugins/build-report/index.test.ts index 9249b0f87..c251fd262 100644 --- a/packages/tests/src/plugins/build-report/index.test.ts +++ b/packages/tests/src/plugins/build-report/index.test.ts @@ -2,7 +2,6 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { getResolvedPath } from '@dd/core/helpers'; import type { Input, Entry, @@ -25,8 +24,11 @@ import { getComplexBuildOverrides, } from '@dd/tests/_jest/helpers/mocks'; import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; -import type { CleanupFn } from '@dd/tests/_jest/helpers/types'; -import { getWebpack4Entries } from '@dd/tests/_jest/helpers/xpackConfigs'; +import type { + BundlerOptionsOverrides, + CleanupEverythingFn, + CleanupFn, +} from '@dd/tests/_jest/helpers/types'; import path from 'path'; const sortFiles = (a: File | Output | Entry, b: File | Output | Entry) => { @@ -64,7 +66,7 @@ describe('Build Report Plugin', () => { describe('Basic build', () => { const bundlerOutdir: Record = {}; const buildReports: Record = {}; - let cleanup: CleanupFn; + let cleanup: CleanupEverythingFn; beforeAll(async () => { cleanup = await runBundlers(getPluginConfig(bundlerOutdir, buildReports)); @@ -76,8 +78,8 @@ describe('Build Report Plugin', () => { const expectedInput = () => expect.objectContaining({ - name: `src/_jest/fixtures/main.js`, - filepath: getResolvedPath(defaultEntry), + name: `easy_project/main.js`, + filepath: path.resolve(cleanup.workingDir, defaultEntry), dependencies: new Set(), dependents: new Set(), size: 302, @@ -90,8 +92,8 @@ describe('Build Report Plugin', () => { filepath: path.join(outDir, 'main.js'), inputs: [ expect.objectContaining({ - name: `src/_jest/fixtures/main.js`, - filepath: getResolvedPath(defaultEntry), + name: `easy_project/main.js`, + filepath: path.resolve(cleanup.workingDir, defaultEntry), dependencies: new Set(), dependents: new Set(), size: expect.any(Number), @@ -179,7 +181,7 @@ describe('Build Report Plugin', () => { // Intercept contexts to verify it at the moment they're used. const bundlerOutdir: Record = {}; const buildReports: Record = {}; - let cleanup: CleanupFn; + let cleanup: CleanupEverythingFn; beforeAll(async () => { cleanup = await runBundlers( @@ -194,8 +196,8 @@ describe('Build Report Plugin', () => { const expectedInput = (name: string) => expect.objectContaining({ - name: `src/_jest/fixtures/project/${name}.js`, - filepath: path.join(process.cwd(), `src/_jest/fixtures/project/${name}.js`), + name: `hard_project/${name}.js`, + filepath: path.join(cleanup.workingDir, `hard_project/${name}.js`), dependencies: expect.any(Array), dependents: [], size: expect.any(Number), @@ -227,12 +229,12 @@ describe('Build Report Plugin', () => { 'color-convert/route.js', 'color-name/index.js', 'escape-string-regexp/index.js', - 'src/_jest/fixtures/project/main1.js', - 'src/_jest/fixtures/project/main2.js', - 'src/_jest/fixtures/project/src/srcFile0.js', - 'src/_jest/fixtures/project/src/srcFile1.js', - 'src/_jest/fixtures/project/workspaces/app/workspaceFile0.js', - 'src/_jest/fixtures/project/workspaces/app/workspaceFile1.js', + 'hard_project/main1.js', + 'hard_project/main2.js', + 'hard_project/src/srcFile0.js', + 'hard_project/src/srcFile1.js', + 'hard_project/workspaces/app/workspaceFile0.js', + 'hard_project/workspaces/app/workspaceFile1.js', 'supports-color/browser.js', ]); }); @@ -269,7 +271,7 @@ describe('Build Report Plugin', () => { .sort(sortFiles); const entryFiles = inputs.filter((file) => - file.name.startsWith('src/_jest/fixtures/project/main'), + file.name.startsWith('hard_project/main'), ); expect(entryFiles).toEqual([expectedInput('main1'), expectedInput('main2')]); @@ -277,19 +279,19 @@ describe('Build Report Plugin', () => { test.each([ { - filename: 'src/_jest/fixtures/project/main1.js', + filename: 'hard_project/main1.js', dependencies: [ 'chalk/index.js', - 'src/_jest/fixtures/project/src/srcFile0.js', - 'src/_jest/fixtures/project/workspaces/app/workspaceFile1.js', + 'hard_project/src/srcFile0.js', + 'hard_project/workspaces/app/workspaceFile1.js', ], dependents: [], }, { - filename: 'src/_jest/fixtures/project/main2.js', + filename: 'hard_project/main2.js', dependencies: [ - 'src/_jest/fixtures/project/src/srcFile0.js', - 'src/_jest/fixtures/project/src/srcFile1.js', + 'hard_project/src/srcFile0.js', + 'hard_project/src/srcFile1.js', ], dependents: [], }, @@ -308,7 +310,7 @@ describe('Build Report Plugin', () => { 'supports-color/browser.js', ], // It should also have a single dependent which is main1. - dependents: ['src/_jest/fixtures/project/main1.js'], + dependents: ['hard_project/main1.js'], }, { filename: 'color-convert/route.js', @@ -553,7 +555,7 @@ describe('Build Report Plugin', () => { beforeAll(async () => { const entries = await generateProject(2, 500); - const bundlerOverrides = { + const bundlerOverrides: BundlerOptionsOverrides = { rollup: { input: entries, }, @@ -565,10 +567,7 @@ describe('Build Report Plugin', () => { }, // Mode production makes the build waaaaayyyyy too slow. webpack5: { mode: 'none', entry: entries }, - webpack4: { - mode: 'none', - entry: getWebpack4Entries(entries), - }, + webpack4: { mode: 'none', entry: entries }, }; cleanup = await runBundlers( getPluginConfig(bundlerOutdir, buildReports, { logLevel: 'error', telemetry: {} }), diff --git a/packages/tests/src/plugins/bundler-report/index.test.ts b/packages/tests/src/plugins/bundler-report/index.test.ts index bfedc1fc2..44b25101b 100644 --- a/packages/tests/src/plugins/bundler-report/index.test.ts +++ b/packages/tests/src/plugins/bundler-report/index.test.ts @@ -2,15 +2,16 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { BundlerReport, Options } from '@dd/core/types'; -import { defaultDestination, defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks'; +import type { BundlerReport, GlobalContext, Options } from '@dd/core/types'; +import { defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks'; import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; -import type { CleanupFn } from '@dd/tests/_jest/helpers/types'; +import type { CleanupEverythingFn } from '@dd/tests/_jest/helpers/types'; describe('Bundler Report', () => { // Intercept contexts to verify it at the moment they're used. const bundlerReports: Record = {}; - let cleanup: CleanupFn; + const contexts: Record> = {}; + let cleanup: CleanupEverythingFn; beforeAll(async () => { const pluginConfig: Options = { ...defaultPluginOptions, @@ -22,6 +23,9 @@ describe('Bundler Report', () => { name: 'custom-plugin', writeBundle() { const config = context.bundler.rawConfig; + contexts[bundlerName] = { + cwd: context.cwd, + }; bundlerReports[bundlerName] = JSON.parse( JSON.stringify({ ...context.bundler, @@ -48,7 +52,7 @@ describe('Bundler Report', () => { const report = bundlerReports[name]; const outDir = report.outDir; - const expectedOutDir = new RegExp(`^${defaultDestination}/[^/]+/${name}$`); + const expectedOutDir = new RegExp(`^${cleanup.workingDir}/[^/]+/${name}$`); expect(outDir).toMatch(expectedOutDir); }); @@ -59,5 +63,9 @@ describe('Bundler Report', () => { expect(rawConfig).toBeDefined(); expect(rawConfig).toEqual(expect.any(Object)); }); + + test('Should have the right cwd.', () => { + expect(contexts[name].cwd).toBe(cleanup.workingDir); + }); }); }); diff --git a/packages/tests/src/plugins/error-tracking/index.test.ts b/packages/tests/src/plugins/error-tracking/index.test.ts new file mode 100644 index 000000000..59585a6be --- /dev/null +++ b/packages/tests/src/plugins/error-tracking/index.test.ts @@ -0,0 +1,46 @@ +// 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 { uploadSourcemaps } from '@dd/error-tracking-plugin/sourcemaps/index'; +import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; +import type { CleanupFn } from '@dd/tests/_jest/helpers/types'; + +import { getSourcemapsConfiguration } from './testHelpers'; + +jest.mock('@dd/error-tracking-plugin/sourcemaps/index', () => { + return { + uploadSourcemaps: jest.fn(), + }; +}); + +const uploadSourcemapsMock = jest.mocked(uploadSourcemaps); + +describe('Error Tracking Plugin', () => { + const cleanups: CleanupFn[] = []; + + afterAll(async () => { + await Promise.all(cleanups.map((cleanup) => cleanup())); + }); + + test('Should process the sourcemaps if enabled.', async () => { + cleanups.push( + await runBundlers({ + errorTracking: { + sourcemaps: getSourcemapsConfiguration(), + }, + }), + ); + expect(uploadSourcemapsMock).toHaveBeenCalledTimes(BUNDLERS.length); + }); + + test('Should not process the sourcemaps with no options.', async () => { + cleanups.push( + await runBundlers({ + errorTracking: {}, + }), + ); + + expect(uploadSourcemapsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tests/src/plugins/rum/sourcemaps/files.test.ts b/packages/tests/src/plugins/error-tracking/sourcemaps/files.test.ts similarity index 89% rename from packages/tests/src/plugins/rum/sourcemaps/files.test.ts rename to packages/tests/src/plugins/error-tracking/sourcemaps/files.test.ts index 7bb1e1aa9..48d0f808f 100644 --- a/packages/tests/src/plugins/rum/sourcemaps/files.test.ts +++ b/packages/tests/src/plugins/error-tracking/sourcemaps/files.test.ts @@ -2,13 +2,13 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { getSourcemapsFiles } from '@dd/rum-plugin/sourcemaps/files'; -import { getContextMock } from '@dd/tests/_jest/helpers/mocks'; +import { getSourcemapsFiles } from '@dd/error-tracking-plugin/sourcemaps/files'; +import { getContextMock, getMockBuild } from '@dd/tests/_jest/helpers/mocks'; import path from 'path'; import { getSourcemapsConfiguration } from '../testHelpers'; -describe('RUM Plugin Sourcemaps Files', () => { +describe('Error Tracking Plugin Sourcemaps Files', () => { test('Should get sourcemap files.', async () => { const sourcemaps = getSourcemapsFiles( getSourcemapsConfiguration({ @@ -22,9 +22,7 @@ describe('RUM Plugin Sourcemaps Files', () => { version: '1.0.0', }, build: { - warnings: [], - errors: [], - logs: [], + ...getMockBuild(), outputs: [ 'fixtures/common.js', 'fixtures/common.min.js.map', diff --git a/packages/tests/src/plugins/rum/sourcemaps/payload.test.ts b/packages/tests/src/plugins/error-tracking/sourcemaps/payload.test.ts similarity index 96% rename from packages/tests/src/plugins/rum/sourcemaps/payload.test.ts rename to packages/tests/src/plugins/error-tracking/sourcemaps/payload.test.ts index b651d2844..4a84d6934 100644 --- a/packages/tests/src/plugins/rum/sourcemaps/payload.test.ts +++ b/packages/tests/src/plugins/error-tracking/sourcemaps/payload.test.ts @@ -2,7 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { checkFile, getPayload, prefixRepeat } from '@dd/rum-plugin/sourcemaps/payload'; +import { checkFile, getPayload, prefixRepeat } from '@dd/error-tracking-plugin/sourcemaps/payload'; import { vol } from 'memfs'; import path from 'path'; @@ -10,7 +10,7 @@ import { getMetadataMock, getRepositoryDataMock, getSourcemapMock } from '../tes jest.mock('fs', () => require('memfs').fs); -describe('RUM Plugins Sourcemaps Payloads', () => { +describe('Error Tracking Plugins Sourcemaps Payloads', () => { describe('prefixRepeat', () => { test.each([ { prefix: '/testing/path/to', filePath: '/path/to/file.js', expected: 'path/to' }, diff --git a/packages/tests/src/plugins/rum/sourcemaps/sender.test.ts b/packages/tests/src/plugins/error-tracking/sourcemaps/sender.test.ts similarity index 97% rename from packages/tests/src/plugins/rum/sourcemaps/sender.test.ts rename to packages/tests/src/plugins/error-tracking/sourcemaps/sender.test.ts index 20660dfb0..013fb29e4 100644 --- a/packages/tests/src/plugins/rum/sourcemaps/sender.test.ts +++ b/packages/tests/src/plugins/error-tracking/sourcemaps/sender.test.ts @@ -3,7 +3,7 @@ // Copyright 2019-Present Datadog, Inc. import { doRequest } from '@dd/core/helpers'; -import { getData, sendSourcemaps, upload } from '@dd/rum-plugin/sourcemaps/sender'; +import { getData, sendSourcemaps, upload } from '@dd/error-tracking-plugin/sourcemaps/sender'; import { getContextMock, mockLogFn, mockLogger } from '@dd/tests/_jest/helpers/mocks'; import { vol } from 'memfs'; import { type Stream } from 'stream'; @@ -36,7 +36,7 @@ function readFully(stream: Stream): Promise { }); } -describe('RUM Plugin Sourcemaps', () => { +describe('Error Tracking Plugin Sourcemaps', () => { describe('getData', () => { afterEach(() => { vol.reset(); diff --git a/packages/tests/src/plugins/rum/testHelpers.ts b/packages/tests/src/plugins/error-tracking/testHelpers.ts similarity index 83% rename from packages/tests/src/plugins/rum/testHelpers.ts rename to packages/tests/src/plugins/error-tracking/testHelpers.ts index 3242f6cfb..3860e68c3 100644 --- a/packages/tests/src/plugins/rum/testHelpers.ts +++ b/packages/tests/src/plugins/error-tracking/testHelpers.ts @@ -3,29 +3,33 @@ // Copyright 2019-Present Datadog, Inc. import type { RepositoryData } from '@dd/core/types'; -import { TrackedFilesMatcher } from '@dd/internal-git-plugin/trackedFilesMatcher'; -import type { Metadata, MultipartValue, Payload } from '@dd/rum-plugin/sourcemaps/payload'; import type { - RumSourcemapsOptions, - RumSourcemapsOptionsWithDefaults, + Metadata, + MultipartValue, + Payload, +} from '@dd/error-tracking-plugin/sourcemaps/payload'; +import type { + SourcemapsOptions, + SourcemapsOptionsWithDefaults, Sourcemap, -} from '@dd/rum-plugin/types'; +} from '@dd/error-tracking-plugin/types'; +import { TrackedFilesMatcher } from '@dd/internal-git-plugin/trackedFilesMatcher'; import { INTAKE_URL } from '@dd/tests/_jest/helpers/mocks'; export const getMinimalSourcemapsConfiguration = ( - options: Partial = {}, -): RumSourcemapsOptions => { + options: Partial = {}, +): SourcemapsOptions => { return { minifiedPathPrefix: '/prefix', releaseVersion: '1.0.0', - service: 'rum-build-plugin-sourcemaps', + service: 'error-tracking-build-plugin-sourcemaps', ...options, }; }; export const getSourcemapsConfiguration = ( - options: Partial = {}, -): RumSourcemapsOptionsWithDefaults => { + options: Partial = {}, +): SourcemapsOptionsWithDefaults => { return { bailOnError: false, dryRun: false, @@ -33,7 +37,7 @@ export const getSourcemapsConfiguration = ( intakeUrl: INTAKE_URL, minifiedPathPrefix: '/prefix', releaseVersion: '1.0.0', - service: 'rum-build-plugin-sourcemaps', + service: 'error-tracking-build-plugin-sourcemaps', ...options, }; }; @@ -53,7 +57,7 @@ export const getMetadataMock = (options: Partial = {}): Metadata => { return { plugin_version: '1.0.0', project_path: '/path/to/project', - service: 'rum-build-plugin-sourcemaps', + service: 'error-tracking-build-plugin-sourcemaps', type: 'js_sourcemap', version: '1.0.0', ...options, diff --git a/packages/tests/src/plugins/rum/validate.test.ts b/packages/tests/src/plugins/error-tracking/validate.test.ts similarity index 86% rename from packages/tests/src/plugins/rum/validate.test.ts rename to packages/tests/src/plugins/error-tracking/validate.test.ts index c12f12777..2eac6ddcd 100644 --- a/packages/tests/src/plugins/rum/validate.test.ts +++ b/packages/tests/src/plugins/error-tracking/validate.test.ts @@ -2,14 +2,14 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { RumSourcemapsOptions } from '@dd/rum-plugin/types'; -import { validateOptions, validateSourcemapsOptions } from '@dd/rum-plugin/validate'; +import type { SourcemapsOptions } from '@dd/error-tracking-plugin/types'; +import { validateOptions, validateSourcemapsOptions } from '@dd/error-tracking-plugin/validate'; import { mockLogger } from '@dd/tests/_jest/helpers/mocks'; import stripAnsi from 'strip-ansi'; import { getMinimalSourcemapsConfiguration } from './testHelpers'; -describe('RUM Plugins validate', () => { +describe('Error Tracking Plugins validate', () => { describe('validateOptions', () => { test('Should return the validated configuration', () => { const config = validateOptions( @@ -17,7 +17,7 @@ describe('RUM Plugins validate', () => { auth: { apiKey: '123', }, - rum: { + errorTracking: { disabled: false, }, }, @@ -36,9 +36,9 @@ describe('RUM Plugins validate', () => { auth: { apiKey: '123', }, - rum: { + errorTracking: { // Invalid configuration, missing required fields. - sourcemaps: {} as RumSourcemapsOptions, + sourcemaps: {} as SourcemapsOptions, }, }, mockLogger, @@ -49,8 +49,8 @@ describe('RUM Plugins validate', () => { describe('validateSourcemapsOptions', () => { test('Should return errors for each missing required field', () => { const { errors } = validateSourcemapsOptions({ - rum: { - sourcemaps: {} as RumSourcemapsOptions, + errorTracking: { + sourcemaps: {} as SourcemapsOptions, }, }); @@ -64,14 +64,14 @@ describe('RUM Plugins validate', () => { }); test('Should return the validated configuration with defaults', () => { - const configObject: RumSourcemapsOptions = { + const configObject: SourcemapsOptions = { minifiedPathPrefix: '/path/to/minified', releaseVersion: '1.0.0', service: 'service', }; const { config, errors } = validateSourcemapsOptions({ - rum: { + errorTracking: { sourcemaps: getMinimalSourcemapsConfiguration(configObject), }, }); @@ -88,10 +88,9 @@ describe('RUM Plugins validate', () => { test('Should return an error with a bad minifiedPathPrefix', () => { const { errors } = validateSourcemapsOptions({ - rum: { + errorTracking: { sourcemaps: getMinimalSourcemapsConfiguration({ - minifiedPathPrefix: - 'bad-prefix' as RumSourcemapsOptions['minifiedPathPrefix'], + minifiedPathPrefix: 'bad-prefix' as SourcemapsOptions['minifiedPathPrefix'], }), }, }); @@ -104,7 +103,7 @@ describe('RUM Plugins validate', () => { test('Should default to the expected intake url', () => { const { config } = validateSourcemapsOptions({ - rum: { + errorTracking: { sourcemaps: getMinimalSourcemapsConfiguration(), }, }); @@ -114,7 +113,7 @@ describe('RUM Plugins validate', () => { test('Should use the provided configuration as the intake url', () => { const { config } = validateSourcemapsOptions({ - rum: { + errorTracking: { sourcemaps: getMinimalSourcemapsConfiguration({ intakeUrl: 'https://example.com', }), @@ -128,7 +127,7 @@ describe('RUM Plugins validate', () => { const initialEnvValue = process.env.DATADOG_SOURCEMAP_INTAKE_URL; process.env.DATADOG_SOURCEMAP_INTAKE_URL = 'https://example.com'; const { config } = validateSourcemapsOptions({ - rum: { + errorTracking: { sourcemaps: getMinimalSourcemapsConfiguration(), }, }); diff --git a/packages/tests/src/plugins/git/index.test.ts b/packages/tests/src/plugins/git/index.test.ts index a63b6b42f..30ab11a1a 100644 --- a/packages/tests/src/plugins/git/index.test.ts +++ b/packages/tests/src/plugins/git/index.test.ts @@ -2,14 +2,14 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { RepositoryData } from '@dd/core/types'; +import type { Options, RepositoryData } from '@dd/core/types'; +import { uploadSourcemaps } from '@dd/error-tracking-plugin/sourcemaps/index'; import { getRepositoryData } from '@dd/internal-git-plugin/helpers'; import { TrackedFilesMatcher } from '@dd/internal-git-plugin/trackedFilesMatcher'; -import { uploadSourcemaps } from '@dd/rum-plugin/sourcemaps/index'; import { API_PATH, FAKE_URL, defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks'; import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; import type { CleanupFn } from '@dd/tests/_jest/helpers/types'; -import { getSourcemapsConfiguration } from '@dd/tests/plugins/rum/testHelpers'; +import { getSourcemapsConfiguration } from '@dd/tests/plugins/error-tracking/testHelpers'; import nock from 'nock'; jest.mock('@dd/internal-git-plugin/helpers', () => { @@ -20,8 +20,8 @@ jest.mock('@dd/internal-git-plugin/helpers', () => { }; }); -jest.mock('@dd/rum-plugin/sourcemaps/index', () => { - const originalModule = jest.requireActual('@dd/rum-plugin/sourcemaps/index'); +jest.mock('@dd/error-tracking-plugin/sourcemaps/index', () => { + const originalModule = jest.requireActual('@dd/error-tracking-plugin/sourcemaps/index'); return { ...originalModule, uploadSourcemaps: jest.fn(), @@ -55,9 +55,9 @@ describe('Git Plugin', () => { let nbCallsToGetRepositoryData = 0; let cleanup: CleanupFn; beforeAll(async () => { - const pluginConfig = { + const pluginConfig: Options = { ...defaultPluginOptions, - rum: { + errorTracking: { // We need sourcemaps to trigger the git plugin. sourcemaps: getSourcemapsConfiguration(), }, @@ -110,9 +110,9 @@ describe('Git Plugin', () => { }); test('Should not run if we disable it from the configuration', async () => { - const pluginConfig = { + const pluginConfig: Options = { ...defaultPluginOptions, - rum: { + errorTracking: { sourcemaps: getSourcemapsConfiguration(), }, disableGit: true, diff --git a/packages/tests/src/plugins/injection/helpers.test.ts b/packages/tests/src/plugins/injection/helpers.test.ts index 76ce46203..e7aec9b24 100644 --- a/packages/tests/src/plugins/injection/helpers.test.ts +++ b/packages/tests/src/plugins/injection/helpers.test.ts @@ -2,12 +2,13 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { ToInjectItem } from '@dd/core/types'; +import { InjectPosition, type ToInjectItem } from '@dd/core/types'; import { processInjections, processItem, processLocalFile, processDistantFile, + getInjectedValue, } from '@dd/internal-injection-plugin/helpers'; import { mockLogger } from '@dd/tests/_jest/helpers/mocks'; import { vol } from 'memfs'; @@ -39,13 +40,13 @@ const nonExistingDistantFile: ToInjectItem = { describe('Injection Plugin Helpers', () => { let nockScope: nock.Scope; - beforeEach(() => { + beforeEach(async () => { nockScope = nock('https://example.com') .get('/distant-file.js') .reply(200, distantFileContent); // Emulate some fixtures. vol.fromJSON({ - [existingFile.value]: localFileContent, + [await getInjectedValue(existingFile)]: localFileContent, }); }); @@ -55,18 +56,28 @@ describe('Injection Plugin Helpers', () => { describe('processInjections', () => { test('Should process injections without throwing.', async () => { - const items: ToInjectItem[] = [ - code, - existingFile, - nonExistingFile, - existingDistantFile, - nonExistingDistantFile, - ]; + const items: Map = new Map([ + ['code', code], + ['existingFile', existingFile], + ['nonExistingFile', nonExistingFile], + ['existingDistantFile', existingDistantFile], + ['nonExistingDistantFile', nonExistingDistantFile], + ]); - const expectResult = expect(processInjections(items, mockLogger)).resolves; + const prom = processInjections(items, mockLogger); + const expectResult = expect(prom).resolves; await expectResult.not.toThrow(); - await expectResult.toEqual([codeContent, localFileContent, distantFileContent]); + + const results = await prom; + expect(Array.from(results.entries())).toEqual([ + ['code', { position: InjectPosition.BEFORE, value: codeContent }], + ['existingFile', { position: InjectPosition.BEFORE, value: localFileContent }], + [ + 'existingDistantFile', + { position: InjectPosition.BEFORE, value: distantFileContent }, + ], + ]); expect(nockScope.isDone()).toBe(true); }); @@ -139,8 +150,7 @@ describe('Injection Plugin Helpers', () => { expectation: localFileContent, }, ])('Should process local file $description.', async ({ value, expectation }) => { - const item: ToInjectItem = { type: 'file', value }; - const expectResult = expect(processLocalFile(item)).resolves; + const expectResult = expect(processLocalFile(value)).resolves; await expectResult.not.toThrow(); await expectResult.toEqual(expectation); @@ -154,12 +164,9 @@ describe('Injection Plugin Helpers', () => { .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'); + await expect( + processDistantFile('https://example.com/delayed-distant-file.js', 1), + ).rejects.toThrow('Timeout'); }); }); }); diff --git a/packages/tests/src/plugins/injection/index.test.ts b/packages/tests/src/plugins/injection/index.test.ts index d15e006f5..598b737df 100644 --- a/packages/tests/src/plugins/injection/index.test.ts +++ b/packages/tests/src/plugins/injection/index.test.ts @@ -2,7 +2,10 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { Options } from '@dd/core/types'; +import { outputFileSync } from '@dd/core/helpers'; +import type { Assign, BundlerFullName, Options, ToInjectItem } from '@dd/core/types'; +import { InjectPosition } from '@dd/core/types'; +import { AFTER_INJECTION, BEFORE_INJECTION } from '@dd/internal-injection-plugin/constants'; import { debugFilesPlugins, getComplexBuildOverrides, @@ -10,160 +13,397 @@ import { } from '@dd/tests/_jest/helpers/mocks'; import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; import type { CleanupFn } from '@dd/tests/_jest/helpers/types'; +import { header, licenses } from '@dd/tools/commands/oss/templates'; import { execute } from '@dd/tools/helpers'; import { readFileSync, writeFileSync } from 'fs'; import { glob } from 'glob'; import nock from 'nock'; import path from 'path'; +const FAKE_FILE_PREFIX = 'fake-file-to-inject-'; +// Files that we will execute part of the test. +const FILES = ['main.js', 'app1.js', 'app2.js'] as const; +const DOMAIN = 'https://example.com'; + +type ExpectedValues = [string | RegExp, number | [number, number]]; +type BaseExpectation = { + name: string; + logs?: Record; + content: ExpectedValues; +}; +type EasyExpectation = Assign; +type HardExpectation = Assign< + BaseExpectation, + { logs?: { 'app1.js': ExpectedValues; 'app2.js': ExpectedValues } } +>; +type BuildState = { + outdir?: string; + content?: string; + // Separate logs based on executed file. + logs?: Partial>; +}; +type File = (typeof FILES)[number]; +enum ContentType { + CODE = 'code', + LOCAL = 'local file', + DISTANT = 'distant file', +} +enum Position { + BEFORE = 'before', + MIDDLE = 'middle', + AFTER = 'after', +} + +const getLog = (type: ContentType, position: Position) => { + const positionString = `in ${position}`; + const contentString = `Hello injection from ${type}`; + return `${contentString} ${positionString}.`; +}; + +const getContent = (type: ContentType, position: Position) => { + return `console.log("${getLog(type, position)}");`; +}; + +const getFileUrl = (position: Position) => { + return `/${FAKE_FILE_PREFIX}${position}.js`; +}; + +const escapeStringForRegExp = (str: string) => + str + // Escape sensible chars in RegExps. + .replace(/([().[\]])/g, '\\$1') + // Replace quotes to allow for both single and double quotes. + .replace(/["']/g, `(?:"|')`); + describe('Injection Plugin', () => { - const distantFileLog = 'Hello injection from distant file.'; - const distantFileContent = `console.log("${distantFileLog}");`; - const localFileLog = 'Hello injection from local file.'; - const localFileContent = `console.log("${localFileLog}");`; - const codeLog = 'Hello injection from code.'; - const codeContent = `console.log("${codeLog}");`; - let outdirs: Record = {}; - - const expectations = [ - { type: 'some string', content: codeContent, log: codeLog }, - { type: 'a local file', content: localFileContent, log: localFileLog }, - { type: 'a distant file', content: distantFileContent, log: distantFileLog }, + // This is the string we log in our entry files + // easy_project/src/main.js and hard_project/src/main1.js. + const normalLog = 'Hello World!'; + + // Prepare a special injection where we use imports in MIDDLE. + const specialLog: string = 'Hello injection with colors from code in middle.'; + + // List of expectations for each type of tests. + const noMarkers: BaseExpectation[] = [ + { + name: 'No BEFORE_INJECTION markers in easy build', + content: [BEFORE_INJECTION, 0], + }, + { + name: 'No AFTER_INJECTION markers in easy build', + content: [AFTER_INJECTION, 0], + }, + ]; + const easyWithoutInjections: EasyExpectation[] = [ + { + name: 'Normal log in easy build', + logs: { + 'main.js': [normalLog, 1], + }, + content: [`console.log("${normalLog}");`, 1], + }, + ...noMarkers, + ]; + const hardWithoutInjections: HardExpectation[] = [ + { + name: 'Normal log in hard build', + logs: { + 'app1.js': [normalLog, 1], + 'app2.js': [normalLog, 0], + }, + // Using only normalLog here, as imports and function names (console.log, chalk) + // are probably re-written and mangled by the bundlers. + content: [normalLog, 1], + }, + ...noMarkers, + ]; + const easyWithInjections: EasyExpectation[] = [ + // We have the same expectation on the normalLog which is not due to injections. + easyWithoutInjections[0], + { + name: '[middle] code injection with imports in easy build', + logs: { + 'main.js': [specialLog, 1], + }, + // Using only 'specialLog' here, as imports and function names (console.log, chalk) + // are probably re-written and mangled by the bundlers. + content: [specialLog, 1], + }, + ]; + const hardWithInjections: HardExpectation[] = [ + // We have the same expectation on the normalLog which is not due to injections. + hardWithoutInjections[0], + { + name: '[middle] code injection with imports in hard build', + logs: { + 'app1.js': [specialLog, 1], + 'app2.js': [specialLog, 1], + }, + // Using only 'specialLog' here, as imports and function names (console.log, chalk) + // are probably re-written and mangled by the bundlers. + // Also, we don't know exactly how each bundler will concatenate the files. + // Since we have two entries here, we can expect the content + // to be repeated at least once and at most twice. + content: [specialLog, [1, 2]], + }, ]; - const customPlugins: Options['customPlugins'] = (opts, context) => { - context.inject({ - type: 'file', - value: 'https://example.com/distant-file.js', - }); - context.inject({ - type: 'file', - value: './src/_jest/fixtures/file-to-inject.js', - }); - context.inject({ + const toInjectItems: ToInjectItem[] = [ + // Add a special case of import to confirm this is working as expected in the middle of the code. + { type: 'code', - value: codeContent, - }); + value: `const chalk = require('chalk');\nconsole.log(chalk.bold.red('${specialLog}'));\n`, + position: InjectPosition.MIDDLE, + }, + ]; - return [ - { - name: 'get-outdirs', - writeBundle() { - // Store the seeded outdir to inspect the produced files. - outdirs[context.bundler.fullName] = context.bundler.outDir; - - // Add a package.json file to the esm builds. - if (['esbuild'].includes(context.bundler.fullName)) { - writeFileSync( - path.resolve(context.bundler.outDir, 'package.json'), - '{ "type": "module" }', - ); - } - }, - }, - ...debugFilesPlugins(context), - ]; - }; + // Build expectations and mock injections. + for (const type of Object.values(ContentType)) { + const injectType = type === ContentType.CODE ? 'code' : 'file'; + for (const position of Object.values(Position)) { + const positionType = + position === Position.BEFORE + ? InjectPosition.BEFORE + : position === Position.MIDDLE + ? InjectPosition.MIDDLE + : InjectPosition.AFTER; - describe('Basic build', () => { - let nockScope: nock.Scope; - let cleanup: CleanupFn; + const injectionLog = getLog(type, position); + const injectionContent = getContent(type, position); + const injection: ToInjectItem = { + type: injectType, + value: injectionContent, + position: positionType, + }; - beforeAll(async () => { - nockScope = nock('https://example.com') - .get('/distant-file.js') - .times(BUNDLERS.length) - .reply(200, distantFileContent); + // Fill in the expectations for each type of test. + hardWithInjections.push({ + name: `[${position}] ${type} injection in hard build`, + logs: { + 'app1.js': [injectionLog, 1], + 'app2.js': [injectionLog, 1], + }, + content: [injectionContent, [1, 2]], + }); - cleanup = await runBundlers( - { - customPlugins, + easyWithInjections.push({ + name: `[${position}] ${type} injection in easy build`, + logs: { + 'main.js': [injectionLog, 1], }, - getNodeSafeBuildOverrides(), - ); - }); + content: [injectionContent, 1], + }); - afterAll(async () => { - outdirs = {}; - nock.cleanAll(); - await cleanup(); - }); + if (type === ContentType.DISTANT) { + injection.value = `${DOMAIN}${getFileUrl(position)}`; + } else if (type === ContentType.LOCAL) { + injection.value = `.${getFileUrl(position)}`; + } - test('Should have requested the distant file for each bundler.', () => { - expect(nockScope.isDone()).toBe(true); - }); + toInjectItems.push(injection); + } + } - describe.each(BUNDLERS)('$name | $version', ({ name }) => { - let programOutput: string; - beforeAll(async () => { - // Test the actual bundled file too. - const result = await execute('node', [path.resolve(outdirs[name], 'main.js')]); - programOutput = result.stdout; - }); + // Create a custom plugin to inject the files/codes into the build, store some states and tweak some output. + const getPlugins = + ( + injections: ToInjectItem[] = [], + buildStates: Partial>, + ): Options['customPlugins'] => + (opts, context) => { + for (const injection of injections) { + context.inject(injection); + } - test.each(expectations)('Should inject $type once.', async ({ content, log }) => { - const files = glob.sync(path.resolve(outdirs[name], '*.{js,mjs}')); - const fullContent = files.map((file) => readFileSync(file, 'utf8')).join('\n'); + return [ + { + name: 'get-outdirs', + writeBundle() { + // Store the seeded outdir to inspect the produced files. + const buildState: BuildState = buildStates[context.bundler.fullName] || {}; + buildState.outdir = context.bundler.outDir; + buildStates[context.bundler.fullName] = buildState; - // We have a single entry, so the content should be repeated only once. - expect(fullContent).toRepeatStringTimes(content, 1); - // Verify the program output from the bundled project. - expect(programOutput).toRepeatStringTimes(log, 1); - }); - }); + // Add a package.json file to the esm builds. + if (['esbuild'].includes(context.bundler.fullName)) { + writeFileSync( + path.resolve(context.bundler.outDir, 'package.json'), + '{ "type": "module" }', + ); + } + }, + }, + ...debugFilesPlugins(context), + ]; + }; + + // Define our tests. + const tests: { + name: string; + overrides: Parameters[1]; + positions: Position[]; + injections: [ToInjectItem[], number]; + expectations: (EasyExpectation | HardExpectation)[]; + }[] = [ + { + name: 'Easy build without injections', + overrides: (workingDir: string) => getNodeSafeBuildOverrides(workingDir), + positions: [], + injections: [[], 0], + expectations: easyWithoutInjections, + }, + { + name: 'Hard build without injections', + overrides: (workingDir: string) => + getNodeSafeBuildOverrides(workingDir, getComplexBuildOverrides()), + positions: [], + injections: [[], 0], + expectations: hardWithoutInjections, + }, + { + name: 'Easy build with injections', + overrides: (workingDir: string) => getNodeSafeBuildOverrides(workingDir), + positions: Object.values(Position), + injections: [toInjectItems, 10], + expectations: easyWithInjections, + }, + { + name: 'Hard build with injections', + overrides: (workingDir: string) => + getNodeSafeBuildOverrides(workingDir, getComplexBuildOverrides()), + positions: Object.values(Position), + injections: [toInjectItems, 10], + expectations: hardWithInjections, + }, + ]; + + beforeAll(() => { + // Prepare mock files. + for (const position of Object.values(Position)) { + // NOTE: These files should already exist and have the correct content. + // It is just to confirm we keep the same content. + // We can't use memfs because bundlers, which read the files, runs within "jest.isolateModulesAsync" + // and don't have access to the same memfs' file system. + const fileContent = `${header(licenses.mit.name)}\n${getContent(ContentType.LOCAL, position)}`; + outputFileSync(`./src/_jest/fixtures${getFileUrl(position)}`, fileContent); + } }); - describe('Complex build', () => { + describe.each(tests)('$name', ({ overrides, positions, injections, expectations }) => { let nockScope: nock.Scope; let cleanup: CleanupFn; + let buildStates: Partial> = {}; beforeAll(async () => { - nockScope = nock('https://example.com') - .get('/distant-file.js') - .times(BUNDLERS.length) - .reply(200, distantFileContent); + nockScope = nock(DOMAIN); + + // Prepare mock routes. + for (const position of positions) { + // Add mock route to file. + nockScope + .get(getFileUrl(position)) + .times(BUNDLERS.length) + .reply(200, getContent(ContentType.DISTANT, position)); + } cleanup = await runBundlers( - { - customPlugins, - }, - getNodeSafeBuildOverrides(getComplexBuildOverrides()), + { customPlugins: getPlugins(injections[0], buildStates) }, + overrides, ); - }); + + // Execute the builds and store some state. + const proms: Promise[] = []; + for (const bundler of BUNDLERS) { + const buildState = buildStates[bundler.name]; + const outdir = buildState?.outdir; + + // This will be caught in the tests for each bundler. + if (!outdir || !buildState) { + continue; + } + + const builtFiles = glob.sync(path.resolve(outdir, '*.{js,mjs}')); + + // Only execute files we identified as entries. + const filesToRun: File[] = builtFiles + .map((file) => path.basename(file) as File) + .filter((basename) => FILES.includes(basename)); + + // Run the files through node to confirm they don't crash and assert their logs. + proms.push( + ...filesToRun.map(async (file) => { + const result = await execute('node', [path.resolve(outdir, file)]); + buildState.logs = buildState.logs || {}; + buildState.logs[file] = result.stdout; + }), + ); + + // Store the content of the built files to assert the injections. + buildState.content = builtFiles + .map((file) => readFileSync(file, 'utf8')) + .join('\n'); + } + + await Promise.all(proms); + // Webpack can be slow to build... + }, 100000); afterAll(async () => { - outdirs = {}; + buildStates = {}; nock.cleanAll(); await cleanup(); }); - test('Should have requested the distant file for each bundler.', () => { + test('Should have the correct test environment.', () => { + expect(injections[0]).toHaveLength(injections[1]); + + // We should have called everything we've mocked for. expect(nockScope.isDone()).toBe(true); }); describe.each(BUNDLERS)('$name | $version', ({ name }) => { - let programOutput1: string; - let programOutput2: string; - beforeAll(async () => { - // Test the actual bundled file too. - const result1 = await execute('node', [path.resolve(outdirs[name], 'app1.js')]); - programOutput1 = result1.stdout; - const result2 = await execute('node', [path.resolve(outdirs[name], 'app2.js')]); - programOutput2 = result2.stdout; - }); + let buildState: BuildState; - test.each(expectations)('Should inject $type.', ({ content, log }) => { - const files = glob.sync(path.resolve(outdirs[name], '*.{js,mjs}')); - 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]); - // Verify the program output from the bundled project. - expect(programOutput1).toRepeatStringTimes(log, 1); - expect(programOutput2).toRepeatStringTimes(log, 1); + test('Should have a buildState.', () => { + buildState = buildStates[name]!; + expect(buildState).toBeDefined(); + expect(buildState.outdir).toEqual(expect.any(String)); + expect(buildState.logs).toEqual(expect.any(Object)); + expect(buildState.content).toEqual(expect.any(String)); }); + + describe.each(expectations)( + '$name', + ({ content: [expectedContent, contentOccurencies], logs }) => { + test('Should have the expected content in the bundles.', () => { + const content = buildState.content; + const expectation = + expectedContent instanceof RegExp + ? expectedContent + : new RegExp(escapeStringForRegExp(expectedContent)); + + expect(content).toRepeatStringTimes(expectation, contentOccurencies); + }); + + if (!logs) { + return; + } + + test('Should have output the expected logs from execution.', () => { + const logExpectations = Object.entries(logs); + for (const [file, [expectedLog, logOccurencies]] of logExpectations) { + const stateLogs = buildState.logs?.[file as File]; + const expectation = + expectedLog instanceof RegExp + ? expectedLog + : new RegExp(escapeStringForRegExp(expectedLog)); + + expect(stateLogs).toBeDefined(); + expect(stateLogs).toRepeatStringTimes(expectation, logOccurencies); + } + }); + }, + ); }); }); }); diff --git a/packages/tests/src/plugins/rum/index.test.ts b/packages/tests/src/plugins/rum/index.test.ts index e36170705..88ee6a328 100644 --- a/packages/tests/src/plugins/rum/index.test.ts +++ b/packages/tests/src/plugins/rum/index.test.ts @@ -2,45 +2,77 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { uploadSourcemaps } from '@dd/rum-plugin/sourcemaps/index'; -import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; -import type { CleanupFn } from '@dd/tests/_jest/helpers/types'; +import type { RumOptions } from '@dd/rum-plugin/types'; +import { getPlugins } from '@dd/rum-plugin'; +import { defaultPluginOptions, getContextMock, mockLogger } from '@dd/tests/_jest/helpers/mocks'; +import path from 'path'; -import { getSourcemapsConfiguration } from './testHelpers'; +// Mock getInjectionvalue @dd/rum-plugin/sdk to return a given string. +const injectionValue = 'DD_RUM INITIALIZATION'; +jest.mock('@dd/rum-plugin/sdk', () => ({ + getInjectionValue: jest.fn(() => injectionValue), +})); -jest.mock('@dd/rum-plugin/sourcemaps/index', () => { - return { - uploadSourcemaps: jest.fn(), +describe('RUM Plugin', () => { + const injections = { + 'browser-sdk': path.resolve('../plugins/rum/src/rum-browser-sdk.js'), + 'sdk-init': injectionValue, + 'rum-react-plugin': path.resolve('../plugins/rum/src/rum-react-plugin.js'), }; -}); -const uploadSourcemapsMock = jest.mocked(uploadSourcemaps); + const expectations: { + type: string; + config: RumOptions; + should: { inject?: (keyof typeof injections)[]; throw?: boolean }; + }[] = [ + { + type: 'no sdk and no react', + config: {}, + should: { inject: [] }, + }, + { + type: 'sdk and no react', + config: { sdk: { applicationId: 'app-id' } }, + should: { inject: ['browser-sdk', 'sdk-init'] }, + }, + { + type: 'sdk and react', + config: { sdk: { applicationId: 'app-id' }, react: { router: true } }, + should: { inject: ['browser-sdk', 'sdk-init', 'rum-react-plugin'] }, + }, + { + type: 'no sdk and react', + config: { react: { router: true } }, + should: { throw: true }, + }, + ]; -describe('RUM Plugin', () => { - const cleanups: CleanupFn[] = []; - - afterAll(async () => { - await Promise.all(cleanups.map((cleanup) => cleanup())); - }); - - test('Should process the sourcemaps if enabled.', async () => { - cleanups.push( - await runBundlers({ - rum: { - sourcemaps: getSourcemapsConfiguration(), - }, - }), - ); - expect(uploadSourcemapsMock).toHaveBeenCalledTimes(BUNDLERS.length); - }); - - test('Should not process the sourcemaps with no options.', async () => { - cleanups.push( - await runBundlers({ - rum: {}, - }), - ); - - expect(uploadSourcemapsMock).not.toHaveBeenCalled(); - }); + test.each(expectations)( + 'Should inject the necessary files with "$type".', + async ({ config, should }) => { + const mockContext = getContextMock(); + const pluginConfig = { ...defaultPluginOptions, rum: config }; + + const expectResult = expect(() => { + getPlugins(pluginConfig, mockContext, mockLogger); + }); + + if (should.throw) { + expectResult.toThrow(); + } else { + expectResult.not.toThrow(); + } + + if (should.inject) { + expect(mockContext.inject).toHaveBeenCalledTimes(should.inject.length); + for (const inject of should.inject) { + expect(mockContext.inject).toHaveBeenCalledWith( + expect.objectContaining({ + value: injections[inject], + }), + ); + } + } + }, + ); }); diff --git a/packages/tests/src/plugins/rum/sdk.test.ts b/packages/tests/src/plugins/rum/sdk.test.ts new file mode 100644 index 000000000..23c69c782 --- /dev/null +++ b/packages/tests/src/plugins/rum/sdk.test.ts @@ -0,0 +1,75 @@ +// 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 } from '@dd/core/helpers'; +import { getInjectionValue } from '@dd/rum-plugin/sdk'; +import type { RumOptionsWithSdk } from '@dd/rum-plugin/types'; +import { validateOptions } from '@dd/rum-plugin/validate'; +import { defaultPluginOptions, getContextMock, mockLogger } from '@dd/tests/_jest/helpers/mocks'; + +// Mock doRequest to intercept the call and mock the result. +jest.mock('@dd/core/helpers', () => ({ + doRequest: jest.fn(), +})); +const doRequestMock = jest.mocked(doRequest); + +describe('RUM Plugin - SDK', () => { + describe('getInjectionValue', () => { + const options = validateOptions( + { ...defaultPluginOptions, rum: { sdk: { applicationId: 'app_id' } } }, + mockLogger, + ) as RumOptionsWithSdk; + const context = getContextMock(); + + test('Should throw if no auth.', () => { + expect(() => { + getInjectionValue(options, { ...context, auth: {} }); + }).toThrow( + 'Missing "auth.apiKey" and/or "auth.appKey" to fetch "rum.sdk.clientToken".', + ); + }); + + test('Should return the content right away with a given clientToken.', () => { + const value = getInjectionValue( + { ...options, sdk: { ...options.sdk, clientToken: 'client_token' } }, + context, + ); + expect(value).toEqual(expect.stringContaining('DD_RUM.init({')); + }); + + describe('Fetch the clientToken from the API.', () => { + let injectedValueFn: () => Promise; + beforeEach(() => { + injectedValueFn = getInjectionValue(options, context) as () => Promise; + // It should be a function. + expect(injectedValueFn).toEqual(expect.any(Function)); + }); + + test('Should return the clientToken and use it in the initialization.', async () => { + doRequestMock.mockResolvedValue({ + data: { + attributes: { + client_token: 'client_token', + }, + }, + }); + + const value = await injectedValueFn(); + expect(value).toEqual(expect.stringContaining('"clientToken":"client_token",')); + }); + + test('Should throw in case of a network error.', () => { + doRequestMock.mockRejectedValue(new Error('Fake Error')); + expect(injectedValueFn).rejects.toThrow('Could not fetch the clientToken'); + }); + + test('Should throw in case of a missing clientToken in the response.', () => { + doRequestMock.mockResolvedValue({ + data: {}, + }); + expect(injectedValueFn).rejects.toThrow('Missing clientToken in the API response.'); + }); + }); + }); +}); diff --git a/packages/tests/src/plugins/telemetry/index.test.ts b/packages/tests/src/plugins/telemetry/index.test.ts index 51bd84b27..ee80defa6 100644 --- a/packages/tests/src/plugins/telemetry/index.test.ts +++ b/packages/tests/src/plugins/telemetry/index.test.ts @@ -114,41 +114,39 @@ describe('Telemetry Universal Plugin', () => { // We don't want to crash if there are no bundlers to test here. // Which can happen when using --bundlers. - if (expectations.length > 0) { - let cleanup: CleanupFn; - - beforeAll(async () => { - const pluginConfig: Options = { - telemetry: { - enableTracing: true, - endPoint: FAKE_URL, - filters: [], - }, - logLevel: 'warn', - customPlugins: (options: Options, context: GlobalContext) => - debugFilesPlugins(context), - }; - // This one is called at initialization, with the initial context. - addMetricsMocked.mockImplementation(getAddMetricsImplem(metrics)); - cleanup = await runBundlers( - pluginConfig, - getComplexBuildOverrides(), - activeBundlers, - ); - }); + if (!expectations.length) { + return; + } - afterAll(async () => { - await cleanup(); - }); + let cleanup: CleanupFn; - test.each(expectations)( - '$name - $version | Should get the related metrics', - ({ name, expectedMetrics }) => { - const metricNames = metrics[name].map((metric) => metric.metric).sort(); - expect(metricNames).toEqual(expect.arrayContaining(expectedMetrics)); + beforeAll(async () => { + const pluginConfig: Options = { + telemetry: { + enableTracing: true, + endPoint: FAKE_URL, + filters: [], }, - ); - } + logLevel: 'warn', + customPlugins: (options: Options, context: GlobalContext) => + debugFilesPlugins(context), + }; + // This one is called at initialization, with the initial context. + addMetricsMocked.mockImplementation(getAddMetricsImplem(metrics)); + cleanup = await runBundlers(pluginConfig, getComplexBuildOverrides(), activeBundlers); + }); + + afterAll(async () => { + await cleanup(); + }); + + test.each(expectations)( + '$name - $version | Should get the related metrics', + ({ name, expectedMetrics }) => { + const metricNames = metrics[name].map((metric) => metric.metric).sort(); + expect(metricNames).toEqual(expect.arrayContaining(expectedMetrics)); + }, + ); }); describe('Without enableTracing', () => { @@ -327,22 +325,10 @@ describe('Telemetry Universal Plugin', () => { // [name, entryNames, size, dependencies, dependents]; const modulesExpectations: [string, string[], number, number, number][] = [ - [ - 'src/_jest/fixtures/project/workspaces/app/workspaceFile0.js', - ['app1', 'app2'], - 30042, - 0, - 2, - ], - [ - 'src/_jest/fixtures/project/workspaces/app/workspaceFile1.js', - ['app1', 'app2'], - 4600, - 1, - 2, - ], - ['src/_jest/fixtures/project/src/srcFile1.js', ['app2'], 2237, 2, 1], - ['src/_jest/fixtures/project/src/srcFile0.js', ['app1', 'app2'], 13248, 1, 3], + ['hard_project/workspaces/app/workspaceFile0.js', ['app1', 'app2'], 30042, 0, 2], + ['hard_project/workspaces/app/workspaceFile1.js', ['app1', 'app2'], 4600, 1, 2], + ['hard_project/src/srcFile1.js', ['app2'], 2237, 2, 1], + ['hard_project/src/srcFile0.js', ['app1', 'app2'], 13248, 1, 3], ['escape-string-regexp/index.js', ['app1'], 226, 0, 1], ['color-name/index.js', ['app1'], 4617, 0, 1], ['color-convert/conversions.js', ['app1'], 16850, 1, 2], @@ -353,8 +339,8 @@ describe('Telemetry Universal Plugin', () => { ['chalk/templates.js', ['app1'], 3133, 0, 1], // Somehow rollup and vite are not reporting the same size. ['chalk/index.js', ['app1'], expect.toBeWithinRange(6437, 6439), 4, 1], - ['src/_jest/fixtures/project/main1.js', ['app1'], 462, 3, 0], - ['src/_jest/fixtures/project/main2.js', ['app2'], 337, 2, 0], + ['hard_project/main1.js', ['app1'], 462, 3, 0], + ['hard_project/main2.js', ['app2'], 337, 2, 0], ]; describe.each(modulesExpectations)( diff --git a/packages/tests/src/tools/src/rollupConfig.test.ts b/packages/tests/src/tools/src/rollupConfig.test.ts index f7fffa49a..ea5f9774c 100644 --- a/packages/tests/src/tools/src/rollupConfig.test.ts +++ b/packages/tests/src/tools/src/rollupConfig.test.ts @@ -6,7 +6,8 @@ import { datadogEsbuildPlugin } from '@datadog/esbuild-plugin'; import { datadogRollupPlugin } from '@datadog/rollup-plugin'; import { datadogRspackPlugin } from '@datadog/rspack-plugin'; import { datadogVitePlugin } from '@datadog/vite-plugin'; -import { formatDuration, rm } from '@dd/core/helpers'; +import { SUPPORTED_BUNDLERS } from '@dd/core/constants'; +import { formatDuration, getUniqueId, rm } from '@dd/core/helpers'; import type { BundlerFullName, Options } from '@dd/core/types'; import { getEsbuildOptions, @@ -14,11 +15,11 @@ import { getWebpack4Options, getWebpack5Options, } from '@dd/tests/_jest/helpers/configBundlers'; -import { BUNDLER_VERSIONS, NO_CLEANUP } from '@dd/tests/_jest/helpers/constants'; +import { BUNDLER_VERSIONS } from '@dd/tests/_jest/helpers/constants'; +import { getOutDir, prepareWorkingDir } from '@dd/tests/_jest/helpers/env'; import { API_PATH, FAKE_URL, - defaultDestination, defaultEntries, getComplexBuildOverrides, getFullPluginConfig, @@ -32,11 +33,12 @@ import { runWebpack5, } from '@dd/tests/_jest/helpers/runBundlers'; import type { CleanupFn } from '@dd/tests/_jest/helpers/types'; -import { getWebpack4Entries, getWebpackPlugin } from '@dd/tests/_jest/helpers/xpackConfigs'; +import { getWebpackPlugin } from '@dd/tests/_jest/helpers/xpackConfigs'; import { ROOT } from '@dd/tools/constants'; import { bgYellow, execute, green } from '@dd/tools/helpers'; import type { BuildOptions } from 'esbuild'; import fs from 'fs'; +import { glob } from 'glob'; import nock from 'nock'; import path from 'path'; @@ -72,12 +74,18 @@ const datadogRspackPluginMock = jest.mocked(datadogRspackPlugin); const datadogVitePluginMock = jest.mocked(datadogVitePlugin); const getWebpackPluginMock = jest.mocked(getWebpackPlugin); +const getPackagePath = (bundlerName: string) => { + // Cover for names that have a version in it, eg: webpack5, webpack4. + const cleanBundlerName = SUPPORTED_BUNDLERS.find((name) => bundlerName.startsWith(name)); + if (!cleanBundlerName) { + throw new Error(`Bundler not supported: "${bundlerName}"`); + } + return path.resolve(ROOT, `packages/published/${cleanBundlerName}-plugin/dist/src`); +}; + // Ensure our packages have been built not too long ago. const getPackageDestination = (bundlerName: string) => { - const packageDestination = path.resolve( - ROOT, - `packages/published/${bundlerName}-plugin/dist/src`, - ); + const packageDestination = getPackagePath(bundlerName); // If we don't need this bundler, no need to check for its bundle. if (BUNDLERS.find((bundler) => bundler.name.startsWith(bundlerName)) === undefined) { @@ -115,10 +123,25 @@ const getPackageDestination = (bundlerName: string) => { return packageDestination; }; +const getBuiltFiles = () => { + const pkgs = glob.sync('packages/plugins/**/package.json', { cwd: ROOT }); + const builtFiles = []; + + for (const pkg of pkgs) { + const content = require(path.resolve(ROOT, pkg)); + if (!content.toBuild) { + continue; + } + + builtFiles.push(...Object.keys(content.toBuild).map((f) => `${f}.js`)); + } + + return builtFiles; +}; + describe('Bundling', () => { let bundlerVersions: Partial> = {}; let processErrors: string[] = []; - const seededFolders: string[] = []; const pluginConfig = getFullPluginConfig({ logLevel: 'error', customPlugins: (opts, context) => [ @@ -143,12 +166,12 @@ describe('Bundling', () => { // Duplicate the webpack plugin to have one with webpack 4 and one with webpack 5. const webpack5Plugin = getPackageDestination('webpack'); const webpack4Plugin = path.resolve(webpack5Plugin, 'index4.js'); - // Create a new file that will use webpack4. + // Create a new file that will use webpack4 instead of webpack. fs.writeFileSync( webpack4Plugin, fs .readFileSync(path.resolve(webpack5Plugin, 'index.js'), { encoding: 'utf-8' }) - .replace("require('webpack')", "require('webpack4')"), + .replace(/require\(('|")webpack("|')\)/g, "require('webpack4')"), ); // Make the mocks target the built packages. @@ -182,12 +205,17 @@ describe('Bundling', () => { .reply(200, {}); // Intercept Node errors. (Especially DeprecationWarnings in the case of Webpack5). - const actualConsoleError = console.error; + const actualConsoleError = jest.requireActual('console').error; // Filter out the errors we expect. const ignoredErrors = [ + // Used for Jest runtime in "yarn test". 'ExperimentalWarning: VM Modules', + // Used in our sourcemaps sender, to build a stream of our zipped sourcemaps. 'ExperimentalWarning: buffer.File', + // Used in Unplugin's xpack loaders. + 'fs.rmdir(path, { recursive: true })', ]; + // NOTE: this will trigger only once per session, per error. jest.spyOn(console, 'error').mockImplementation((err) => { if (!ignoredErrors.some((e) => err.includes(e))) { @@ -205,97 +233,94 @@ describe('Bundling', () => { afterAll(async () => { nock.cleanAll(); - if (NO_CLEANUP) { - return; - } - console.log('[rollupConfig | Bundling] Cleaning up seeded folders.\n', seededFolders); - await Promise.all(seededFolders.map((folder) => rm(folder))); }); const nameSize = Math.max(...BUNDLERS.map((bundler) => bundler.name.length)) + 1; - const TIMESTAMP = Date.now(); - - describe.each(BUNDLERS)('Bundler: $name', (bundler) => { - test('Should not throw on a easy project.', async () => { - const projectName = 'easy'; - const timeId = `[ ${green(bundler.name.padEnd(nameSize))}] ${green(projectName)} run`; - console.time(timeId); - - const SEED = `${TIMESTAMP}-${projectName}-${jest.getSeed()}`; - const rootDir = path.resolve(defaultDestination, SEED); - const outdir = path.resolve(rootDir, bundler.name); - seededFolders.push(rootDir); - const bundlerConfig = bundler.config( - SEED, - pluginConfig, - getNodeSafeBuildOverrides()[bundler.name], - ); - - if (!bundlerConfig) { - throw new Error(`Missing bundlerConfig for ${bundler.name}.`); - } - - const bundlerConfigOverrides = - bundler.name === 'vite' ? bundlerConfig.build.rollupOptions : bundlerConfig; - const { errors } = await bundler.run(SEED, pluginConfig, bundlerConfigOverrides); - expect(errors).toHaveLength(0); - - // Test the actual bundled file too. - await expect(execute('node', [path.resolve(outdir, 'main.js')])).resolves.not.toThrow(); - - // It should use the correct version of the bundler. - // This is to ensure our test is running in the right conditions. - expect(bundlerVersions[bundler.name]).toBe(BUNDLER_VERSIONS[bundler.name]); - // It should not have printed any error. - expect(processErrors).toHaveLength(0); - - console.timeEnd(timeId); - - // Adding some timeout because webpack is SLOW. - }, 10000); - - test('Should not throw on a hard project.', async () => { - const projectName = 'hard'; - const timeId = `[ ${green(bundler.name.padEnd(nameSize))}] ${green(projectName)} run`; - console.time(timeId); + describe.each( + // Only do bundlers that are requested to be tested. + SUPPORTED_BUNDLERS.filter((bundlerName: string) => + BUNDLERS.find((bundler) => bundler.name.startsWith(bundlerName)), + ), + )('Bundler: %s', (bundlerName) => { + test(`Should add the correct files to @datadog/${bundlerName}-plugin.`, () => { + const builtFiles = getBuiltFiles(); + const expectedFiles = [ + 'index.d.ts', + 'index.js', + 'index.js.map', + 'index.mjs', + 'index.mjs.map', + ...builtFiles, + ].sort(); + const existingFiles = fs.readdirSync(getPackagePath(bundlerName)).sort(); + const exceptions = [ + // We are adding this file ourselves in the test to test both webpack4 and webpack5. + 'index4.js', + ]; + expect(existingFiles.filter((f) => !exceptions.includes(f))).toEqual(expectedFiles); + }); + }); - const SEED = `${TIMESTAMP}-${projectName}-${jest.getSeed()}`; - const rootDir = path.resolve(defaultDestination, SEED); - const outdir = path.resolve(rootDir, bundler.name); - seededFolders.push(rootDir); - const bundlerConfig = bundler.config( - SEED, - pluginConfig, - getNodeSafeBuildOverrides(getComplexBuildOverrides())[bundler.name], - ); + describe.each(BUNDLERS)('Bundler: $name', (bundler) => { + test.each<{ projectName: string; filesToRun: string[] }>([ + { projectName: 'easy', filesToRun: ['main.js'] }, + { projectName: 'hard', filesToRun: ['app1.js', 'app2.js'] }, + ])( + 'Should not throw on $projectName project.', + async ({ projectName, filesToRun }) => { + const timeId = `[ ${green(bundler.name.padEnd(nameSize))}] ${green(projectName)} run`; + console.time(timeId); + + const SEED = `${jest.getSeed()}.${projectName}.${getUniqueId()}`; + const rootDir = await prepareWorkingDir(SEED); + const overrides = getNodeSafeBuildOverrides( + rootDir, + projectName === 'hard' ? getComplexBuildOverrides() : {}, + ); + const outdir = getOutDir(rootDir, bundler.name); + const bundlerConfig = bundler.config( + rootDir, + pluginConfig, + overrides[bundler.name], + ); + + if (!bundlerConfig) { + throw new Error(`Missing bundlerConfig for ${bundler.name}.`); + } - if (!bundlerConfig) { - throw new Error(`Missing bundlerConfig for ${bundler.name}.`); - } + // Our vite run function has a slightly different signature due to how it sets up its bundling. + const bundlerConfigOverrides = + bundler.name === 'vite' ? bundlerConfig.build.rollupOptions : bundlerConfig; - // Vite only overrides its options.build.rollupOptions. - const bundlerConfigOverrides = - bundler.name === 'vite' ? bundlerConfig.build.rollupOptions : bundlerConfig; + const { errors } = await bundler.run(rootDir, pluginConfig, bundlerConfigOverrides); + expect(errors).toHaveLength(0); - const { errors } = await bundler.run(SEED, pluginConfig, bundlerConfigOverrides); - expect(errors).toHaveLength(0); + // Test the actual bundled files too. + await Promise.all( + filesToRun + .map((f) => path.resolve(outdir, f)) + .map((file) => expect(execute('node', [file])).resolves.not.toThrow()), + ); - // Test the actual bundled file too. - await expect(execute('node', [path.resolve(outdir, 'app1.js')])).resolves.not.toThrow(); - await expect(execute('node', [path.resolve(outdir, 'app2.js')])).resolves.not.toThrow(); + // It should use the correct version of the bundler. + // This is to ensure our test is running in the right conditions. + expect(bundlerVersions[bundler.name]).toBe(BUNDLER_VERSIONS[bundler.name]); - // It should use the correct version of the bundler. - // This is to ensure our test is running in the right conditions. - expect(bundlerVersions[bundler.name]).toBe(BUNDLER_VERSIONS[bundler.name]); + // It should not have printed any error. + expect(processErrors).toHaveLength(0); - // It should not have printed any error. - expect(processErrors).toHaveLength(0); + // Clean working dir. + if (!process.env.NO_CLEANUP) { + await rm(rootDir); + } - console.timeEnd(timeId); + console.timeEnd(timeId); - // Adding some timeout because webpack is SLOW. - }, 10000); + // Adding some timeout because webpack is SLOW. + }, + 10000, + ); }); test('Should not throw on a weird project.', async () => { @@ -303,21 +328,20 @@ describe('Bundling', () => { const timeId = `[ ${green('esbuild + webpack + rspack')}] ${green(projectName)} run`; console.time(timeId); - const SEED = `${TIMESTAMP}-${projectName}-${jest.getSeed()}`; - const rootDir = path.resolve(defaultDestination, SEED); - const outdir = path.resolve(rootDir, projectName); - seededFolders.push(rootDir); - const configs = getNodeSafeBuildOverrides(getComplexBuildOverrides()); + const SEED = `${jest.getSeed()}.${getUniqueId()}`; + const rootDir = await prepareWorkingDir(SEED); - // Build esbuild somewhere temporary first. - const esbuildOutdir = path.resolve(outdir, './temp'); + const overrides = getNodeSafeBuildOverrides(rootDir, getComplexBuildOverrides()); + const esbuildOverrides = overrides.esbuild; // Configure bundlers. - const baseEsbuildConfig = getEsbuildOptions(SEED, {}, configs.esbuild); + const baseEsbuildConfig = getEsbuildOptions(rootDir, {}, esbuildOverrides); + const esbuildOutdir = baseEsbuildConfig.outdir!; + const esbuildConfig1: BuildOptions = { ...baseEsbuildConfig, - outdir: esbuildOutdir, - entryPoints: { app1: defaultEntries.app1 }, + // Only one entry, we'll build the second one in a parallel build. + entryPoints: { app1: path.resolve(rootDir, defaultEntries.app1) }, plugins: [ ...(baseEsbuildConfig.plugins || []), // Add a custom loader that will build a new file using the parent configuration. @@ -325,9 +349,9 @@ describe('Bundling', () => { name: 'custom-build-loader', setup(build) { build.onLoad({ filter: /.*\/main1\.js/ }, async ({ path: filepath }) => { - const outfile = path.resolve(esbuildOutdir, 'app1.2.js'); + const outfile = path.resolve(build.initialOptions.outdir!, 'app1.2.js'); await runEsbuild( - SEED, + rootDir, {}, { ...build.initialOptions, @@ -349,36 +373,29 @@ describe('Bundling', () => { // Add a second parallel build. const esbuildConfig2: BuildOptions = { - ...baseEsbuildConfig, - outdir: esbuildOutdir, - entryPoints: { app2: defaultEntries.app2 }, + ...getEsbuildOptions(rootDir, {}, overrides.esbuild), + entryPoints: { app2: path.resolve(rootDir, defaultEntries.app2) }, }; // Webpack triggers some deprecations warnings only when we have multi-entry entries. const webpackEntries = { - app1: [ - path.resolve(esbuildOutdir, 'app1.js'), - '@dd/tests/_jest/fixtures/project/empty.js', - ], - app2: [ - path.resolve(esbuildOutdir, 'app2.js'), - '@dd/tests/_jest/fixtures/project/empty.js', - ], + app1: [path.resolve(esbuildOutdir, 'app1.js'), path.resolve(rootDir, './empty.js')], + app2: [path.resolve(esbuildOutdir, 'app2.js'), path.resolve(rootDir, './empty.js')], }; const rspackConfig = { - ...getRspackOptions(SEED, {}, configs.rspack), + ...getRspackOptions(rootDir, {}, overrides.rspack), entry: webpackEntries, }; const webpack5Config = { - ...getWebpack5Options(SEED, {}, configs.webpack5), + ...getWebpack5Options(rootDir, {}, overrides.webpack5), entry: webpackEntries, }; const webpack4Config = { - ...getWebpack4Options(SEED, {}, configs.webpack4), - entry: getWebpack4Entries(webpackEntries), + ...getWebpack4Options(rootDir, {}, overrides.webpack4), + entry: webpackEntries, }; // Build the sequence. @@ -386,14 +403,14 @@ describe('Bundling', () => { const sequence: (() => Promise)[] = [ () => Promise.all([ - runEsbuild(SEED, pluginConfig, esbuildConfig1), - runEsbuild(SEED, pluginConfig, esbuildConfig2), + runEsbuild(rootDir, pluginConfig, esbuildConfig1), + runEsbuild(rootDir, pluginConfig, esbuildConfig2), ]), () => Promise.all([ - runWebpack5(SEED, pluginConfig, webpack5Config), - runWebpack4(SEED, pluginConfig, webpack4Config), - runRspack(SEED, pluginConfig, rspackConfig), + runWebpack5(rootDir, pluginConfig, webpack5Config), + runWebpack4(rootDir, pluginConfig, webpack4Config), + runRspack(rootDir, pluginConfig, rspackConfig), ]), ]; @@ -415,6 +432,11 @@ describe('Bundling', () => { // It should not have printed any error. expect(processErrors).toHaveLength(0); + // Clean working dir. + if (!process.env.NO_CLEANUP) { + await rm(rootDir); + } + console.timeEnd(timeId); }); }); diff --git a/packages/tools/package.json b/packages/tools/package.json index b81c6c57f..8d746da09 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -13,6 +13,7 @@ "packageManager": "yarn@4.0.2", "exports": { "./rollupConfig.mjs": "./src/rollupConfig.mjs", + "./commands/oss/templates": "./src/commands/oss/templates.ts", "./*": "./src/*.ts" }, "scripts": { diff --git a/packages/tools/src/commands/create-plugin/templates.ts b/packages/tools/src/commands/create-plugin/templates.ts index 043752b90..f0d6ba410 100644 --- a/packages/tools/src/commands/create-plugin/templates.ts +++ b/packages/tools/src/commands/create-plugin/templates.ts @@ -27,7 +27,7 @@ export const getFiles = (context: Context): File[] => { content: (ctx) => { const hooksContent = ctx.hooks.map((hook) => getHookTemplate(hook)).join('\n'); return outdent` - import type { GlobalContext, GetPlugins } from '@dd/core/types'; + import type { GlobalContext, GetPlugins, Logger } from '@dd/core/types'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; import type { OptionsWith${pascalCase}, ${pascalCase}Options, ${pascalCase}OptionsWithDefaults } from './types'; @@ -56,9 +56,8 @@ export const getFiles = (context: Context): File[] => { export const getPlugins: GetPlugins = ( opts: OptionsWith${pascalCase}, context: GlobalContext, + log: Logger, ) => { - const log = context.getLogger(PLUGIN_NAME); - // Verify configuration. const options = validateOptions(opts); @@ -99,13 +98,11 @@ export const getFiles = (context: Context): File[] => { content: (ctx) => { const hooksContent = ctx.hooks.map((hook) => getHookTemplate(hook)).join('\n'); return outdent` - import type { GlobalContext, PluginOptions } from '@dd/core/types'; + import type { Logger, PluginOptions } from '@dd/core/types'; import { PLUGIN_NAME } from './constants'; - export const get${pascalCase}Plugins = (context: GlobalContext): PluginOptions[] => { - const log = context.getLogger(PLUGIN_NAME); - + export const get${pascalCase}Plugins = (log: Logger): PluginOptions[] => { return [ { name: PLUGIN_NAME, diff --git a/packages/tools/src/commands/integrity/readme.ts b/packages/tools/src/commands/integrity/readme.ts index 6024f41d5..6641e99e0 100644 --- a/packages/tools/src/commands/integrity/readme.ts +++ b/packages/tools/src/commands/integrity/readme.ts @@ -24,7 +24,7 @@ import { } from '@dd/tools/helpers'; import type { Workspace } from '@dd/tools/types'; import fs from 'fs'; -import glob from 'glob'; +import { glob } from 'glob'; import { outdent } from 'outdent'; import path from 'path'; @@ -139,12 +139,17 @@ const getPluginTemplate = (plugin: Workspace, pluginMeta: PluginMetadata) => { const configContent = pluginMeta.config ? outdent` +
+ + Configuration + \`\`\`typescript datadogWebpackPlugin({ ${pluginMeta.config.replace(/;/g, ',')} }); \`\`\` +
` : ''; @@ -152,8 +157,9 @@ const getPluginTemplate = (plugin: Workspace, pluginMeta: PluginMetadata) => { ${titleContent}${bundlerContent ? ` ${bundlerContent}` : ''} > ${intro.split('\n').join('\n> ')} + + #### [📝 Full documentation ➡️](/${plugin.location}#readme) ${configContent} - [📝 Full documentation ➡️](/${plugin.location}#readme) `; }; @@ -168,26 +174,14 @@ const getBundlerMeta = (bundler: Workspace): BundlerMetadata => { // Catch installation and usage. // Everything between "## (Installation|Usage)" and the next "##". const installation = readme.match(/## Installation\s*((!?[\s\S](?!##))*)/)?.[1] || ''; - const usage = readme.match(/## Usage\s*((!?[\s\S](?!##))*)/)?.[1] || ''; + const usage = readme.match(/## Usage\s*((!?[\s\S](?!```\n))+\n```)/)?.[1] || ''; return { title, name: title.toLowerCase(), usage, installation }; }; const getBundlerTemplate = (bundler: Workspace, bundlerMeta: BundlerMetadata) => { - const { title, name, installation, usage } = bundlerMeta; - return outdent` - ### ${getBundlerPicture(name)} ${title} - - \`${bundler.name}\` - - #### Installation - ${installation} - - #### Usage - ${usage} - - [📝 More details ➡️](/${bundler.location}#readme) - `; + const { title, name } = bundlerMeta; + return outdent`- [${getBundlerPicture(name)} ${title} \`${bundler.name}\`](/${bundler.location}#readme)`; }; const handleBundler = (bundler: Workspace, index: number) => { @@ -300,7 +294,7 @@ const handlePlugin = async (plugin: Workspace) => { const getGlobalContextType = () => { // Will capture the first code block after '## Global Context' up to the next title '## '. const RX = - /## Global Context(!?[\s\S](?!```typescript))+[\s\S](?```typescript([\s\S](?!## ))+)/gm; + /## Global Context(!?[\s\S](?!```typescript))+[\s\S](?```typescript([\s\S](?!```\n))+\n```)/gm; const coreReadmeContent = fs.readFileSync( path.resolve(ROOT, './packages/factory/README.md'), 'utf-8', @@ -364,7 +358,7 @@ export const updateReadmes = async (plugins: Workspace[], bundlers: Workspace[]) rootReadmeContent = replaceInBetween( rootReadmeContent, MD_BUNDLERS_KEY, - bundlersContents.join('\n\n'), + bundlersContents.join('\n'), ); rootReadmeContent = replaceInBetween( rootReadmeContent, diff --git a/packages/tools/src/commands/oss/apply.ts b/packages/tools/src/commands/oss/apply.ts index 93f1d98ff..b45fb159e 100644 --- a/packages/tools/src/commands/oss/apply.ts +++ b/packages/tools/src/commands/oss/apply.ts @@ -6,7 +6,7 @@ import checkbox from '@inquirer/checkbox'; import select from '@inquirer/select'; import chalk from 'chalk'; import fs from 'fs'; -import glob from 'glob'; +import { glob } from 'glob'; import path from 'path'; import { NAME, ROOT } from '../../constants'; diff --git a/packages/tools/src/helpers.ts b/packages/tools/src/helpers.ts index 9efaa9bbc..25ef19a88 100644 --- a/packages/tools/src/helpers.ts +++ b/packages/tools/src/helpers.ts @@ -4,7 +4,7 @@ import { ALL_BUNDLERS, SUPPORTED_BUNDLERS } from '@dd/core/constants'; import { readJsonSync } from '@dd/core/helpers'; -import type { GetPlugins, Logger } from '@dd/core/types'; +import type { BuildReport, GetPlugins, Logger } from '@dd/core/types'; import chalk from 'chalk'; import { execFile, execFileSync } from 'child_process'; import path from 'path'; @@ -47,7 +47,10 @@ export const slugify = (string: string) => { export const replaceInBetween = (content: string, mark: string, injection: string) => { const escapedMark = mark.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedInjection = injection.replace(/\$/g, '$$$$'); - const rx = new RegExp(`${escapedMark}[\\S\\s]*${escapedMark}`, 'gm'); + const rx = new RegExp( + `${escapedMark}([\\S\\s](?!${escapedMark}))*(\\s|\\S)?${escapedMark}`, + 'gm', + ); return content.replace(rx, `${mark}\n${escapedInjection}\n${mark}`); }; @@ -131,10 +134,15 @@ export const getWorkspaces = async ( // TODO: Update this, it's a bit hacky. export const getSupportedBundlers = (getPlugins: GetPlugins) => { + const bundler: BuildReport['bundler'] = { + name: 'esbuild', + fullName: 'esbuild', + version: '1.0.0', + }; const plugins = getPlugins( { telemetry: {}, - rum: { + errorTracking: { sourcemaps: { releaseVersion: '0', service: 'service', @@ -147,15 +155,14 @@ export const getSupportedBundlers = (getPlugins: GetPlugins) => { version: '0', start: 0, bundler: { - name: 'esbuild', - fullName: 'esbuild', + ...bundler, outDir: ROOT, - version: '1.0.0', }, build: { warnings: [], errors: [], logs: [], + bundler, }, inject() {}, pluginNames: [], diff --git a/packages/tools/src/rollupConfig.mjs b/packages/tools/src/rollupConfig.mjs index bf6d25d46..a424b539a 100644 --- a/packages/tools/src/rollupConfig.mjs +++ b/packages/tools/src/rollupConfig.mjs @@ -6,18 +6,24 @@ import babel from '@rollup/plugin-babel'; import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; import { nodeResolve } from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; +import chalk from 'chalk'; +import glob from 'glob'; import modulePackage from 'module'; +import path from 'path'; import dts from 'rollup-plugin-dts'; import esbuild from 'rollup-plugin-esbuild'; +const CWD = process.env.PROJECT_CWD; + /** * @param {{module: string; main: string;}} packageJson * @param {import('rollup').RollupOptions} config * @returns {import('rollup').RollupOptions} */ export const bundle = (packageJson, config) => ({ - ...config, input: 'src/index.ts', + ...config, external: [ // All peer dependencies are external dependencies. ...Object.keys(packageJson.peerDependencies), @@ -29,7 +35,15 @@ export const bundle = (packageJson, config) => ({ '@dd/tests', // We never want to include Node.js built-in modules in the bundle. ...modulePackage.builtinModules, + ...(config.external || []), ], + onwarn(warning, warn) { + // Ignore warnings about undefined `this`. + if (warning.code === 'THIS_IS_UNDEFINED') { + return; + } + warn(warning); + }, plugins: [ babel({ babelHelpers: 'bundled', @@ -40,37 +54,91 @@ export const bundle = (packageJson, config) => ({ nodeResolve({ preferBuiltins: true }), ...config.plugins, ], - output: { +}); + +/** + * @param {{module: string; main: string;}} packageJson + * @param {Partial} overrides + * @returns {import('rollup').OutputOptions} + */ +const getOutput = (packageJson, overrides = {}) => { + const filename = overrides.format === 'esm' ? packageJson.module : packageJson.main; + return { exports: 'named', sourcemap: true, - ...config.output, - }, -}); + entryFileNames: `[name]${path.extname(filename)}`, + dir: path.dirname(filename), + plugins: [terser()], + format: 'cjs', + // No chunks. + manualChunks: () => '[name]', + ...overrides, + }; +}; /** * @param {{module: string; main: string;}} packageJson * @returns {import('rollup').RollupOptions[]} */ -export const getDefaultBuildConfigs = (packageJson) => [ - bundle(packageJson, { - plugins: [esbuild()], - output: { - file: packageJson.module, - format: 'esm', - }, - }), - bundle(packageJson, { - plugins: [esbuild()], - output: { - file: packageJson.main, - format: 'cjs', - }, - }), - // FIXME: This build is sloooow. - bundle(packageJson, { - plugins: [dts()], - output: { - dir: 'dist/src', - }, - }), -]; +export const getDefaultBuildConfigs = async (packageJson) => { + // Verify if we have anything else to build from plugins. + const pkgs = glob.sync('packages/plugins/**/package.json', { cwd: CWD }); + const pluginBuilds = []; + for (const pkg of pkgs) { + const { default: content } = await import(path.resolve(CWD, pkg), { + assert: { type: 'json' }, + }); + + if (!content.toBuild) { + continue; + } + + console.log( + `Will also build ${chalk.green.bold(content.name)} additional files: ${chalk.green.bold(Object.keys(content.toBuild).join(', '))}`, + ); + + pluginBuilds.push( + ...Object.entries(content.toBuild).map(([name, config]) => { + return bundle(packageJson, { + plugins: [esbuild()], + external: config.external, + input: { + [name]: path.join(CWD, path.dirname(pkg), config.entry), + }, + output: [ + getOutput(packageJson, { + format: 'cjs', + sourcemap: false, + plugins: [terser({ mangle: true })], + }), + ], + }); + }), + ); + } + + const configs = [ + // Main bundle. + bundle(packageJson, { + plugins: [esbuild()], + input: { + index: 'src/index.ts', + }, + output: [ + getOutput(packageJson, { format: 'esm' }), + getOutput(packageJson, { format: 'cjs' }), + ], + }), + ...pluginBuilds, + // Bundle type definitions. + // FIXME: This build is sloooow. + // Check https://github.com/timocov/dts-bundle-generator + bundle(packageJson, { + plugins: [dts()], + output: { + dir: 'dist/src', + }, + }), + ]; + return configs; +}; diff --git a/yarn.lock b/yarn.lock index b94cf79dd..da74d5a5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1417,6 +1417,59 @@ __metadata: languageName: node linkType: hard +"@datadog/browser-core@npm:6.0.0": + version: 6.0.0 + resolution: "@datadog/browser-core@npm:6.0.0" + checksum: 10/283097b37b9108d84eb667e38a43b85ff13f00b50ce49ee062e40ce64b01c4bcce27a1ad049e46ecfe5c9e8d347615c2b838dde9b1f1273dbf4505b0ae4dd274 + languageName: node + linkType: hard + +"@datadog/browser-rum-core@npm:6.0.0": + version: 6.0.0 + resolution: "@datadog/browser-rum-core@npm:6.0.0" + dependencies: + "@datadog/browser-core": "npm:6.0.0" + checksum: 10/92ff7c44a486166c4f05c6ddd51b6aee252ae52d78b5a94239d1581c53e98e942ccee5df43d51320a943bafebd9c51abb42af17cf52c0c5ae11528f9a77c9a87 + languageName: node + linkType: hard + +"@datadog/browser-rum-react@npm:6.0.0": + version: 6.0.0 + resolution: "@datadog/browser-rum-react@npm:6.0.0" + dependencies: + "@datadog/browser-core": "npm:6.0.0" + "@datadog/browser-rum-core": "npm:6.0.0" + peerDependencies: + react: 18 + react-router-dom: 6 + peerDependenciesMeta: + "@datadog/browser-rum": + optional: true + "@datadog/browser-rum-slim": + optional: true + react: + optional: true + react-router-dom: + optional: true + checksum: 10/f13aec89dea182f0aeab70ab6e58d8e34da96699f6b6e62e2490f50d048015f018ea28bfd4f773582627fda198f49b1c623b11c4b197cd0df97da94974e85f55 + languageName: node + linkType: hard + +"@datadog/browser-rum@npm:6.0.0": + version: 6.0.0 + resolution: "@datadog/browser-rum@npm:6.0.0" + dependencies: + "@datadog/browser-core": "npm:6.0.0" + "@datadog/browser-rum-core": "npm:6.0.0" + peerDependencies: + "@datadog/browser-logs": 6.0.0 + peerDependenciesMeta: + "@datadog/browser-logs": + optional: true + checksum: 10/e50c3955afc9b1a391bc959609f20adb411f1f62cfbc42e5ec26e367c8e396d5faf512ae8ca8d0efd5d224bedeafc00e324763ca3a6ea83c1c7e58f2eeea637a + languageName: node + linkType: hard + "@datadog/build-plugins@workspace:.": version: 0.0.0-use.local resolution: "@datadog/build-plugins@workspace:." @@ -1451,6 +1504,7 @@ __metadata: "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-json": "npm:6.1.0" "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-terser": "npm:0.4.4" "@types/babel__core": "npm:^7" "@types/babel__preset-env": "npm:^7" async-retry: "npm:1.3.3" @@ -1484,6 +1538,7 @@ __metadata: "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-json": "npm:6.1.0" "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-terser": "npm:0.4.4" "@types/babel__core": "npm:^7" "@types/babel__preset-env": "npm:^7" async-retry: "npm:1.3.3" @@ -1504,7 +1559,7 @@ __metadata: languageName: unknown linkType: soft -"@datadog/rspack-plugin@workspace:packages/published/rspack-plugin": +"@datadog/rspack-plugin@workspace:*, @datadog/rspack-plugin@workspace:packages/published/rspack-plugin": version: 0.0.0-use.local resolution: "@datadog/rspack-plugin@workspace:packages/published/rspack-plugin" dependencies: @@ -1517,6 +1572,7 @@ __metadata: "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-json": "npm:6.1.0" "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-terser": "npm:0.4.4" "@types/babel__core": "npm:^7" "@types/babel__preset-env": "npm:^7" async-retry: "npm:1.3.3" @@ -1550,6 +1606,7 @@ __metadata: "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-json": "npm:6.1.0" "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-terser": "npm:0.4.4" "@types/babel__core": "npm:^7" "@types/babel__preset-env": "npm:^7" async-retry: "npm:1.3.3" @@ -1583,6 +1640,7 @@ __metadata: "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-json": "npm:6.1.0" "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-terser": "npm:0.4.4" "@types/babel__core": "npm:^7" "@types/babel__preset-env": "npm:^7" async-retry: "npm:1.3.3" @@ -1618,16 +1676,30 @@ __metadata: "@types/node": "npm:^18" async-retry: "npm:1.3.3" chalk: "npm:2.3.1" + esbuild: "npm:0.24.0" + glob: "npm:11.0.0" typescript: "npm:5.4.3" unplugin: "npm:1.16.0" languageName: unknown linkType: soft +"@dd/error-tracking-plugin@workspace:*, @dd/error-tracking-plugin@workspace:packages/plugins/error-tracking": + version: 0.0.0-use.local + resolution: "@dd/error-tracking-plugin@workspace:packages/plugins/error-tracking" + dependencies: + "@dd/core": "workspace:*" + chalk: "npm:2.3.1" + outdent: "npm:0.8.0" + p-queue: "npm:6.6.2" + languageName: unknown + linkType: soft + "@dd/factory@workspace:*, @dd/factory@workspace:packages/factory": version: 0.0.0-use.local resolution: "@dd/factory@workspace:packages/factory" dependencies: "@dd/core": "workspace:*" + "@dd/error-tracking-plugin": "workspace:*" "@dd/internal-build-report-plugin": "workspace:*" "@dd/internal-bundler-report-plugin": "workspace:*" "@dd/internal-git-plugin": "workspace:*" @@ -1644,7 +1716,6 @@ __metadata: resolution: "@dd/internal-build-report-plugin@workspace:packages/plugins/build-report" dependencies: "@dd/core": "workspace:*" - glob: "npm:11.0.0" languageName: unknown linkType: soft @@ -1678,10 +1749,10 @@ __metadata: version: 0.0.0-use.local resolution: "@dd/rum-plugin@workspace:packages/plugins/rum" dependencies: + "@datadog/browser-rum": "npm:6.0.0" + "@datadog/browser-rum-react": "npm:6.0.0" "@dd/core": "workspace:*" chalk: "npm:2.3.1" - outdent: "npm:0.8.0" - p-queue: "npm:6.6.2" languageName: unknown linkType: soft @@ -1712,14 +1783,15 @@ __metadata: dependencies: "@datadog/esbuild-plugin": "workspace:*" "@datadog/rollup-plugin": "workspace:*" + "@datadog/rspack-plugin": "workspace:*" "@datadog/vite-plugin": "workspace:*" "@datadog/webpack-plugin": "workspace:*" "@dd/core": "workspace:*" + "@dd/error-tracking-plugin": "workspace:*" "@dd/internal-build-report-plugin": "workspace:*" "@dd/internal-bundler-report-plugin": "workspace:*" "@dd/internal-git-plugin": "workspace:*" "@dd/internal-injection-plugin": "workspace:*" - "@dd/rum-plugin": "workspace:*" "@dd/telemetry-plugin": "workspace:*" "@rollup/plugin-commonjs": "npm:28.0.1" "@rspack/core": "npm:1.1.2" @@ -2756,6 +2828,22 @@ __metadata: languageName: node linkType: hard +"@rollup/plugin-terser@npm:0.4.4": + version: 0.4.4 + resolution: "@rollup/plugin-terser@npm:0.4.4" + dependencies: + serialize-javascript: "npm:^6.0.1" + smob: "npm:^1.0.0" + terser: "npm:^5.17.4" + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10/a5e066ddea55fc8c32188bc8b484cca619713516f10e3a06801881ec98bf37459ca24e5fe8711f93a5fa7f26a6e9132a47bc1a61c01e0b513dfd79a96cdc6eb7 + languageName: node + linkType: hard + "@rollup/pluginutils@npm:^5.0.1, @rollup/pluginutils@npm:^5.0.5, @rollup/pluginutils@npm:^5.1.0": version: 5.1.0 resolution: "@rollup/pluginutils@npm:5.1.0" @@ -9802,14 +9890,6 @@ __metadata: languageName: node linkType: hard -"project@workspace:packages/tests/src/_jest/fixtures/project": - version: 0.0.0-use.local - resolution: "project@workspace:packages/tests/src/_jest/fixtures/project" - dependencies: - chalk: "npm:2.3.1" - languageName: unknown - linkType: soft - "promise-inflight@npm:^1.0.1": version: 1.0.1 resolution: "promise-inflight@npm:1.0.1" @@ -10700,6 +10780,13 @@ __metadata: languageName: node linkType: hard +"smob@npm:^1.0.0": + version: 1.5.0 + resolution: "smob@npm:1.5.0" + checksum: 10/a1ea453bcea89989062626ea30a1fcb42c62e96255619c8641ffa1d7ab42baf415975c67c718127036901b9e487d8bf4c46219e50cec54295412c1227700b8fe + languageName: node + linkType: hard + "snapdragon-node@npm:^2.0.1": version: 2.1.1 resolution: "snapdragon-node@npm:2.1.1" @@ -11221,6 +11308,20 @@ __metadata: languageName: node linkType: hard +"terser@npm:^5.17.4": + version: 5.37.0 + resolution: "terser@npm:5.37.0" + dependencies: + "@jridgewell/source-map": "npm:^0.3.3" + acorn: "npm:^8.8.2" + commander: "npm:^2.20.0" + source-map-support: "npm:~0.5.20" + bin: + terser: bin/terser + checksum: 10/3afacf7c38c47a5a25dbe1ba2e7aafd61166474d4377ec0af490bd41ab3686ab12679818d5fe4a3e7f76efee26f639c92ac334940c378bbc31176520a38379c3 + languageName: node + linkType: hard + "terser@npm:^5.26.0": version: 5.29.2 resolution: "terser@npm:5.29.2"