diff --git a/.eslintrc.js b/.eslintrc.js index eff08d41f..04fe0f43c 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', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 27de7a7c1..841dba687 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -23,6 +23,6 @@ packages/tests/src/plugins/injection @yoannmoin packages/plugins/telemetry @DataDog/frontend-devx @yoannmoinet packages/tests/src/plugins/telemetry @DataDog/frontend-devx @yoannmoinet -# Rum -packages/plugins/rum @DataDog/rum @yoannmoinet -packages/tests/src/plugins/rum @DataDog/rum @yoannmoinet +# Error Tracking +packages/plugins/error-tracking @yoannmoinet +packages/tests/src/plugins/error-tracking @yoannmoinet diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0ba48f334..85c58b66d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,20 +8,95 @@ jobs: fail-fast: false matrix: node: - - 18 - - 20 + - node-version-file: 'package.json' + - node-version: 20.x - name: Unit tests w/ Node.js ${{matrix.node}}.x + name: Unit tests w/ Node.js ${{matrix.node.node-version || matrix.node.node-version-file}} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install Node ${{matrix.node}}.x + + - name: Install Node ${{matrix.node.node-version || matrix.node.node-version-file}} uses: actions/setup-node@v4 + with: ${{matrix.node}} + + - name: Cache build:all + id: cache-build + uses: actions/cache@v4 with: - node-version: ${{matrix.node}}.x + path: packages/published/**/dist + key: ${{ matrix.node }}-cache-build-${{ hashFiles('packages/published/**', 'yarn.lock') }} + - run: yarn install - - run: yarn test + + - name: Build all plugins + if: steps.cache-build.outputs.cache-hit != 'true' + run: yarn build:all + + - run: yarn test:unit + + e2e: + timeout-minutes: 10 + + name: End to End + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version-file: 'package.json' + + - name: Cache build:all + id: cache-build + uses: actions/cache@v4 + with: + path: packages/published/**/dist + key: node18-cache-build-${{ hashFiles('packages/published/**', 'yarn.lock') }} + + - name: Cache playwright binaries + id: cache-playwright-binaries + uses: actions/cache@v4 + with: + path: | + ~/.cache/ms-playwright + ~/Library/Caches/ms-playwright + %USERPROFILE%\AppData\Local\ms-playwright + key: cache-playwright-binaries-${{ hashFiles('yarn.lock') }} + + - run: yarn install + + - name: Install playwright + run: yarn workspace @dd/tests playwright install --with-deps + + - name: Build all plugins + if: steps.cache-build.outputs.cache-hit != 'true' + run: yarn build:all + + - run: yarn test:e2e + + - name: Save playwright cache + if: always() && steps.cache-playwright-binaries.outputs.cache-hit != 'true' + id: save-playwright-cache + uses: actions/cache/save@v4 + with: + path: | + ~/.cache/ms-playwright + ~/Library/Caches/ms-playwright + %USERPROFILE%\AppData\Local\ms-playwright + key: cache-playwright-binaries-${{ hashFiles('yarn.lock') }} + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() && failure() }} + with: + name: playwright + path: | + packages/tests/playwright-report + packages/tests/test-results + retention-days: 3 lint: name: Linting @@ -29,12 +104,27 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install node + + - name: Install Node uses: actions/setup-node@v4 with: - node-version: '18.19.0' + node-version-file: 'package.json' + + - name: Cache build:all + id: cache-build + uses: actions/cache@v4 + with: + path: packages/published/**/dist + key: node18-cache-build-${{ hashFiles('packages/published/**', 'yarn.lock') }} + - run: yarn install - - run: yarn build:all + + - name: Build all plugins + if: steps.cache-build.outputs.cache-hit != 'true' + run: yarn build:all + - run: yarn typecheck:all + - run: yarn cli integrity + - run: git diff --exit-code && git diff --cached --exit-code || (echo "Please run 'yarn cli integrity' and commit the result." && exit 1) diff --git a/.node-version b/.node-version deleted file mode 100644 index a9d087399..000000000 --- a/.node-version +++ /dev/null @@ -1 +0,0 @@ -18.19.0 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/@playwright-test-npm-1.49.1-6026636721-bb0d5eda58.zip b/.yarn/cache/@playwright-test-npm-1.49.1-6026636721-bb0d5eda58.zip new file mode 100644 index 000000000..611afa47a Binary files /dev/null and b/.yarn/cache/@playwright-test-npm-1.49.1-6026636721-bb0d5eda58.zip differ diff --git a/.yarn/cache/@rollup-plugin-esm-shim-npm-0.1.7-6570f9e71a-c3cc762ce7.zip b/.yarn/cache/@rollup-plugin-esm-shim-npm-0.1.7-6570f9e71a-c3cc762ce7.zip new file mode 100644 index 000000000..26985dd79 Binary files /dev/null and b/.yarn/cache/@rollup-plugin-esm-shim-npm-0.1.7-6570f9e71a-c3cc762ce7.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/@types-lodash-npm-4.17.14-6b38705727-6ee40725f3.zip b/.yarn/cache/@types-lodash-npm-4.17.14-6b38705727-6ee40725f3.zip new file mode 100644 index 000000000..e1c1ed3f5 Binary files /dev/null and b/.yarn/cache/@types-lodash-npm-4.17.14-6b38705727-6ee40725f3.zip differ diff --git a/.yarn/cache/@types-lodash.template-npm-4.5.3-a68fcd8d4e-7c9d32b8d8.zip b/.yarn/cache/@types-lodash.template-npm-4.5.3-a68fcd8d4e-7c9d32b8d8.zip new file mode 100644 index 000000000..5c51bf9cc Binary files /dev/null and b/.yarn/cache/@types-lodash.template-npm-4.5.3-a68fcd8d4e-7c9d32b8d8.zip differ diff --git a/.yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-6b5b6f5692.zip b/.yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-6b5b6f5692.zip new file mode 100644 index 000000000..816292417 Binary files /dev/null and b/.yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-6b5b6f5692.zip differ diff --git a/.yarn/cache/fsevents-patch-19706e7e35-10.zip b/.yarn/cache/fsevents-patch-19706e7e35-10.zip new file mode 100644 index 000000000..aff1ab12c Binary files /dev/null and b/.yarn/cache/fsevents-patch-19706e7e35-10.zip differ diff --git a/.yarn/cache/lodash._reinterpolate-npm-3.0.0-3c62ca439e-06d2d5f331.zip b/.yarn/cache/lodash._reinterpolate-npm-3.0.0-3c62ca439e-06d2d5f331.zip new file mode 100644 index 000000000..cf016b22b Binary files /dev/null and b/.yarn/cache/lodash._reinterpolate-npm-3.0.0-3c62ca439e-06d2d5f331.zip differ diff --git a/.yarn/cache/lodash.template-npm-4.5.0-5272df3039-56d18ba410.zip b/.yarn/cache/lodash.template-npm-4.5.0-5272df3039-56d18ba410.zip new file mode 100644 index 000000000..325273d92 Binary files /dev/null and b/.yarn/cache/lodash.template-npm-4.5.0-5272df3039-56d18ba410.zip differ diff --git a/.yarn/cache/lodash.templatesettings-npm-4.2.0-15fbdebcf4-ef470fa8b6.zip b/.yarn/cache/lodash.templatesettings-npm-4.2.0-15fbdebcf4-ef470fa8b6.zip new file mode 100644 index 000000000..d1d09d5dd Binary files /dev/null and b/.yarn/cache/lodash.templatesettings-npm-4.2.0-15fbdebcf4-ef470fa8b6.zip differ diff --git a/.yarn/cache/playwright-core-npm-1.49.1-a372dbc965-baa39a5302.zip b/.yarn/cache/playwright-core-npm-1.49.1-a372dbc965-baa39a5302.zip new file mode 100644 index 000000000..c1722107a Binary files /dev/null and b/.yarn/cache/playwright-core-npm-1.49.1-a372dbc965-baa39a5302.zip differ diff --git a/.yarn/cache/playwright-npm-1.49.1-0a8fed5892-49fb063f4a.zip b/.yarn/cache/playwright-npm-1.49.1-0a8fed5892-49fb063f4a.zip new file mode 100644 index 000000000..d52f8a070 Binary files /dev/null and b/.yarn/cache/playwright-npm-1.49.1-0a8fed5892-49fb063f4a.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/LICENSES-3rdparty.csv b/LICENSES-3rdparty.csv index 8405fc40f..fb065bf4f 100644 --- a/LICENSES-3rdparty.csv +++ b/LICENSES-3rdparty.csv @@ -167,10 +167,13 @@ Component,Origin,Licence,Copyright @nodelib/fs.walk,npm,MIT,(https://www.npmjs.com/package/@nodelib/fs.walk) @pkgjs/parseargs,npm,MIT,(https://github.com/pkgjs/parseargs#readme) @pkgr/core,npm,MIT,JounQin (https://github.com/un-ts/pkgr/blob/master/packages/core) +@playwright/test,npm,Apache-2.0,Microsoft Corporation (https://playwright.dev) @rollup/plugin-babel,virtual,MIT,Rich Harris (https://github.com/rollup/plugins/tree/master/packages/babel#readme) @rollup/plugin-commonjs,virtual,MIT,Rich Harris (https://github.com/rollup/plugins/tree/master/packages/commonjs/#readme) +@rollup/plugin-esm-shim,virtual,MIT,Peter Placzek (https://github.com/rollup/plugins/tree/master/packages/esm-shim#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) @@ -205,6 +208,8 @@ Component,Origin,Licence,Copyright @types/jest,npm,MIT,(https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/jest) @types/json-schema,npm,MIT,(https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/json-schema) @types/json5,npm,MIT,Jason Swearingen (https://www.npmjs.com/package/@types/json5) +@types/lodash,npm,MIT,(https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/lodash) +@types/lodash.template,npm,MIT,(https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/lodash.template) @types/minimatch,npm,MIT,(https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/minimatch) @types/mute-stream,npm,MIT,(https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/mute-stream) @types/node,npm,MIT,(https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node) @@ -640,9 +645,12 @@ listr2,npm,MIT,Cenk Kilic (https://srcs.kilic.dev) loader-runner,npm,MIT,Tobias Koppers @sokra (https://github.com/webpack/loader-runner#readme) loader-utils,npm,MIT,Tobias Koppers @sokra (https://www.npmjs.com/package/loader-utils) locate-path,npm,MIT,Sindre Sorhus (sindresorhus.com) +lodash._reinterpolate,npm,MIT,John-David Dalton (https://lodash.com/) lodash.debounce,npm,MIT,John-David Dalton (https://lodash.com/) lodash.memoize,npm,MIT,John-David Dalton (https://lodash.com/) lodash.merge,npm,MIT,John-David Dalton (https://lodash.com/) +lodash.template,npm,MIT,John-David Dalton (https://lodash.com/) +lodash.templatesettings,npm,MIT,John-David Dalton (https://lodash.com/) log-symbols,npm,MIT,Sindre Sorhus (sindresorhus.com) log-update,npm,MIT,Sindre Sorhus (sindresorhus.com) lru-cache,npm,ISC,Isaac Z. Schlueter (https://www.npmjs.com/package/lru-cache) @@ -739,6 +747,8 @@ picomatch,npm,MIT,Jon Schlinkert (https://github.com/micromatch/picomatch) pify,npm,MIT,Sindre Sorhus (sindresorhus.com) pirates,npm,MIT,Ari Porad (https://github.com/danez/pirates#readme) pkg-dir,npm,MIT,Sindre Sorhus (sindresorhus.com) +playwright,npm,Apache-2.0,Microsoft Corporation (https://playwright.dev) +playwright-core,npm,Apache-2.0,Microsoft Corporation (https://playwright.dev) please-upgrade-node,npm,MIT,typicode (https://github.com/typicode/please-upgrade-node#readme) posix-character-classes,npm,MIT,Jon Schlinkert (https://github.com/jonschlinkert/posix-character-classes) possible-typed-array-names,npm,MIT,Jordan Harband (https://github.com/ljharb/possible-typed-array-names#readme) @@ -822,6 +832,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/README.md b/README.md index 382fdbf6d..37754e150 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # Datadog Build Plugins <!-- #omit in toc --> A set of bundler plugins for: - - <img src="packages/assets/src/webpack.svg" alt="Webpack" width="17" /> Webpack - - <img src="packages/assets/src/vite.svg" alt="Vite" width="17" /> Vite - - <img src="packages/assets/src/esbuild.svg" alt="ESBuild" width="17" /> esbuild - - <img src="packages/assets/src/rollup.svg" alt="Rollup" width="17" /> Rollup - - <img src="packages/assets/src/rspack.svg" alt="Rspack" width="17" /> Rspack +<!-- #list-of-bundlers --> +- [<img src="packages/assets/src/esbuild.svg" alt="ESBuild" width="17" /> esbuild `@datadog/esbuild-plugin`](/packages/published/esbuild-plugin#readme) +- [<img src="packages/assets/src/rollup.svg" alt="Rollup" width="17" /> Rollup `@datadog/rollup-plugin`](/packages/published/rollup-plugin#readme) +- [<img src="packages/assets/src/rspack.svg" alt="Rspack" width="17" /> Rspack `@datadog/rspack-plugin`](/packages/published/rspack-plugin#readme) +- [<img src="packages/assets/src/vite.svg" alt="Vite" width="17" /> Vite `@datadog/vite-plugin`](/packages/published/vite-plugin#readme) +- [<img src="packages/assets/src/webpack.svg" alt="Webpack" width="17" /> Webpack `@datadog/webpack-plugin`](/packages/published/webpack-plugin#readme) +<!-- #list-of-bundlers --> To interact with Datadog directly from your builds. @@ -17,208 +19,44 @@ To interact with Datadog directly from your builds. <!-- This is auto generated with yarn cli integrity --> <!-- #toc --> -- [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-----) + - [Telemetry](#telemetry-----) - [Contributing](#contributing) - [License](#license) <!-- #toc --> -## Bundler Plugins - -<!-- #list-of-bundlers --> -### <img src="packages/assets/src/esbuild.svg" alt="ESBuild" width="17" /> 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. - - -<kbd>[📝 More details ➡️](/packages/published/esbuild-plugin#readme)</kbd> - -### <img src="packages/assets/src/rollup.svg" alt="Rollup" width="17" /> 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. - - -<kbd>[📝 More details ➡️](/packages/published/rollup-plugin#readme)</kbd> - -### <img src="packages/assets/src/rspack.svg" alt="Rspack" width="17" /> 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. - - -<kbd>[📝 More details ➡️](/packages/published/rspack-plugin#readme)</kbd> - -### <img src="packages/assets/src/vite.svg" alt="Vite" width="17" /> 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. - - -<kbd>[📝 More details ➡️](/packages/published/vite-plugin#readme)</kbd> - -### <img src="packages/assets/src/webpack.svg" alt="Webpack" width="17" /> Webpack - -`@datadog/webpack-plugin` +## Installation -#### 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 +64,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. - -<kbd>[📝 More details ➡️](/packages/published/webpack-plugin#readme)</kbd> +Follow the specific documentation for each bundler: +<!-- #list-of-bundlers --> +- [<img src="packages/assets/src/esbuild.svg" alt="ESBuild" width="17" /> esbuild `@datadog/esbuild-plugin`](/packages/published/esbuild-plugin#readme) +- [<img src="packages/assets/src/rollup.svg" alt="Rollup" width="17" /> Rollup `@datadog/rollup-plugin`](/packages/published/rollup-plugin#readme) +- [<img src="packages/assets/src/rspack.svg" alt="Rspack" width="17" /> Rspack `@datadog/rspack-plugin`](/packages/published/rspack-plugin#readme) +- [<img src="packages/assets/src/vite.svg" alt="Vite" width="17" /> Vite `@datadog/vite-plugin`](/packages/published/vite-plugin#readme) +- [<img src="packages/assets/src/webpack.svg" alt="Webpack" width="17" /> Webpack `@datadog/webpack-plugin`](/packages/published/webpack-plugin#readme) <!-- #list-of-bundlers --> -## Features - -<!-- #list-of-packages --> -### RUM <img src="packages/assets/src/esbuild.svg" alt="ESBuild" width="17" /> <img src="packages/assets/src/rollup.svg" alt="Rollup" width="17" /> <img src="packages/assets/src/rspack.svg" alt="Rspack" width="17" /> <img src="packages/assets/src/vite.svg" alt="Vite" width="17" /> <img src="packages/assets/src/webpack.svg" alt="Webpack" width="17" /> - -> 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, - }, - } -}); -``` - -<kbd>[📝 Full documentation ➡️](/packages/plugins/rum#readme)</kbd> - -### Telemetry <img src="packages/assets/src/esbuild.svg" alt="ESBuild" width="17" /> <img src="packages/assets/src/rollup.svg" alt="Rollup" width="17" /> <img src="packages/assets/src/rspack.svg" alt="Rspack" width="17" /> <img src="packages/assets/src/vite.svg" alt="Vite" width="17" /> <img src="packages/assets/src/webpack.svg" alt="Webpack" width="17" /> - -> 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 -<kbd>[📝 Full documentation ➡️](/packages/plugins/telemetry#readme)</kbd> -<!-- #list-of-packages --> +<details> -## Configuration +<summary>Full configuration object</summary> <!-- #full-configuration --> ```typescript @@ -296,7 +89,7 @@ datadogWebpackPlugin({ }; customPlugins?: (options: Options, context: GlobalContext, log: Logger) => UnpluginPlugin[]; logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'none'; - rum?: { + errorTracking?: { disabled?: boolean; sourcemaps?: { bailOnError?: boolean; @@ -328,12 +121,20 @@ datadogWebpackPlugin({ ``` <!-- #full-configuration --> +</details> + ### `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). + ### `logLevel` > default: `'warn'` @@ -367,7 +168,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. + +<details> + +<summary>Full context object</summary> <!-- #global-context-type --> ```typescript @@ -433,16 +238,77 @@ type GlobalContext = { version: string; } ``` +<!-- #global-context-type --> -> [!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. +</details> -<!-- #global-context-type --> +#### [📝 Full documentation ➡️](/packages/factory#global-context) + +## Features + +<!-- #list-of-packages --> +### Error Tracking <img src="packages/assets/src/esbuild.svg" alt="ESBuild" width="17" /> <img src="packages/assets/src/rollup.svg" alt="Rollup" width="17" /> <img src="packages/assets/src/rspack.svg" alt="Rspack" width="17" /> <img src="packages/assets/src/vite.svg" alt="Vite" width="17" /> <img src="packages/assets/src/webpack.svg" alt="Webpack" width="17" /> + +> Interact with Error Tracking directly from your build system. -Your function will need to return an array of [Unplugin Plugins definitions](https://unplugin.unjs.io/guide/#supported-hooks). +#### [📝 Full documentation ➡️](/packages/plugins/error-tracking#readme) + +<details> + +<summary>Configuration</summary> + +```typescript +datadogWebpackPlugin({ + errorTracking?: { + disabled?: boolean, + sourcemaps?: { + bailOnError?: boolean, + dryRun?: boolean, + intakeUrl?: string, + maxConcurrency?: number, + minifiedPathPrefix: string, + releaseVersion: string, + service: string, + }, + } +}); +``` + +</details> + +### Telemetry <img src="packages/assets/src/esbuild.svg" alt="ESBuild" width="17" /> <img src="packages/assets/src/rollup.svg" alt="Rollup" width="17" /> <img src="packages/assets/src/rspack.svg" alt="Rspack" width="17" /> <img src="packages/assets/src/vite.svg" alt="Vite" width="17" /> <img src="packages/assets/src/webpack.svg" alt="Webpack" width="17" /> + +> Display and send telemetry data as metrics to Datadog. + +#### [📝 Full documentation ➡️](/packages/plugins/telemetry#readme) + +<details> + +<summary>Configuration</summary> + +```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)[], + } +}); +``` + +</details> +<!-- #list-of-packages --> ## Contributing @@ -452,4 +318,4 @@ Check out [CONTRIBUTING.md](/CONTRIBUTING.md) for more information about how to [MIT](/LICENSE) -<kbd>[Back to top :arrow_up:](#top)</kbd> +### [Back to top :arrow_up:](#top) <!-- #omit in toc --> diff --git a/package.json b/package.json index d4bbd2602..fa5594976 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,6 @@ "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", "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/constants.ts b/packages/core/src/constants.ts index 4982a9c06..13096e63a 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -7,10 +7,11 @@ export const INJECTED_FILE = '__datadog-helper-file'; export const ALL_BUNDLERS = ['webpack', 'vite', 'esbuild', 'rollup', 'rspack', 'rolldown', 'farm']; export const SUPPORTED_BUNDLERS = ['webpack', 'vite', 'esbuild', 'rollup', 'rspack'] as const; export const FULL_NAME_BUNDLERS = [ - 'webpack4', - 'webpack5', - 'vite', 'esbuild', 'rollup', 'rspack', + 'vite', + 'webpack4', + 'webpack5', ] as const; +export const ENV_VAR_REQUESTED_BUNDLERS = 'PLAYWRIGHT_REQUESTED_BUNDLERS'; diff --git a/packages/core/src/helpers.ts b/packages/core/src/helpers.ts index 582719656..822c0c5be 100644 --- a/packages/core/src/helpers.ts +++ b/packages/core/src/helpers.ts @@ -4,12 +4,28 @@ 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 { + BuildReport, + Entry, + File, + GlobalContext, + Input, + Logger, + Output, + RequestOpts, + ResolvedEntry, + SerializedBuildReport, + SerializedEntry, + SerializedInput, + SerializedOutput, +} from './types'; // Format a duration 0h 0m 0s 0ms export const formatDuration = (duration: number) => { @@ -25,19 +41,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<ResolvedEntry[]> => { + 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 (entryPoints && 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 = <T>(opts: RequestOpts): Promise<T> => { - 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 +124,24 @@ export const doRequest = <T>(opts: RequestOpts): Promise<T> => { // 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 +255,158 @@ 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}`; + +// Returns an object that is safe to serialize to JSON. +// Mostly useful for debugging and testing. +export const serializeBuildReport = (report: BuildReport): SerializedBuildReport => { + // Report is an object that self reference some of its values. + // 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, + start: report.start, + end: report.end, + duration: report.duration, + writeDuration: report.writeDuration, + entries: [], + inputs: [], + outputs: [], + }; + + for (const entry of report.entries || []) { + const newEntry: SerializedEntry = { ...entry, inputs: [], outputs: [] }; + if (entry.inputs) { + newEntry.inputs = entry.inputs.map((file: File) => file.filepath); + } + if (entry.outputs) { + newEntry.outputs = entry.outputs.map((file: File) => file.filepath); + } + jsonReport.entries.push(newEntry); + } + + for (const input of report.inputs || []) { + const newInput: SerializedInput = { ...input, dependencies: [], dependents: [] }; + if (input.dependencies) { + for (const dependency of input.dependencies) { + newInput.dependencies.push(dependency.filepath); + } + } + if (input.dependents) { + for (const dependent of input.dependents) { + newInput.dependents.push(dependent.filepath); + } + } + jsonReport.inputs.push(newInput); + } + + for (const output of report.outputs || []) { + const newOutput: SerializedOutput = { ...output, inputs: [] }; + if (output.inputs) { + newOutput.inputs = output.inputs.map((file: File) => file.filepath); + } + jsonReport.outputs.push(newOutput); + } + + return jsonReport; +}; + +// Returns an object that is unserialized from serializeBuildReport(). +// 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, + start: report.start, + end: report.end, + duration: report.duration, + writeDuration: report.writeDuration, + }; + + const reportInputs = report.inputs || []; + const reportOutputs = report.outputs || []; + + const entries: Entry[] = []; + + // Prefill inputs and outputs as they are sometimes self-referencing themselves. + const indexedInputs: Map<string, Input> = new Map(); + const inputs: Input[] = reportInputs.map<Input>((input) => { + const newInput: Input = { + ...input, + // Keep them empty for now, we'll fill them later. + dependencies: new Set(), + dependents: new Set(), + }; + indexedInputs.set(input.filepath, newInput); + return newInput; + }); + + const indexedOutputs: Map<string, Output> = new Map(); + const outputs: Output[] = reportOutputs.map<Output>((output) => { + const newOutput: Output = { ...output, inputs: [] }; + indexedOutputs.set(output.filepath, newOutput); + return newOutput; + }); + + // Fill in the inputs' dependencies and dependents. + for (const input of reportInputs) { + const newInput: Input = indexedInputs.get(input.filepath)!; + + // Re-assign the dependencies and dependents to the actual objects. + if (input.dependencies) { + for (const dependency of input.dependencies) { + const newDependency = indexedInputs.get(dependency)!; + newInput.dependencies.add(newDependency); + } + } + if (input.dependents) { + for (const dependent of input.dependents) { + const newDependent = indexedInputs.get(dependent)!; + newInput.dependents.add(newDependent); + } + } + } + + // Fill in the outputs' inputs. + for (const output of reportOutputs) { + const newOutput: Output = indexedOutputs.get(output.filepath)!; + if (output.inputs) { + // Re-assign the inputs to the actual objects. + newOutput.inputs = output.inputs + .map< + // Can be either an input or an output (for sourcemaps). + Input | Output | undefined + >((filepath: string) => indexedInputs.get(filepath) || indexedOutputs.get(filepath)) + .filter(Boolean) as (Input | Output)[]; + } + } + + for (const entry of report.entries || []) { + const newEntry: Entry = { ...entry, inputs: [], outputs: [] }; + if (entry.inputs) { + newEntry.inputs = entry.inputs + .map((filepath: string) => indexedInputs.get(filepath)) + .filter(Boolean) as (Output | Input)[]; + } + if (entry.outputs) { + newEntry.outputs = entry.outputs + .map((filepath: string) => indexedOutputs.get(filepath)) + .filter(Boolean) as Output[]; + } + entries.push(newEntry); + } + + return { + ...buildReport, + entries, + inputs, + outputs, + }; +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 11e6a6b46..a9d148a25 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,8 +9,8 @@ import type { TrackedFilesMatcher } from '@dd/internal-git-plugin/trackedFilesMatcher'; /* eslint-disable arca/import-ordering */ // #imports-injection-marker -import type { RumOptions } from '@dd/rum-plugin/types'; -import type * as rum from '@dd/rum-plugin'; +import type { ErrorTrackingOptions } from '@dd/error-tracking-plugin/types'; +import type * as errorTracking from '@dd/error-tracking-plugin'; import type { TelemetryOptions } from '@dd/telemetry-plugin/types'; import type * as telemetry from '@dd/telemetry-plugin'; // #imports-injection-marker @@ -41,9 +41,16 @@ export type SerializedInput = Assign<Input, { dependencies: string[]; dependents export type SerializedOutput = Assign<Output, { inputs: string[] }>; export type BuildReport = { + bundler: Omit<BundlerReport, 'outDir' | 'rawConfig'>; 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 +81,18 @@ export type BundlerReport = { version: string; }; -export type ToInjectItem = { type: 'file' | 'code'; value: string; fallback?: ToInjectItem }; +export type InjectedValue = string | (() => Promise<string>); +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 +134,7 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none'; export type AuthOptions = { apiKey?: string; + appKey?: string; }; export interface BaseOptions { @@ -127,7 +146,7 @@ export interface BaseOptions { export interface Options extends BaseOptions { // Each product should have a unique entry. // #types-injection-marker - [rum.CONFIG_KEY]?: RumOptions; + [errorTracking.CONFIG_KEY]?: ErrorTrackingOptions; [telemetry.CONFIG_KEY]?: TelemetryOptions; // #types-injection-marker customPlugins?: GetCustomPlugins; @@ -138,11 +157,14 @@ export type OptionsWithDefaults = Assign<Options, GetPluginsOptions>; export type PluginName = `datadog-${Lowercase<string>}-plugin`; -type Data = { data: BodyInit; headers?: Record<string, string> }; +type Data = { data?: BodyInit; headers?: Record<string, string> }; export type RequestOpts = { url: string; + auth?: AuthOptions; method?: string; getData?: () => Promise<Data> | 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. -<kbd>[📝 Full documentation ➡️](/packages/plugins/build-report#readme)</kbd> +#### [📝 Full documentation ➡️](/packages/plugins/build-report#readme) + ### Bundler Report > A very basic report on the currently used bundler.<br/> > It is useful to unify some configurations. -<kbd>[📝 Full documentation ➡️](/packages/plugins/bundler-report#readme)</kbd> +#### [📝 Full documentation ➡️](/packages/plugins/bundler-report#readme) + ### Git > Adds repository data to the global context from the `buildStart` hook. -<kbd>[📝 Full documentation ➡️](/packages/plugins/git#readme)</kbd> +#### [📝 Full documentation ➡️](/packages/plugins/git#readme) + ### Injection -> This is used to prepend some code to the produced bundle.<br/> -> 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.<br/> +> Particularly useful : +> - to share some global context. +> - to automatically inject some SDK. +> - to initialise some global dependencies. +> - ... + +#### [📝 Full documentation ➡️](/packages/plugins/injection#readme) -<kbd>[📝 Full documentation ➡️](/packages/plugins/injection#readme)</kbd> <!-- #internal-plugins-list --> ## 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..b686e4eab 100644 --- a/packages/factory/package.json +++ b/packages/factory/package.json @@ -17,11 +17,11 @@ }, "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:*", "@dd/internal-injection-plugin": "workspace:*", - "@dd/rum-plugin": "workspace:*", "@dd/telemetry-plugin": "workspace:*", "chalk": "2.3.1", "unplugin": "1.16.0" 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<LogLevel, number> = { 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<string, ToInjectItem>; 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..bf633c12a 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,13 +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 { OptionsWithRum } from '@dd/rum-plugin/types'; -import * as rum from '@dd/rum-plugin'; +import type { OptionsWithErrorTracking } from '@dd/error-tracking-plugin/types'; +import * as errorTracking from '@dd/error-tracking-plugin'; import type { OptionsWithTelemetry } from '@dd/telemetry-plugin/types'; import * as telemetry from '@dd/telemetry-plugin'; import { getBuildReportPlugins } from '@dd/internal-build-report-plugin'; @@ -37,10 +38,9 @@ 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 RumTypes } from '@dd/rum-plugin'; +export type { types as ErrorTrackingTypes } from '@dd/error-tracking-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 +68,7 @@ export const buildPluginFactory = ({ } // Create the global context. - const injections: ToInjectItem[] = []; + const injections: Map<string, ToInjectItem> = new Map(); const context: GlobalContext = getContext({ options, bundlerVersion: bundler.version || bundler.VERSION, @@ -91,6 +91,7 @@ export const buildPluginFactory = ({ ...getGitPlugins(options, context), ...getInjectionPlugins( bundler, + options, context, injections, getLogger('datadog-injection-plugin'), @@ -110,9 +111,16 @@ export const buildPluginFactory = ({ // Based on configuration add corresponding plugin. // #configs-injection-marker - if (options[rum.CONFIG_KEY] && options[rum.CONFIG_KEY].disabled !== true) { + if ( + options[errorTracking.CONFIG_KEY] && + options[errorTracking.CONFIG_KEY].disabled !== true + ) { plugins.push( - ...rum.getPlugins(options as OptionsWithRum, context, getLogger(rum.PLUGIN_NAME)), + ...errorTracking.getPlugins( + options as OptionsWithErrorTracking, + context, + getLogger(errorTracking.PLUGIN_NAME), + ), ); } if (options[telemetry.CONFIG_KEY] && options[telemetry.CONFIG_KEY].disabled !== true) { @@ -129,6 +137,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 = <T>(obj: Record<string, T>, 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<string, string> | { in: string; out: string }[] | undefined, - context: GlobalContext, -): Map<string, string> => { - 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..aeaee5dcb 100644 --- a/packages/plugins/build-report/src/helpers.ts +++ b/packages/plugins/build-report/src/helpers.ts @@ -4,18 +4,7 @@ import { INJECTED_FILE } from '@dd/core/constants'; import { isInjectionFile } from '@dd/core/helpers'; -import type { - BuildReport, - SerializedEntry, - File, - GlobalContext, - SerializedInput, - SerializedBuildReport, - SerializedOutput, - Entry, - Input, - Output, -} from '@dd/core/types'; +import type { GlobalContext } from '@dd/core/types'; import path from 'path'; // Will match any last part of a path after a dot or slash and is a word character. @@ -43,156 +32,6 @@ export const getType = (name: string): string => { return getExtension(cleanPath(name)) || 'unknown'; }; -// Returns an object that is safe to serialize to JSON. -// Mostly useful for debugging and testing. -export const serializeBuildReport = (report: BuildReport): SerializedBuildReport => { - // Report is an object that self reference some of its values. - // 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 = { - errors: report.errors, - warnings: report.warnings, - logs: report.logs, - start: report.start, - end: report.end, - duration: report.duration, - writeDuration: report.writeDuration, - entries: [], - inputs: [], - outputs: [], - }; - - for (const entry of report.entries || []) { - const newEntry: SerializedEntry = { ...entry, inputs: [], outputs: [] }; - if (entry.inputs) { - newEntry.inputs = entry.inputs.map((file: File) => file.filepath); - } - if (entry.outputs) { - newEntry.outputs = entry.outputs.map((file: File) => file.filepath); - } - jsonReport.entries.push(newEntry); - } - - for (const input of report.inputs || []) { - const newInput: SerializedInput = { ...input, dependencies: [], dependents: [] }; - if (input.dependencies) { - for (const dependency of input.dependencies) { - newInput.dependencies.push(dependency.filepath); - } - } - if (input.dependents) { - for (const dependent of input.dependents) { - newInput.dependents.push(dependent.filepath); - } - } - jsonReport.inputs.push(newInput); - } - - for (const output of report.outputs || []) { - const newOutput: SerializedOutput = { ...output, inputs: [] }; - if (output.inputs) { - newOutput.inputs = output.inputs.map((file: File) => file.filepath); - } - jsonReport.outputs.push(newOutput); - } - - return jsonReport; -}; - -// Returns an object that is unserialized from serializeBuildReport(). -// Mostly useful for debugging and testing. -export const unserializeBuildReport = (report: SerializedBuildReport): BuildReport => { - const buildReport: BuildReport = { - errors: report.errors, - warnings: report.warnings, - logs: report.logs, - start: report.start, - end: report.end, - duration: report.duration, - writeDuration: report.writeDuration, - }; - - const reportInputs = report.inputs || []; - const reportOutputs = report.outputs || []; - - const entries: Entry[] = []; - - // Prefill inputs and outputs as they are sometimes self-referencing themselves. - const indexedInputs: Map<string, Input> = new Map(); - const inputs: Input[] = reportInputs.map<Input>((input) => { - const newInput: Input = { - ...input, - // Keep them empty for now, we'll fill them later. - dependencies: new Set(), - dependents: new Set(), - }; - indexedInputs.set(input.filepath, newInput); - return newInput; - }); - - const indexedOutputs: Map<string, Output> = new Map(); - const outputs: Output[] = reportOutputs.map<Output>((output) => { - const newOutput: Output = { ...output, inputs: [] }; - indexedOutputs.set(output.filepath, newOutput); - return newOutput; - }); - - // Fill in the inputs' dependencies and dependents. - for (const input of reportInputs) { - const newInput: Input = indexedInputs.get(input.filepath)!; - - // Re-assign the dependencies and dependents to the actual objects. - if (input.dependencies) { - for (const dependency of input.dependencies) { - const newDependency = indexedInputs.get(dependency)!; - newInput.dependencies.add(newDependency); - } - } - if (input.dependents) { - for (const dependent of input.dependents) { - const newDependent = indexedInputs.get(dependent)!; - newInput.dependents.add(newDependent); - } - } - } - - // Fill in the outputs' inputs. - for (const output of reportOutputs) { - const newOutput: Output = indexedOutputs.get(output.filepath)!; - if (output.inputs) { - // Re-assign the inputs to the actual objects. - newOutput.inputs = output.inputs - .map< - // Can be either an input or an output (for sourcemaps). - Input | Output | undefined - >((filepath: string) => indexedInputs.get(filepath) || indexedOutputs.get(filepath)) - .filter(Boolean) as (Input | Output)[]; - } - } - - for (const entry of report.entries || []) { - const newEntry: Entry = { ...entry, inputs: [], outputs: [] }; - if (entry.inputs) { - newEntry.inputs = entry.inputs - .map((filepath: string) => indexedInputs.get(filepath)) - .filter(Boolean) as (Output | Input)[]; - } - if (entry.outputs) { - newEntry.outputs = entry.outputs - .map((filepath: string) => indexedOutputs.get(filepath)) - .filter(Boolean) as Output[]; - } - entries.push(newEntry); - } - - return { - ...buildReport, - entries, - inputs, - outputs, - }; -}; - const BUNDLER_SPECIFICS = ['unknown', 'commonjsHelpers.js', 'vite/preload-helper.js']; // Make list of paths unique, remove the current file and particularities. export const cleanReport = <T = string>( 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..04f34f7d4 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<string> = 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: { + ...rollupPlugin(), + config(config) { + if (config.root) { + context.cwd = config.root; + } else { + handleCwd(Array.from(directories), context); + } + }, + }, + rollup: rollupPlugin(), }; return [bundlerReportPlugin]; diff --git a/packages/plugins/rum/README.md b/packages/plugins/error-tracking/README.md similarity index 67% rename from packages/plugins/rum/README.md rename to packages/plugins/error-tracking/README.md index 601b50102..756fdf881 100644 --- a/packages/plugins/rum/README.md +++ b/packages/plugins/error-tracking/README.md @@ -1,6 +1,6 @@ -# RUM Plugin <!-- #omit in toc --> +# Error Tracking Plugin <!-- #omit in toc --> -Interact with our Real User Monitoring product (RUM) in Datadog directly from your build system. +Interact with Error Tracking directly from your build system. <!-- The title and the following line will both be added to the root README.md --> @@ -11,19 +11,19 @@ Interact with our Real User Monitoring product (RUM) in Datadog directly from yo <!-- #toc --> - [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) + - [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) <!-- #toc --> ## Configuration ```ts -rum?: { +errorTracking?: { disabled?: boolean; sourcemaps?: { bailOnError?: boolean; @@ -45,31 +45,31 @@ Upload JavaScript sourcemaps to Datadog to un-minify your errors. > 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`). -### `rum.sourcemaps.bailOnError` +### errorTracking.sourcemaps.bailOnError > default: `false` Should the upload of sourcemaps fail the build on first error? -### `rum.sourcemaps.dryRun` +### errorTracking.sourcemaps.dryRun > default: `false` It will not upload the sourcemaps to Datadog, but will do everything else. -### `rum.sourcemaps.intakeUrl` +### errorTracking.sourcemaps.intakeUrl > default: `https://sourcemap-intake.datadoghq.com/api/v2/srcmap` Against which endpoint do you want to upload the sourcemaps. -### `rum.sourcemaps.maxConcurrency` +### errorTracking.sourcemaps.maxConcurrency > default: `20` Number of concurrent upload to the API. -### `rum.sourcemaps.minifiedPathPrefix` +### errorTracking.sourcemaps.minifiedPathPrefix > required @@ -79,13 +79,13 @@ 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. -### `rum.sourcemaps.releaseVersion` +### errorTracking.sourcemaps.releaseVersion > required Is similar and will be used to match the `version` tag set on the RUM SDK. -### `rum.sourcemaps.service` +### errorTracking.sourcemaps.service > required diff --git a/packages/plugins/rum/package.json b/packages/plugins/error-tracking/package.json similarity index 73% rename from packages/plugins/rum/package.json rename to packages/plugins/error-tracking/package.json index 031685db4..2c19e7e22 100644 --- a/packages/plugins/rum/package.json +++ b/packages/plugins/error-tracking/package.json @@ -1,15 +1,15 @@ { - "name": "@dd/rum-plugin", + "name": "@dd/error-tracking-plugin", "packageManager": "yarn@4.0.2", "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/plugins/rum#readme", + "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/rum" + "directory": "packages/plugins/error-tracking" }, "exports": { ".": "./src/index.ts", diff --git a/packages/plugins/rum/src/constants.ts b/packages/plugins/error-tracking/src/constants.ts similarity index 67% rename from packages/plugins/rum/src/constants.ts rename to packages/plugins/error-tracking/src/constants.ts index a27b7a174..2dde88b79 100644 --- a/packages/plugins/rum/src/constants.ts +++ b/packages/plugins/error-tracking/src/constants.ts @@ -4,5 +4,5 @@ import type { PluginName } from '@dd/core/types'; -export const CONFIG_KEY = 'rum' as const; -export const PLUGIN_NAME: PluginName = 'datadog-rum-plugin' as const; +export const CONFIG_KEY = 'errorTracking' as const; +export const PLUGIN_NAME: PluginName = 'datadog-error-tracking-plugin' as const; diff --git a/packages/plugins/rum/src/index.ts b/packages/plugins/error-tracking/src/index.ts similarity index 55% rename from packages/plugins/rum/src/index.ts rename to packages/plugins/error-tracking/src/index.ts index 6cb5ddd7b..e2604e16f 100644 --- a/packages/plugins/rum/src/index.ts +++ b/packages/plugins/error-tracking/src/index.ts @@ -4,37 +4,46 @@ import type { GlobalContext, GetPlugins, Logger } from '@dd/core/types'; +import { PLUGIN_NAME } from './constants'; import { uploadSourcemaps } from './sourcemaps'; -import type { OptionsWithRum, RumOptions, RumOptionsWithSourcemaps } from './types'; +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. - RumOptions: RumOptions; - OptionsWithRum: OptionsWithRum; + ErrorTrackingOptions: ErrorTrackingOptions; + OptionsWithErrorTracking: OptionsWithErrorTracking; }; -export const getPlugins: GetPlugins<OptionsWithRum> = ( - opts: OptionsWithRum, +export const getPlugins: GetPlugins<OptionsWithErrorTracking> = ( + opts: OptionsWithErrorTracking, context: GlobalContext, log: Logger, ) => { // Verify configuration. - const rumOptions = validateOptions(opts, log); + const options = validateOptions(opts, log); return [ { - name: 'datadog-rum-sourcemaps-plugin', + name: PLUGIN_NAME, enforce: 'post', async writeBundle() { - if (rumOptions.disabled) { + if (options.disabled) { return; } - if (rumOptions.sourcemaps) { + if (options.sourcemaps) { // Need the "as" because Typescript doesn't understand that we've already checked for sourcemaps. - await uploadSourcemaps(rumOptions as RumOptionsWithSourcemaps, context, log); + 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<Sourcemap, 'minifiedFilePath' | 'minifiedUrl' | 'relativePath'>; 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/rum/src/types.ts b/packages/plugins/error-tracking/src/types.ts similarity index 64% rename from packages/plugins/rum/src/types.ts rename to packages/plugins/error-tracking/src/types.ts index ac70fac15..3319b5740 100644 --- a/packages/plugins/rum/src/types.ts +++ b/packages/plugins/error-tracking/src/types.ts @@ -8,7 +8,7 @@ import type { CONFIG_KEY } from './constants'; export type MinifiedPathPrefix = `http://${string}` | `https://${string}` | `/${string}`; -export type RumSourcemapsOptions = { +export type SourcemapsOptions = { bailOnError?: boolean; dryRun?: boolean; intakeUrl?: string; @@ -18,25 +18,25 @@ export type RumSourcemapsOptions = { service: string; }; -export type RumOptions = { +export type SourcemapsOptionsWithDefaults = Required<SourcemapsOptions>; + +export type ErrorTrackingOptions = { disabled?: boolean; - sourcemaps?: RumSourcemapsOptions; + sourcemaps?: SourcemapsOptions; }; -export type RumSourcemapsOptionsWithDefaults = Required<RumSourcemapsOptions>; - -export type RumOptionsWithDefaults = { +export type ErrorTrackingOptionsWithDefaults = { disabled?: boolean; - sourcemaps?: RumSourcemapsOptionsWithDefaults; + sourcemaps?: SourcemapsOptionsWithDefaults; }; -export type RumOptionsWithSourcemaps = { +export type ErrorTrackingOptionsWithSourcemaps = { disabled?: boolean; - sourcemaps: RumSourcemapsOptionsWithDefaults; + sourcemaps: SourcemapsOptionsWithDefaults; }; -export interface OptionsWithRum extends GetPluginsOptions { - [CONFIG_KEY]: RumOptions; +export interface OptionsWithErrorTracking extends GetPluginsOptions { + [CONFIG_KEY]: ErrorTrackingOptions; } export type Sourcemap = { diff --git a/packages/plugins/rum/src/validate.ts b/packages/plugins/error-tracking/src/validate.ts similarity index 84% rename from packages/plugins/rum/src/validate.ts rename to packages/plugins/error-tracking/src/validate.ts index 89448ab92..ec7061502 100644 --- a/packages/plugins/rum/src/validate.ts +++ b/packages/plugins/error-tracking/src/validate.ts @@ -7,19 +7,19 @@ import chalk from 'chalk'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; import type { - OptionsWithRum, - RumOptions, - RumOptionsWithDefaults, - RumSourcemapsOptionsWithDefaults, + 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<OptionsWithRum>, + config: Partial<OptionsWithErrorTracking>, log: Logger, -): RumOptionsWithDefaults => { +): ErrorTrackingOptionsWithDefaults => { const errors: string[] = []; // Validate and add defaults sub-options. @@ -33,7 +33,7 @@ export const validateOptions = ( } // Build the final configuration. - const toReturn: RumOptionsWithDefaults = { + const toReturn: ErrorTrackingOptionsWithDefaults = { ...config[CONFIG_KEY], sourcemaps: undefined, }; @@ -68,11 +68,11 @@ const validateMinifiedPathPrefix = (minifiedPathPrefix: string): boolean => { }; export const validateSourcemapsOptions = ( - config: Partial<OptionsWithRum>, -): ToReturn<RumOptionsWithDefaults['sourcemaps']> => { + config: Partial<OptionsWithErrorTracking>, +): ToReturn<SourcemapsOptionsWithDefaults> => { const red = chalk.bold.red; - const validatedOptions: RumOptions = config[CONFIG_KEY] || {}; - const toReturn: ToReturn<Required<RumSourcemapsOptionsWithDefaults>> = { + const validatedOptions: ErrorTrackingOptions = config[CONFIG_KEY] || {}; + const toReturn: ToReturn<SourcemapsOptionsWithDefaults> = { errors: [], }; @@ -98,7 +98,7 @@ export const validateSourcemapsOptions = ( } // Add the defaults. - const sourcemapsWithDefaults: RumSourcemapsOptionsWithDefaults = { + const sourcemapsWithDefaults: SourcemapsOptionsWithDefaults = { bailOnError: false, dryRun: false, maxConcurrency: 20, diff --git a/packages/plugins/rum/tsconfig.json b/packages/plugins/error-tracking/tsconfig.json similarity index 100% rename from packages/plugins/rum/tsconfig.json rename to packages/plugins/error-tracking/tsconfig.json 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 <!-- #omit in toc --> -This is used to prepend some code to the produced bundle.<br/> -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.<br/> +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,<br/> -so you'll have to have submitted your injection prior to that.<br/> +so you'll have to "submit" your injection(s) prior to that.<br/> 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.<br/> @@ -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<string> => { + 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<string> => { let timeoutId: ReturnType<typeof setTimeout> | undefined; return Promise.race([ - doRequest<string>({ url: item.value }).finally(() => { + doRequest<string>({ url }).finally(() => { if (timeout) { clearTimeout(timeoutId); } @@ -30,32 +40,36 @@ export const processDistantFile = async ( ]); }; -export const processLocalFile = async (item: ToInjectItem): Promise<string> => { - const absolutePath = getAbsolutePath(process.cwd(), item.value); +export const processLocalFile = async ( + filepath: string, + cwd: string = process.cwd(), +): Promise<string> => { + const absolutePath = getAbsolutePath(cwd, filepath); return readFile(absolutePath, { encoding: 'utf-8' }); }; -export const processRawCode = async (item: ToInjectItem): Promise<string> => { - // TODO: Confirm the code actually executes without errors. - return item.value; -}; - -export const processItem = async (item: ToInjectItem, log: Logger): Promise<string> => { +export const processItem = async ( + item: ToInjectItem, + log: Logger, + cwd: string = process.cwd(), +): Promise<string> => { 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<stri }; export const processInjections = async ( - toInject: ToInjectItem[], + toInject: Map<string, ToInjectItem>, log: Logger, -): Promise<string[]> => { - const proms: (Promise<string> | string)[] = []; + cwd: string = process.cwd(), +): Promise<Map<string, { position: InjectPosition; value: string }>> => { + const toReturn: Map<string, { position: InjectPosition; value: string }> = 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<string, string>) => { + 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<string, ToInjectItem>, + 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<string, ToInjectItem>, 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<typeof ChunkGraph>; - 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<InjectPosition, Map<string, string>>; + +export type FileToInject = { + absolutePath: string; + filename: string; + toInject: Map<string, string>; +}; +export type FilesToInject = Record<InjectPosition, FileToInject>; diff --git a/packages/plugins/injection/src/xpack.ts b/packages/plugins/injection/src/xpack.ts new file mode 100644 index 000000000..e4b1c6071 --- /dev/null +++ b/packages/plugins/injection/src/xpack.ts @@ -0,0 +1,170 @@ +// 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, outputFileSync, 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<string, ToInjectItem>, + 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`, + ); + + // NOTE: 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') { + outputFileSync(filePath, ''); + } + + // 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; + }; + + // We need to prepare the injections before the build starts. + // Otherwise they'll be empty once resolved. + compiler.hooks.beforeRun.tapPromise(PLUGIN_NAME, async () => { + // 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); + } + }); + + // We inject the new entry. + const newEntry = injectEntry(compiler.options.entry); + compiler.options.entry = newEntry; + }; diff --git a/packages/plugins/telemetry/src/common/output/text.ts b/packages/plugins/telemetry/src/common/output/text.ts index 1be2299ec..fac1b8e78 100644 --- a/packages/plugins/telemetry/src/common/output/text.ts +++ b/packages/plugins/telemetry/src/common/output/text.ts @@ -2,9 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { formatDuration, truncateString } from '@dd/core/helpers'; +import { formatDuration, serializeBuildReport, truncateString } from '@dd/core/helpers'; import type { Logger, Entry, GlobalContext, Output } from '@dd/core/types'; -import { serializeBuildReport } from '@dd/internal-build-report-plugin/helpers'; import chalk from 'chalk'; import prettyBytes from 'pretty-bytes'; 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 b20d456d7..fd291184d 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@datadog/esbuild-plugin", "packageManager": "yarn@4.0.2", - "version": "2.4.2", + "version": "2.5.0", "license": "MIT", "author": "Datadog", "description": "Datadog ESBuild Plugin", @@ -20,7 +20,9 @@ }, "main": "./dist/src/index.js", "module": "./dist/src/index.mjs", + "types": "./dist/src/index.d.ts", "exports": { + "./dist/src": "./dist/src/index.js", ".": "./src/index.ts" }, "publishConfig": { @@ -63,8 +65,10 @@ "@dd/tools": "workspace:*", "@rollup/plugin-babel": "6.0.4", "@rollup/plugin-commonjs": "28.0.1", + "@rollup/plugin-esm-shim": "0.1.7", "@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 a02a95466..c0687c8b7 100644 --- a/packages/published/esbuild-plugin/src/index.ts +++ b/packages/published/esbuild-plugin/src/index.ts @@ -7,24 +7,29 @@ // will be updated using the 'yarn cli integrity' command. import type { Options } from '@dd/core/types'; +import type { + // #types-export-injection-marker + ErrorTrackingTypes, + 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 EsbuildPluginOptions = Options; - export type { // #types-export-injection-marker - RumTypes, + ErrorTrackingTypes, 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 401c917f1..9a88e5594 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@datadog/rollup-plugin", "packageManager": "yarn@4.0.2", - "version": "2.4.2", + "version": "2.5.0", "license": "MIT", "author": "Datadog", "description": "Datadog Rollup Plugin", @@ -20,7 +20,9 @@ }, "main": "./dist/src/index.js", "module": "./dist/src/index.mjs", + "types": "./dist/src/index.d.ts", "exports": { + "./dist/src": "./dist/src/index.js", ".": "./src/index.ts" }, "publishConfig": { @@ -63,8 +65,10 @@ "@dd/tools": "workspace:*", "@rollup/plugin-babel": "6.0.4", "@rollup/plugin-commonjs": "28.0.1", + "@rollup/plugin-esm-shim": "0.1.7", "@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 c0e9eaf86..a1d657111 100644 --- a/packages/published/rollup-plugin/src/index.ts +++ b/packages/published/rollup-plugin/src/index.ts @@ -7,24 +7,29 @@ // will be updated using the 'yarn cli integrity' command. import type { Options } from '@dd/core/types'; +import type { + // #types-export-injection-marker + ErrorTrackingTypes, + 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 RollupPluginOptions = Options; - export type { // #types-export-injection-marker - RumTypes, + ErrorTrackingTypes, 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 10f5c57a1..ad3fcf395 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@datadog/rspack-plugin", "packageManager": "yarn@4.0.2", - "version": "2.4.2", + "version": "2.5.0", "license": "MIT", "author": "Datadog", "description": "Datadog Rspack Plugin", @@ -20,7 +20,9 @@ }, "main": "./dist/src/index.js", "module": "./dist/src/index.mjs", + "types": "./dist/src/index.d.ts", "exports": { + "./dist/src": "./dist/src/index.js", ".": "./src/index.ts" }, "publishConfig": { @@ -63,8 +65,10 @@ "@dd/tools": "workspace:*", "@rollup/plugin-babel": "6.0.4", "@rollup/plugin-commonjs": "28.0.1", + "@rollup/plugin-esm-shim": "0.1.7", "@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 e5496f436..03ec3d2ac 100644 --- a/packages/published/rspack-plugin/src/index.ts +++ b/packages/published/rspack-plugin/src/index.ts @@ -7,24 +7,29 @@ // will be updated using the 'yarn cli integrity' command. import type { Options } from '@dd/core/types'; +import type { + // #types-export-injection-marker + ErrorTrackingTypes, + 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 RspackPluginOptions = Options; - export type { // #types-export-injection-marker - RumTypes, + ErrorTrackingTypes, 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 3ddd6e079..621fbca5a 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@datadog/vite-plugin", "packageManager": "yarn@4.0.2", - "version": "2.4.2", + "version": "2.5.0", "license": "MIT", "author": "Datadog", "description": "Datadog Vite Plugin", @@ -20,7 +20,9 @@ }, "main": "./dist/src/index.js", "module": "./dist/src/index.mjs", + "types": "./dist/src/index.d.ts", "exports": { + "./dist/src": "./dist/src/index.js", ".": "./src/index.ts" }, "publishConfig": { @@ -63,8 +65,10 @@ "@dd/tools": "workspace:*", "@rollup/plugin-babel": "6.0.4", "@rollup/plugin-commonjs": "28.0.1", + "@rollup/plugin-esm-shim": "0.1.7", "@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 684af94d7..64e64e863 100644 --- a/packages/published/vite-plugin/src/index.ts +++ b/packages/published/vite-plugin/src/index.ts @@ -7,24 +7,29 @@ // will be updated using the 'yarn cli integrity' command. import type { Options } from '@dd/core/types'; +import type { + // #types-export-injection-marker + ErrorTrackingTypes, + TelemetryTypes, + // #types-export-injection-marker +} from '@dd/factory'; import * as factory from '@dd/factory'; import * as vite from 'vite'; import pkg from '../package.json'; -export const datadogVitePlugin = factory.buildPluginFactory({ - bundler: vite, - version: pkg.version, -}).vite; - export type VitePluginOptions = Options; - export type { // #types-export-injection-marker - RumTypes, + ErrorTrackingTypes, 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 4f3cb664a..f4a189514 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@datadog/webpack-plugin", "packageManager": "yarn@4.0.2", - "version": "2.4.2", + "version": "2.5.0", "license": "MIT", "author": "Datadog", "description": "Datadog Webpack Plugin", @@ -20,7 +20,9 @@ }, "main": "./dist/src/index.js", "module": "./dist/src/index.mjs", + "types": "./dist/src/index.d.ts", "exports": { + "./dist/src/*": "./dist/src/*", ".": "./src/index.ts" }, "publishConfig": { @@ -63,8 +65,10 @@ "@dd/tools": "workspace:*", "@rollup/plugin-babel": "6.0.4", "@rollup/plugin-commonjs": "28.0.1", + "@rollup/plugin-esm-shim": "0.1.7", "@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 501d34afe..2e338f4bf 100644 --- a/packages/published/webpack-plugin/src/index.ts +++ b/packages/published/webpack-plugin/src/index.ts @@ -7,24 +7,29 @@ // will be updated using the 'yarn cli integrity' command. import type { Options } from '@dd/core/types'; +import type { + // #types-export-injection-marker + ErrorTrackingTypes, + 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 WebpackPluginOptions = Options; - export type { // #types-export-injection-marker - RumTypes, + ErrorTrackingTypes, 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/.gitignore b/packages/tests/.gitignore new file mode 100644 index 000000000..584439da2 --- /dev/null +++ b/packages/tests/.gitignore @@ -0,0 +1,5 @@ +# Playwright stuffs +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/packages/tests/README.md b/packages/tests/README.md index 46c15d778..59d81e2c2 100644 --- a/packages/tests/README.md +++ b/packages/tests/README.md @@ -10,41 +10,39 @@ Especially useful for having mock projects, built with specific bundlers and run <!-- This is auto generated with yarn cli integrity --> <!-- #toc --> -- [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) - - [Bundlers](#bundlers) - - [More complex projects](#more-complex-projects) - - [Work with the global context](#work-with-the-global-context) +- [Unit tests](#unit-tests) + - [Run](#run) + - [Debug](#debug) + - [Test a plugin](#test-a-plugin) +- [End to End tests](#end-to-end-tests) + - [Run](#run) + - [Debug](#debug) <!-- #toc --> -## Run all the tests +## Unit tests -```bash -yarn test -``` - -## Run all the tests with all the logs +Place your tests in `packages/tests/src/unit/plugins/<your plugin>/**/*.test.ts`.<br/> -By default, jest is in silent mode and won't show any logs. +### Run ```bash -yarn test:noisy +yarn test:unit +yarn workspace @dd/tests test ``` -## Debug a test +You can use [jest flags](https://jestjs.io/docs/cli) directly after the command. + +### Debug 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:unit packages/tests/... ``` -## Test a plugin +### Test a plugin Once you have your plugin ready, you can test it in two ways, both are not exclusive. @@ -54,7 +52,7 @@ This doesn't need much explanation as it is pretty straight-forward. Or the **integration** way, which will test the plugin within the whole ecosystem, but is a bit more involved to setup correctly.<br/> Let's talk about this a bit more. -### Bootstrapping your test +#### Bootstrapping your test Here's a bootstrap to get you going: @@ -86,34 +84,35 @@ describe('My very awesome plugin', () => { }); ``` -### Bundlers +#### Bundlers -We currently support `webpack4`, `webpack5`, `esbuild`, `rollup` and `vite`.<br/> +We currently support `webpack4`, `webpack5`, `rspack`, `esbuild`, `rollup` and `vite`.<br/> 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).<br/> +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).<br/> 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.<br/> For this, you can use the `--bundlers=<name>,<name>` flag when running your tests: ```bash -yarn test:noisy packages/tests/... --bundlers=webpack4,esbuild +yarn test:unit 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:unit 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 +# Will also build both webpack and esbuild plugins before running the tests. +yarn test:unit packages/tests/... --build=1 --bundlers=webpack4,esbuild ``` -### More complex projects +#### More complex projects We also have [a more complex project](/packages/tests/src/_jest/fixtures/project), with third parties dependencies for instance, that you can use with the `getComplexBuildOverrides()` function.<br/> To be used as follow: @@ -133,7 +132,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 +152,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); @@ -174,12 +168,12 @@ describe('Some very massive project', () => { > `generateProject()` is not persistent. > So for now it's only to be used to debug your plugin when necessary. -### Work with the global context +#### Work with the global context The global context is pretty nifty to share data between plugins.<br/> But, it is a mutable object, so you'll have to keep that in mind when testing around it. -The best way would be to freeze the content you need to test, at the moment you want to test it: +The best way would be to freeze the content you need to test, at the moment you want to test it, for instance, to capture the initial context using `JSON.parse(JSON.stringify(context.bundler))` to freeze it: ```typescript import type { GlobalContext, Options } from '@dd/core/types'; @@ -198,7 +192,7 @@ describe('Global Context Plugin', () => { customPlugins: (opts, context) => { const bundlerName = context.bundler.fullName; // Freeze the context here, to verify what's available during initialization. - initialContexts[bundlerName] = JSON.parse(JSON.stringify(context)); + initialContexts[bundlerName] = JSON.parse(JSON.stringify(context.bundler)); return []; }, }; @@ -233,10 +227,7 @@ buildReports[bundlerName] = unserializeBuildReport(serializeBuildReport(context. Giving the following, more involved example: ```typescript -import { - serializeBuildReport, - unserializeBuildReport, -} from '@dd/core/plugins/build-report/helpers'; +import { serializeBuildReport, unserializeBuildReport } from '@dd/core/helpers'; import type { BuildReport, Options } from '@dd/core/types'; import { defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks'; import type { CleanupFn } from '@dd/tests/_jest/helpers/runBundlers'; @@ -276,3 +267,66 @@ describe('Build Reports', () => { }); }); ``` + +## End to End tests + +We use [Playwright](https://playwright.dev/) for our end to end tests. + +Place your tests in `packages/tests/src/e2e/**/*.spec.ts`. + +The test run takes care of building the `@datadog/*-plugin` packages locally.<br/> +You can bypass this build step prefixing your command with `CI=1 yarn [...]` reducing the duration of the run. + +### Run + +```bash +yarn test:e2e +``` + +You can use [Playwright flags](https://playwright.dev/docs/running-tests#command-line) directly after the command. + +### Debug + +#### From the CI + +If your CI job fails, you can download the `playwright` artifact of the run, at the bottom of the summary page. + +Once downloaded, extract it by double clicking on it and run the following command: + +```bash +yarn workspace @dd/tests playwright show-report ~/Downloads/playwright/playwright-report +``` + +#### Locally + +Run the test with the UI enabled: + +```bash +yarn test:e2e --ui +``` + +Then, you can use the Playwright UI to debug your test. + +More information on the [Playwright documentation](https://playwright.dev/docs/running-tests#command-line). + + +#### Run a specific bundler or browser + +There is one project for each bundler / browser combination.<br/> +The naming follows the pattern `<browser> | <bundler>` eg. `chrome | webpack4`. + +You can use the `--project` flag to target a specific project (or multiple projects): + +```bash +yarn test:e2e --project "chrome | webpack4" --project "firefox | esbuild" +``` + +It also supports glob patterns: + +```bash +# Run all the bundlers for the chrome browser. +yarn test:e2e --project "chrome | *" + +# Run all browsers for the webpack4 bundler. +yarn test:e2e --project "* | webpack4" +``` diff --git a/packages/tests/jest.config.js b/packages/tests/jest.config.js index 57fd27844..796986f99 100644 --- a/packages/tests/jest.config.js +++ b/packages/tests/jest.config.js @@ -7,10 +7,9 @@ module.exports = { clearMocks: true, globalSetup: '<rootDir>/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/'], + roots: ['./src/unit/'], setupFilesAfterEnv: ['<rootDir>/src/_jest/setupAfterEnv.ts'], testEnvironment: 'node', testMatch: ['**/*.test.*'], diff --git a/packages/tests/package.json b/packages/tests/package.json index 422c50efd..c757b4208 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -14,28 +14,29 @@ "exports": { "./*": "./src/*.ts", "./_jest/fixtures/*": "./src/_jest/fixtures/*", - "./_jest/helpers/*": "./src/_jest/helpers/*.ts", - "./plugins/telemetry/*": "./src/plugins/telemetry/*.ts" + "./_jest/helpers/*": "./src/_jest/helpers/*.ts" }, "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:e2e": "FORCE_COLOR=true playwright test", + "test:unit": "FORCE_COLOR=true 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:*", + "@dd/tools": "workspace:*", "@rollup/plugin-commonjs": "28.0.1", "clipanion": "4.0.0-rc.3", "glob": "11.0.0", @@ -43,6 +44,7 @@ "ts-jest": "29.1.2" }, "devDependencies": { + "@playwright/test": "1.49.1", "@rspack/core": "1.1.2", "@types/faker": "5.5.9", "@types/jest": "29.5.12", diff --git a/packages/tests/playwright.config.ts b/packages/tests/playwright.config.ts new file mode 100644 index 000000000..4251ae213 --- /dev/null +++ b/packages/tests/playwright.config.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 { FULL_NAME_BUNDLERS } from '@dd/core/constants'; +import { DEV_SERVER_PORT, DEV_SERVER_URL, PUBLIC_DIR } from '@dd/tests/_playwright/constants'; +import { getRequestedBundlers } from '@dd/tests/_playwright/helpers/requestedBundlers'; +import type { TestOptions } from '@dd/tests/_playwright/testParams'; +import { ROOT } from '@dd/tools/constants'; +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig<TestOptions>({ + testDir: './src/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? 'html' : 'list', + /* Path to file run before all the tests. See https://playwright.dev/docs/test-global-setup-teardown */ + globalSetup: require.resolve('./src/_playwright/globalSetup.ts'), + use: { + bundlers: getRequestedBundlers(), + trace: 'retain-on-failure', + }, + timeout: 5_000, + /* Configure projects for each bundler */ + // TODO Also build and test for ESM. + projects: FULL_NAME_BUNDLERS.map((bundler) => [ + { + name: `chrome | ${bundler}`, + use: { + ...devices['Desktop Chrome'], + bundler, + }, + }, + { + name: `firefox | ${bundler}`, + use: { + ...devices['Desktop Firefox'], + bundler, + }, + }, + { + name: `edge | ${bundler}`, + use: { + ...devices['Desktop Edge'], + bundler, + }, + }, + { + name: `safari | ${bundler}`, + use: { + ...devices['Desktop Safari'], + bundler, + }, + }, + ]).flat(), + + /* Run your local dev server before starting the tests */ + webServer: { + command: `yarn cli dev-server --root=${PUBLIC_DIR} --port=${DEV_SERVER_PORT}`, + cwd: ROOT, + url: DEV_SERVER_URL, + reuseExistingServer: !process.env.CI, + }, +}); 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/fake-file-to-inject-after.js b/packages/tests/src/_jest/fixtures/fake-file-to-inject-after.js new file mode 100644 index 000000000..2645ec5a0 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/fake-file-to-inject-after.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 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..31d65d197 100644 --- a/packages/tests/src/_jest/helpers/configBundlers.ts +++ b/packages/tests/src/_jest/helpers/configBundlers.ts @@ -7,43 +7,56 @@ import { datadogRollupPlugin } from '@datadog/rollup-plugin'; import { datadogRspackPlugin } from '@datadog/rspack-plugin'; import { datadogVitePlugin } from '@datadog/vite-plugin'; import type { Options } from '@dd/core/types'; -import commonjs from '@rollup/plugin-commonjs'; -import { nodeResolve } from '@rollup/plugin-node-resolve'; +import { + configEsbuild, + configRollup, + configRspack, + configVite, + configWebpack4, + configWebpack5, +} from '@dd/tools/bundlers'; import type { RspackOptions } from '@rspack/core'; import type { BuildOptions } from 'esbuild'; import path from 'path'; import type { RollupOptions } from 'rollup'; import type { UserConfig } from 'vite'; -import type { Configuration as Configuration4, Plugin } from 'webpack4'; +import type { Configuration as Configuration4 } from 'webpack4'; 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 { getWebpackPlugin } from './getWebpackPlugin'; +import { defaultEntry, defaultPluginOptions } from './mocks'; +import type { BundlerOptionsOverrides } from './types'; export const getRspackOptions = ( - seed: string, + workingDir: string, pluginOverrides: Partial<Options> = {}, - bundlerOverrides: BundlerOverrides['rspack'] = {}, + bundlerOverrides: BundlerOptionsOverrides['rspack'] = {}, ): RspackOptions => { const newPluginOptions = { ...defaultPluginOptions, ...pluginOverrides, }; + const plugin = datadogRspackPlugin(newPluginOptions); + return { - ...(getBaseXpackConfig(seed, 'rspack') as RspackOptions), - plugins: [datadogRspackPlugin(newPluginOptions)], + ...configRspack({ + workingDir, + entry: { main: path.resolve(workingDir, defaultEntry) }, + outDir: getOutDir(workingDir, 'rspack'), + plugins: [plugin], + }), ...bundlerOverrides, }; }; export const getWebpack5Options = ( - seed: string, + workingDir: string, pluginOverrides: Partial<Options> = {}, - bundlerOverrides: BundlerOverrides['webpack5'] = {}, + bundlerOverrides: BundlerOptionsOverrides['webpack5'] = {}, ): Configuration => { const newPluginOptions = { ...defaultPluginOptions, @@ -53,16 +66,20 @@ export const getWebpack5Options = ( const plugin = getWebpackPlugin(newPluginOptions, webpack5); return { - ...getBaseXpackConfig(seed, 'webpack5'), - plugins: [plugin], + ...configWebpack5({ + workingDir, + entry: { main: path.resolve(workingDir, defaultEntry) }, + outDir: getOutDir(workingDir, 'webpack5'), + plugins: [plugin], + }), ...bundlerOverrides, }; }; export const getWebpack4Options = ( - seed: string, + workingDir: string, pluginOverrides: Partial<Options> = {}, - bundlerOverrides: BundlerOverrides['webpack4'] = {}, + bundlerOverrides: BundlerOptionsOverrides['webpack4'] = {}, ): Configuration4 => { const newPluginOptions = { ...defaultPluginOptions, @@ -72,18 +89,21 @@ export const getWebpack4Options = ( const plugin = getWebpackPlugin(newPluginOptions, webpack4); return { - ...getBaseXpackConfig(seed, 'webpack4'), - entry: getWebpack4Entries(defaultEntry), - plugins: [plugin as unknown as Plugin], + ...configWebpack4({ + workingDir, + entry: { main: path.resolve(workingDir, defaultEntry) }, + outDir: getOutDir(workingDir, 'webpack4'), + plugins: [plugin], + }), node: false, ...bundlerOverrides, }; }; export const getEsbuildOptions = ( - seed: string, + workingDir: string, pluginOverrides: Partial<Options> = {}, - bundlerOverrides: BundlerOverrides['esbuild'] = {}, + bundlerOverrides: BundlerOptionsOverrides['esbuild'] = {}, ): BuildOptions => { const newPluginOptions = { ...defaultPluginOptions, @@ -91,59 +111,37 @@ export const getEsbuildOptions = ( }; return { - bundle: true, - chunkNames: 'chunk.[hash]', - entryPoints: { main: defaultEntry }, - entryNames: '[name]', + ...configEsbuild({ + workingDir, + entry: { main: defaultEntry }, + outDir: getOutDir(workingDir, 'esbuild'), + plugins: [datadogEsbuildPlugin(newPluginOptions)], + }), format: 'esm', - outdir: path.join(defaultDestination, seed, 'esbuild'), - plugins: [datadogEsbuildPlugin(newPluginOptions)], - sourcemap: true, splitting: true, ...bundlerOverrides, }; }; -export const getRollupBaseConfig = (seed: string, bundlerName: string): RollupOptions => { - return { - input: defaultEntry, - onwarn: (warning, handler) => { - if ( - !/Circular dependency:/.test(warning.message) && - !/Sourcemap is likely to be incorrect/.test(warning.message) - ) { - return handler(warning); - } - }, - output: { - chunkFileNames: 'chunk.[hash].js', - compact: false, - dir: path.join(defaultDestination, seed, bundlerName), - entryFileNames: '[name].js', - sourcemap: true, - }, - }; -}; - export const getRollupOptions = ( - seed: string, + workingDir: string, pluginOverrides: Partial<Options> = {}, - bundlerOverrides: BundlerOverrides['rollup'] = {}, + bundlerOverrides: BundlerOptionsOverrides['rollup'] = {}, ): RollupOptions => { const newPluginOptions = { ...defaultPluginOptions, ...pluginOverrides, }; - const baseConfig = getRollupBaseConfig(seed, 'rollup'); + const baseConfig = configRollup({ + workingDir, + entry: { main: defaultEntry }, + outDir: getOutDir(workingDir, 'rollup'), + plugins: [datadogRollupPlugin(newPluginOptions)], + }); return { ...baseConfig, - plugins: [ - commonjs(), - datadogRollupPlugin(newPluginOptions), - nodeResolve({ preferBuiltins: true, browser: true }), - ], ...bundlerOverrides, output: { ...baseConfig.output, @@ -153,31 +151,35 @@ export const getRollupOptions = ( }; export const getViteOptions = ( - seed: string, + workingDir: string, pluginOverrides: Partial<Options> = {}, - bundlerOverrides: BundlerOverrides['vite'] = {}, + bundlerOverrides: BundlerOptionsOverrides['vite'] = {}, ): UserConfig => { const newPluginOptions = { ...defaultPluginOptions, ...pluginOverrides, }; - const baseConfig = getRollupBaseConfig(seed, 'vite'); + const baseConfig = configVite({ + workingDir, + entry: { main: defaultEntry }, + outDir: getOutDir(workingDir, 'vite'), + plugins: [datadogVitePlugin(newPluginOptions)], + }); return { + root: workingDir, + ...baseConfig, build: { - assetsDir: '', // Disable assets dir to simplify the test. - minify: false, + ...baseConfig.build, rollupOptions: { - ...baseConfig, + ...baseConfig.build?.rollupOptions, ...bundlerOverrides, output: { - ...baseConfig.output, + ...baseConfig.build?.rollupOptions?.output, ...bundlerOverrides.output, }, }, }, - logLevel: 'silent', - plugins: [datadogVitePlugin(newPluginOptions)], }; }; 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<BundlerFullName, string> = { 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..6f87a3c9b --- /dev/null +++ b/packages/tests/src/_jest/helpers/env.ts @@ -0,0 +1,133 @@ +// 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), + ).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/getWebpackPlugin.ts b/packages/tests/src/_jest/helpers/getWebpackPlugin.ts new file mode 100644 index 000000000..e54cca53e --- /dev/null +++ b/packages/tests/src/_jest/helpers/getWebpackPlugin.ts @@ -0,0 +1,22 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { Options } from '@dd/core/types'; +import { buildPluginFactory } from '@dd/factory'; +import type webpack4 from 'webpack4'; +import type webpack5 from 'webpack5'; + +import { PLUGIN_VERSIONS } from './constants'; + +// Return the correct plugin for webpack 4 or 5. +export const getWebpackPlugin = ( + pluginOptions: Options, + bundler: typeof webpack4 | typeof webpack5, +) => { + // Need to use the factory directly since we pass the bundler to the factory. + return buildPluginFactory({ + bundler, + version: PLUGIN_VERSIONS.webpack, + }).webpack(pluginOptions); +}; diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index fb71e71e8..520ff73e9 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -2,46 +2,37 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { outputJsonSync } from '@dd/core/helpers'; import type { + BuildReport, File, - GetCustomPlugins, GetPluginsOptions, GlobalContext, - IterableElement, Logger, LogLevel, Options, } from '@dd/core/types'; -import { serializeBuildReport } from '@dd/internal-build-report-plugin/helpers'; -import { getSourcemapsConfiguration } from '@dd/tests/plugins/rum/testHelpers'; -import { getTelemetryConfiguration } from '@dd/tests/plugins/telemetry/testHelpers'; +import { getAbsolutePath } from '@dd/internal-build-report-plugin/helpers'; +import { getSourcemapsConfiguration } from '@dd/tests/unit/plugins/error-tracking/testHelpers'; +import { getTelemetryConfiguration } from '@dd/tests/unit/plugins/telemetry/testHelpers'; +import { configXpack } from '@dd/tools/bundlers'; +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'; 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,78 +55,139 @@ const logFn: Logger = { }; export const mockLogger: Logger = logFn; -export const getContextMock = (options: Partial<GlobalContext> = {}): GlobalContext => { +export const getEsbuildMock = (overrides: Partial<PluginBuild> = {}): 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(), + ...overrides, + 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', + ...(overrides.esbuild || {}), + }, + initialOptions: { + ...(overrides.initialOptions || {}), + }, + }; +}; + +export const getMockBuild = (overrides: Partial<BuildReport> = {}): BuildReport => ({ + errors: [], + warnings: [], + logs: [], + ...overrides, + bundler: { + name: 'esbuild', + fullName: 'esbuild', + version: 'FAKE_VERSION', + ...(overrides.bundler || {}), + }, +}); + +export const getContextMock = (overrides: Partial<GlobalContext> = {}): 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: [], start: Date.now(), version: 'FAKE_VERSION', - ...options, + ...overrides, }; }; -export const getComplexBuildOverrides = ( - overrides: BundlerOverrides = {}, -): Required<BundlerOverrides> => { - 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<BundlerOverrides> => { + const overridesResolved = + typeof overrides === 'function' ? overrides(workingDir) : overrides || {}; + + // 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), + ]), + ); - return bundlerOverrides; -}; + 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<BundlerOverrides> => { + workingDir: string, + overrides?: BundlerOverrides, +): Required<BundlerOptionsOverrides> => { + 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<BundlerOverrides> = { + const baseWebpack = configXpack({ workingDir: 'fake_cwd', outDir: 'dist', entry: {} }); + const bundlerOverrides: Required<BundlerOptionsOverrides> = { 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 +195,7 @@ export const getNodeSafeBuildOverrides = ( ...baseWebpack.optimization, splitChunks: false, }, - ...overrides.rspack, + ...overridesResolved.rspack, }, webpack5: { target: 'node', @@ -151,15 +203,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,7 +222,7 @@ export const getNodeSafeBuildOverrides = ( export const getFullPluginConfig = (overrides: Partial<Options> = {}): Options => { return { ...defaultPluginOptions, - rum: { + errorTracking: { sourcemaps: getSourcemapsConfiguration(), }, telemetry: getTelemetryConfiguration(), @@ -189,82 +241,6 @@ export const getMirroredFixtures = (paths: string[], cwd: string) => { return fixtures; }; -// Returns a customPlugin to output some debug files. -type CustomPlugins = ReturnType<GetCustomPlugins>; -export const debugFilesPlugins = (context: GlobalContext): CustomPlugins => { - const rollupPlugin: IterableElement<CustomPlugins>['rollup'] = { - writeBundle(options, bundle) { - outputJsonSync( - path.resolve(context.bundler.outDir, `output.${context.bundler.fullName}.json`), - bundle, - ); - }, - }; - - const xpackPlugin: IterableElement<CustomPlugins>['webpack'] & - IterableElement<CustomPlugins>['rspack'] = (compiler) => { - type Compilation = Parameters<Parameters<typeof compiler.hooks.afterEmit.tap>[1]>[0]; - - compiler.hooks.afterEmit.tap('bundler-outputs', (compilation: Compilation) => { - const stats = compilation.getStats().toJson({ - all: false, - assets: true, - children: true, - chunks: true, - chunkGroupAuxiliary: true, - chunkGroupChildren: true, - chunkGroups: true, - chunkModules: true, - chunkRelations: true, - entrypoints: true, - errors: true, - ids: true, - modules: true, - nestedModules: true, - reasons: true, - relatedAssets: true, - warnings: true, - }); - outputJsonSync( - path.resolve(context.bundler.outDir, `output.${context.bundler.fullName}.json`), - stats, - ); - }); - }; - - return [ - { - name: 'build-report', - writeBundle() { - outputJsonSync( - path.resolve(context.bundler.outDir, `report.${context.bundler.fullName}.json`), - serializeBuildReport(context.build), - ); - }, - }, - { - name: 'bundler-outputs', - esbuild: { - setup(build) { - build.onEnd((result) => { - outputJsonSync( - path.resolve( - context.bundler.outDir, - `output.${context.bundler.fullName}.json`, - ), - result.metafile, - ); - }); - }, - }, - rspack: xpackPlugin, - rollup: rollupPlugin, - vite: rollupPlugin, - webpack: xpackPlugin, - }, - ]; -}; - // Filter out stuff from the build report. export const filterOutParticularities = (input: File) => // Vite injects its own preloader helper. @@ -274,4 +250,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..0d74a6958 100644 --- a/packages/tests/src/_jest/helpers/runBundlers.ts +++ b/packages/tests/src/_jest/helpers/runBundlers.ts @@ -2,15 +2,22 @@ // 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 { + buildWithEsbuild, + buildWithRollup, + buildWithRspack, + buildWithVite, + buildWithWebpack4, + buildWithWebpack5, +} from '@dd/tools/bundlers'; +import { buildPlugins, green } from '@dd/tools/helpers'; +import type { RspackOptions } 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'; +import type { Configuration as Configuration4 } from 'webpack4'; +import type { Configuration } from 'webpack5'; import { getEsbuildOptions, @@ -20,45 +27,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'; - -const xpackCallback = ( - err: Error | null, - stats: Stats4 | Stats | RspackStats | undefined, - resolve: (value: unknown) => void, - reject: (reason?: any) => void, - delay: number = 0, -) => { - if (err) { - reject(err); - return; - } - - if (!stats) { - reject('No stats returned from webpack.'); - return; - } - - const { errors, warnings } = stats.compilation; - if (errors?.length) { - reject(errors[0]); - return; - } - - if (warnings?.length) { - console.warn(warnings.join('\n')); - } - - // Delay the resolve to give time to the bundler to finish writing the files. - // Webpack4 in particular is impacted by this and otherwise triggers a - // "Jest did not exit one second after the test run has completed." warning. - // TODO: Investigate this need for a delay after webpack 4's build. - setTimeout(() => { - resolve(stats); - }, delay); -}; +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 getCleanupFunction = (bundlerName: string, outdirs: (string | undefined)[]): CleanupFn => @@ -82,107 +62,52 @@ const getCleanupFunction = }; export const runRspack: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial<RspackOptions> = {}, ) => { - const bundlerConfigs = getRspackOptions(seed, pluginOverrides, bundlerOverrides); - const { rspack } = await import('@rspack/core'); - const errors = []; - - try { - await new Promise((resolve, reject) => { - rspack(bundlerConfigs, (err, stats) => { - xpackCallback(err, stats, resolve, reject); - }); - }); - } catch (e: any) { - console.error(`Build failed for Rspack`, e); - errors.push(`[RSPACK] : ${e.message}`); - } - + const bundlerConfigs = getRspackOptions(workingDir, pluginOverrides, bundlerOverrides); + const { errors } = await buildWithRspack(bundlerConfigs); return { cleanup: getCleanupFunction('Rspack', [bundlerConfigs.output?.path]), errors }; }; export const runWebpack5: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial<Configuration> = {}, ) => { - const bundlerConfigs = getWebpack5Options(seed, pluginOverrides, bundlerOverrides); - const { webpack } = await import('webpack5'); - const errors = []; - - try { - await new Promise((resolve, reject) => { - webpack(bundlerConfigs, (err, stats) => { - xpackCallback(err, stats, resolve, reject); - }); - }); - } catch (e: any) { - console.error(`Build failed for Webpack 5`, e); - errors.push(`[WEBPACK5] : ${e.message}`); - } - + const bundlerConfigs = getWebpack5Options(workingDir, pluginOverrides, bundlerOverrides); + const { errors } = await buildWithWebpack5(bundlerConfigs); return { cleanup: getCleanupFunction('Webpack 5', [bundlerConfigs.output?.path]), errors }; }; export const runWebpack4: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial<Configuration4> = {}, ) => { - const bundlerConfigs = getWebpack4Options(seed, pluginOverrides, bundlerOverrides); - const webpack = (await import('webpack4')).default; - const errors = []; - - try { - await new Promise((resolve, reject) => { - webpack(bundlerConfigs, (err, stats) => { - xpackCallback(err, stats, resolve, reject, 600); - }); - }); - } catch (e: any) { - console.error(`Build failed for Webpack 4`, e); - errors.push(`[WEBPACK4] : ${e.message}`); - } - + const bundlerConfigs = getWebpack4Options(workingDir, pluginOverrides, bundlerOverrides); + const { errors } = await buildWithWebpack4(bundlerConfigs); return { cleanup: getCleanupFunction('Webpack 4', [bundlerConfigs.output?.path]), errors }; }; export const runEsbuild: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial<BuildOptions> = {}, ) => { - const bundlerConfigs = getEsbuildOptions(seed, pluginOverrides, bundlerOverrides); - const { build } = await import('esbuild'); - const errors = []; - - try { - await build(bundlerConfigs); - } catch (e: any) { - console.error(`Build failed for ESBuild`, e); - errors.push(`[ESBUILD] : ${e.message}`); - } - + const bundlerConfigs = getEsbuildOptions(workingDir, pluginOverrides, bundlerOverrides); + const { errors } = await buildWithEsbuild(bundlerConfigs); return { cleanup: getCleanupFunction('ESBuild', [bundlerConfigs.outdir]), errors }; }; export const runVite: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial<RollupOptions> = {}, ) => { - const bundlerConfigs = getViteOptions(seed, pluginOverrides, bundlerOverrides); - const vite = await import('vite'); - const errors = []; - try { - await vite.build(bundlerConfigs); - } catch (e: any) { - console.error(`Build failed for Vite`, e); - errors.push(`[VITE] : ${e.message}`); - } + const bundlerConfigs = getViteOptions(workingDir, pluginOverrides, bundlerOverrides); + const { errors } = await buildWithVite(bundlerConfigs); const outdirs: (string | undefined)[] = []; if (Array.isArray(bundlerConfigs.build?.rollupOptions?.output)) { @@ -195,33 +120,12 @@ export const runVite: BundlerRunFunction = async ( }; export const runRollup: BundlerRunFunction = async ( - seed: string, + workingDir: string, pluginOverrides: Options = {}, bundlerOverrides: Partial<RollupOptions> = {}, ) => { - const bundlerConfigs = getRollupOptions(seed, pluginOverrides, bundlerOverrides); - const { rollup } = await import('rollup'); - const errors = []; - - try { - const result = await rollup(bundlerConfigs); - - // Write out the results. - if (bundlerConfigs.output) { - const outputProms = []; - const outputOptions = Array.isArray(bundlerConfigs.output) - ? bundlerConfigs.output - : [bundlerConfigs.output]; - for (const outputOption of outputOptions) { - outputProms.push(result.write(outputOption)); - } - - await Promise.all(outputProms); - } - } catch (e: any) { - console.error(`Build failed for Rollup`, e); - errors.push(`[ROLLUP] : ${e.message}`); - } + const bundlerConfigs = getRollupOptions(workingDir, pluginOverrides, bundlerOverrides); + const { errors } = await buildWithRollup(bundlerConfigs); const outdirs: (string | undefined)[] = []; if (Array.isArray(bundlerConfigs.output)) { @@ -272,47 +176,47 @@ 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. if (NEED_BUILD) { - const bundlersToBuild = new Set( - BUNDLERS.map((bundler) => `@datadog/${bundler.name.replace(/\d/g, '')}-plugin`), - ); - - for (const bundler of bundlersToBuild) { - console.log(`Building ${green(bundler)}...`); - executeSync('yarn', ['workspace', bundler, 'run', 'build']); - } + const bundlersToBuild = BUNDLERS.map(({ name }) => name); + console.log(`[BUILD] Building ${green(bundlersToBuild.join(', '))}...`); + buildPlugins(bundlersToBuild); + console.log(`[BUILD] Done.`); } export const runBundlers = async ( pluginOverrides: Partial<Options> = {}, - bundlerOverrides: Record<string, any> = {}, + bundlerOverrides?: BundlerOverrides, bundlers?: string[], -): Promise<CleanupFn> => { - const cleanups: CleanupFn[] = []; +): Promise<CleanupEverythingFn> => { 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<ReturnType<BundlerRunFunction>>; // 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 +227,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<RollupOptions>; vite?: Partial<RollupOptions>; esbuild?: Partial<BuildOptions>; @@ -18,6 +18,10 @@ export type BundlerOverrides = { webpack4?: Partial<Configuration4>; }; +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<void>; +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 deleted file mode 100644 index d6e16efee..000000000 --- a/packages/tests/src/_jest/helpers/xpackConfigs.ts +++ /dev/null @@ -1,84 +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 { getResolvedPath } from '@dd/core/helpers'; -import type { Options } from '@dd/core/types'; -import { buildPluginFactory } from '@dd/factory'; -import type { RspackOptions } from '@rspack/core'; -import path from 'path'; -import type webpack4 from 'webpack4'; -import type { Configuration as Configuration4 } from 'webpack4'; -import type { Configuration as Configuration5 } from 'webpack5'; -import type webpack5 from 'webpack5'; - -import { PLUGIN_VERSIONS } from './constants'; -import { defaultDestination, defaultEntry } from './mocks'; - -export const getBaseXpackConfig = ( - seed: string, - bundlerName: string, -): Configuration5 & Configuration4 & RspackOptions => { - return { - entry: defaultEntry, - mode: 'production', - output: { - path: path.join(defaultDestination, seed, bundlerName), - 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`; - }, - }, - }, - }; -}; - -// Return the correct plugin for webpack 4 or 5. -export const getWebpackPlugin = ( - pluginOptions: Options, - bundler: typeof webpack4 | typeof webpack5, -) => { - // Need to use the factory directly since we pass the bundler to the factory. - return buildPluginFactory({ - bundler, - version: PLUGIN_VERSIONS.webpack, - }).webpack(pluginOptions); -}; - -// Webpack 4 doesn't support pnp resolution OOTB. -export const getWebpack4Entries = ( - entries: NonNullable<Configuration5['entry']>, - 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<R> { 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/_playwright/constants.ts b/packages/tests/src/_playwright/constants.ts new file mode 100644 index 000000000..d8018dbf0 --- /dev/null +++ b/packages/tests/src/_playwright/constants.ts @@ -0,0 +1,10 @@ +// 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 { ROOT } from '@dd/tools/constants'; +import path from 'path'; + +export const PUBLIC_DIR = path.resolve(ROOT, 'packages/tests/src/_playwright/public'); +export const DEV_SERVER_PORT = 8000; +export const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; diff --git a/packages/tests/src/_playwright/globalSetup.ts b/packages/tests/src/_playwright/globalSetup.ts new file mode 100644 index 000000000..ed4bdb914 --- /dev/null +++ b/packages/tests/src/_playwright/globalSetup.ts @@ -0,0 +1,47 @@ +// 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 { ENV_VAR_REQUESTED_BUNDLERS } from '@dd/core/constants'; +import { rm } from '@dd/core/helpers'; +import type { BundlerFullName } from '@dd/core/types'; +import { getRequestedBundlers } from '@dd/tests/_playwright/helpers/requestedBundlers'; +import type { TestOptions } from '@dd/tests/_playwright/testParams'; +import { blue, buildPlugins, dim, green } from '@dd/tools/helpers'; +import type { FullConfig } from '@playwright/test'; +import { glob } from 'glob'; +import path from 'path'; + +// TODO Also build and test for ESM. +const globalSetup = async (config: FullConfig<TestOptions>) => { + const getPfx = (name: string) => `[${blue(name)}] `; + const getSubPfx = (name: string) => ` ${dim(getPfx(name))}`; + const globalPfx = getPfx('Global Setup'); + console.time(globalPfx); + const requestedBundlers = getRequestedBundlers(); + // Save the requested bundlers in the env. + process.env[ENV_VAR_REQUESTED_BUNDLERS] = requestedBundlers.join(','); + console.log(`${globalPfx}Setting up tests.`); + + // In the CI we're building before the job starts. + // No need to do it here. + if (!process.env.CI) { + // Build the requested bundler plugins. + const buildPluginsPfx = getSubPfx('Build Plugins'); + console.time(buildPluginsPfx); + console.log(`${buildPluginsPfx}Building ${green(requestedBundlers.join(', '))} plugins...`); + buildPlugins(requestedBundlers as BundlerFullName[]); + console.timeEnd(buildPluginsPfx); + } + + // Delete public dirs. + const cleanPfx = getSubPfx('Clean'); + console.time(cleanPfx); + const publicDirs = await glob('public/*/', { cwd: __dirname }); + await Promise.all(publicDirs.map((dir) => rm(path.resolve(__dirname, dir)))); + console.timeEnd(cleanPfx); + + console.timeEnd(globalPfx); +}; + +export default globalSetup; diff --git a/packages/tests/src/_playwright/helpers/buildProject.ts b/packages/tests/src/_playwright/helpers/buildProject.ts new file mode 100644 index 000000000..32a2a5845 --- /dev/null +++ b/packages/tests/src/_playwright/helpers/buildProject.ts @@ -0,0 +1,128 @@ +// 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 { mkdir, rm } from '@dd/core/helpers'; +import type { BundlerFullName } from '@dd/core/types'; +import { allBundlers } from '@dd/tools/bundlers'; +import { dim } from '@dd/tools/helpers'; +import { allPlugins, fullConfig } from '@dd/tools/plugins'; +import fs from 'fs'; +import path from 'path'; + +// Build a given project with a given bundler. +const buildProject = async (bundler: BundlerFullName, cwd: string) => { + const plugin = allPlugins[bundler](fullConfig); + const build = allBundlers[bundler]; + const buildConfig = build.config({ + workingDir: cwd, + // We'll use the name of the bundler as the name of the entry file. + // eg: For "Webpack 4" we'll have "./projects/dist/webpack4.js". + entry: { [bundler]: './index.js' }, + outDir: path.resolve(cwd, './dist'), + plugins: [plugin], + }); + + return build.run(buildConfig); +}; + +// Build a given project with a list of bundlers. +const buildProjectWithBundlers = async (projectPath: string, bundlers: BundlerFullName[]) => { + const name = projectPath.split(path.sep).pop() || 'unknown'; + + // Clean the dist folders. + await rm(path.resolve(projectPath, 'dist')); + + // Build with all the bundlers. + return Promise.all( + bundlers.map(async (bundler) => { + const buildBundlerPfx = ` [${dim(`Build ${name} with ${bundler}`)}]`; + console.time(buildBundlerPfx); + const { errors } = await buildProject(bundler, projectPath); + console.timeEnd(buildBundlerPfx); + return errors; + }), + ); +}; + +// Wrapper around the buildProjectWithBundlers function. +// - Create the destination folder. +// - Copy the content of the source folder in it. +// - Build the project with all the requested bundlers. +// - Delete the folder if the build failed. +// - Touch a "built" file if the build succeeded. +const handleBuild = async (source: string, destination: string, bundlers: BundlerFullName[]) => { + // Create the project dir. + await mkdir(destination); + // Copy the content of our project in it. + await fs.promises.cp(`${source}/`, `${destination}/`, { + recursive: true, + errorOnExist: true, + force: true, + }); + + // Build it with all the requested bundlers. + const name = destination.split(path.sep).pop() || 'unknown'; + const buildProjectPfx = ` [${dim(name)}] `; + console.time(buildProjectPfx); + const errors = (await buildProjectWithBundlers(destination, bundlers)).flat(); + + if (errors.length) { + console.error(`${buildProjectPfx}Build failed.`, errors); + // Delete the folder, so other tests can try and build it. + await rm(destination); + } else { + // Touch the built file so other tests know it's ready. + await fs.promises.writeFile(`${destination}/built`, ''); + } + + console.timeEnd(buildProjectPfx); +}; + +// Wait for the build to be done or to fail. +// Based on the presence of the "built" file or the disparition of the project folder. +const waitForBuild = async (projectDir: string): Promise<{ built: boolean; error: boolean }> => { + return new Promise((resolve) => { + const builtInterval = setInterval(() => { + if (fs.existsSync(`${projectDir}/built`)) { + clearInterval(builtInterval); + clearInterval(errorInterval); + resolve({ built: true, error: false }); + } + }, 100); + + const errorInterval = setInterval(() => { + if (!fs.existsSync(projectDir)) { + clearInterval(errorInterval); + clearInterval(builtInterval); + resolve({ built: false, error: true }); + } + }, 100); + }); +}; + +// Verify if the project has been built. +// Trigger the build if it's not been done yet. +// Wait for the build to be done. +// Note: This is to be used in a beforeAll hook, +// so all the workers can use the same build of a given suite. +export const verifyProjectBuild = async ( + source: string, + destination: string, + bundlers: BundlerFullName[], +) => { + // Wait a random time to avoid conflicts. + await new Promise<void>((resolve) => setTimeout(resolve, Math.floor(Math.random() * 500))); + + // Verify if the build as started, by checking the presence of the public directory. + const dirExists = fs.existsSync(destination); + if (dirExists) { + const result = await waitForBuild(destination); + if (result.error) { + await verifyProjectBuild(source, destination, bundlers); + } + } else { + // Build the project. + await handleBuild(source, destination, bundlers); + } +}; diff --git a/packages/tests/src/_playwright/helpers/requestedBundlers.ts b/packages/tests/src/_playwright/helpers/requestedBundlers.ts new file mode 100644 index 000000000..07fcbde58 --- /dev/null +++ b/packages/tests/src/_playwright/helpers/requestedBundlers.ts @@ -0,0 +1,55 @@ +// 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 { ENV_VAR_REQUESTED_BUNDLERS, FULL_NAME_BUNDLERS } from '@dd/core/constants'; +import type { BundlerFullName } from '@dd/core/types'; +import { yellow } from '@dd/tools/helpers'; + +const getBundlerNameFromProject = (projectName: string) => { + return projectName.split(' | ')[1] as BundlerFullName; +}; + +// Parse and detect all the --project arguments to get the requested bundlers. +export const getRequestedBundlers = ( + baseBundlers: readonly BundlerFullName[] = FULL_NAME_BUNDLERS, +): BundlerFullName[] => { + if (process.env[ENV_VAR_REQUESTED_BUNDLERS]) { + return process.env[ENV_VAR_REQUESTED_BUNDLERS].split(',') as BundlerFullName[]; + } + + const requestedBundlers: Set<BundlerFullName> = new Set(); + let capture = false; + + for (const arg of process.argv) { + let bundlerName: BundlerFullName; + if (arg === '--project') { + // Capture the next argument as the bundler name. + capture = true; + continue; + } + + if (capture === true) { + // Capture pass. + bundlerName = getBundlerNameFromProject(arg.trim()); + capture = false; + } else if (arg.startsWith('--project=')) { + // Argument is already in the format --project=... + bundlerName = getBundlerNameFromProject(arg.split('=')[1].trim()); + } else { + continue; + } + + if (baseBundlers.includes(bundlerName) && FULL_NAME_BUNDLERS.includes(bundlerName)) { + requestedBundlers.add(bundlerName); + } else { + console.warn(yellow(`Bundler "${bundlerName}" is not available.`)); + } + } + + const requestBundlers: BundlerFullName[] = requestedBundlers.size + ? Array.from(requestedBundlers) + : [...baseBundlers]; + + return requestBundlers; +}; diff --git a/packages/tests/src/_playwright/public/.gitignore b/packages/tests/src/_playwright/public/.gitignore new file mode 100644 index 000000000..a2632cea4 --- /dev/null +++ b/packages/tests/src/_playwright/public/.gitignore @@ -0,0 +1,6 @@ +# We don't want anything else in this folder. +# It is used for e2e tests to serve their own apps. +* +!.gitignore +!404.html +!index.html diff --git a/packages/tests/src/_playwright/public/404.html b/packages/tests/src/_playwright/public/404.html new file mode 100644 index 000000000..f8d67d86c --- /dev/null +++ b/packages/tests/src/_playwright/public/404.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link rel="icon" type="image/svg+xml" sizes="21x21" href="data:image/svg+xml,"> + <title>404 Not Found</title> +</head> + +<body> + <h1>404 - Page Not Found for {{bundler}}</h1> + <p>Sorry, the page you are looking for does not exist.</p> + <a href="./index.html">Go back to Home Page</a> +</body> + +</html> diff --git a/packages/tests/src/_playwright/public/index.html b/packages/tests/src/_playwright/public/index.html new file mode 100644 index 000000000..c4a889feb --- /dev/null +++ b/packages/tests/src/_playwright/public/index.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link rel="icon" type="image/svg+xml" sizes="21x21" href="data:image/svg+xml,"> + <title>Welcome</title> +</head> + +<body> + <h1>Welcome</h1> +</body> + +</html> diff --git a/packages/tests/src/_playwright/testParams.ts b/packages/tests/src/_playwright/testParams.ts new file mode 100644 index 000000000..66d1dcaf7 --- /dev/null +++ b/packages/tests/src/_playwright/testParams.ts @@ -0,0 +1,42 @@ +// 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 { DEV_SERVER_URL, PUBLIC_DIR } from '@dd/tests/_playwright/constants'; +import { test as base } from '@playwright/test'; +import path from 'path'; + +export type TestOptions = { + bundler: BundlerFullName; + bundlers: BundlerFullName[]; +}; + +type Fixtures = { + devServerUrl: string; + publicDir: string; + suiteName: string; +}; + +export const test = base.extend<TestOptions & Fixtures>({ + // Default value, will be overridden by config. + bundler: ['rollup', { option: true }], + bundlers: [[...FULL_NAME_BUNDLERS], { option: true }], + devServerUrl: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use, info) => { + const url = info.config.webServer?.url || DEV_SERVER_URL; + await use(url); + }, + { auto: true }, + ], + suiteName: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use, info) => { + await use(path.dirname(info.file).split(path.sep).pop() || 'unknown'); + }, + { auto: true }, + ], + publicDir: PUBLIC_DIR, +}); diff --git a/packages/tests/src/core/helpers.test.ts b/packages/tests/src/core/helpers.test.ts deleted file mode 100644 index 94f6d4313..000000000 --- a/packages/tests/src/core/helpers.test.ts +++ /dev/null @@ -1,162 +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 { RequestOpts } from '@dd/core/types'; -import { API_PATH, FAKE_URL, INTAKE_URL } from '@dd/tests/_jest/helpers/mocks'; -import nock from 'nock'; -import { Readable } from 'stream'; -import { createGzip } from 'zlib'; - -// Reduce the retry timeout to speed up the tests. -jest.mock('async-retry', () => { - const original = jest.requireActual('async-retry'); - return jest.fn((callback, options) => { - return original(callback, { - ...options, - minTimeout: 0, - maxTimeout: 1, - }); - }); -}); - -describe('Core Helpers', () => { - describe('formatDuration', () => { - test.each([ - [10, '10ms'], - [10010, '10s 10ms'], - [1000010, '16m 40s 10ms'], - [10000010, '2h 46m 40s 10ms'], - [1000000010, '11d 13h 46m 40s 10ms'], - ])('Should format duration %s => %s', async (ms, expected) => { - const { formatDuration } = await import('@dd/core/helpers'); - expect(formatDuration(ms)).toBe(expected); - }); - }); - - describe('doRequest', () => { - const getDataStream = () => { - const gz = createGzip(); - const stream = new Readable(); - stream.push('Some data'); - stream.push(null); - return stream.pipe(gz); - }; - - const getDataMock = () => ({ - data: getDataStream(), - headers: { - 'Content-Encoding': 'gzip', - }, - }); - - const requestOpts: RequestOpts = { - url: INTAKE_URL, - method: 'POST', - type: 'json', - getData: getDataMock, - }; - - afterEach(() => { - nock.cleanAll(); - }); - - test('Should do a request', async () => { - const { doRequest } = await import('@dd/core/helpers'); - const scope = nock(FAKE_URL).post(API_PATH).reply(200, {}); - - const response = await doRequest(requestOpts); - - expect(scope.isDone()).toBe(true); - expect(response).toEqual({}); - }); - - test('Should retry on error', async () => { - const { doRequest } = await import('@dd/core/helpers'); - // Success after 2 retries. - const scope = nock(FAKE_URL) - .post(API_PATH) - .times(2) - .reply(404) - .post(API_PATH) - .reply(200, { data: 'ok' }); - - const response = await doRequest(requestOpts); - - expect(scope.isDone()).toBe(true); - expect(response).toEqual({ data: 'ok' }); - }); - - test('Should throw on too many retries', async () => { - const { doRequest } = await import('@dd/core/helpers'); - const scope = nock(FAKE_URL) - .post(API_PATH) - .times(6) - .reply(500, 'Internal Server Error'); - - await expect(async () => { - await doRequest(requestOpts); - }).rejects.toThrow('HTTP 500 Internal Server Error'); - expect(scope.isDone()).toBe(true); - }); - - test('Should bail on specific status', async () => { - const { doRequest } = await import('@dd/core/helpers'); - const scope = nock(FAKE_URL).post(API_PATH).reply(400, 'Bad Request'); - - await expect(async () => { - await doRequest(requestOpts); - }).rejects.toThrow('HTTP 400 Bad Request'); - expect(scope.isDone()).toBe(true); - }); - - test('Should bail on unrelated errors', async () => { - const { doRequest } = await import('@dd/core/helpers'); - const scope = nock(FAKE_URL).post(API_PATH).reply(404); - // Creating the data stream outside should make the fetch invocation fail - // on the second pass as it will try to read an already consumed stream. - const data = getDataStream(); - - await expect(async () => { - await doRequest({ ...requestOpts, getData: () => ({ data, headers: {} }) }); - }).rejects.toThrow('Response body object should not be disturbed or locked'); - expect(scope.isDone()).toBe(true); - }); - }); - - describe('truncateString', () => { - test.each([ - // No truncation needed. - ['Short string', 20, '[...]', 'Short string'], - // Keep at least 2 characters on each side. - ['Short string', 2, '[...]', 'Sh[...]ng'], - // Equaly truncate on both sides. - [ - 'A way too long sentence could be truncated a bit.', - 20, - '[...]', - 'A way t[...]d a bit.', - ], - // Custom placeholder. - [ - 'A way too long sentence could be truncated a bit.', - 20, - '***', - 'A way to***ed a bit.', - ], - // Longer sentence. - [ - 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - 50, - '[...]', - 'Lorem ipsu[...]t ut labore et dolore magna aliqua.', - ], - ])( - 'Should truncate string "%s" to max length %d with placeholder "%s" => "%s"', - async (str, maxLength, placeholder, expected) => { - const { truncateString } = await import('@dd/core/helpers'); - expect(truncateString(str, maxLength, placeholder)).toBe(expected); - }, - ); - }); -}); diff --git a/packages/tests/src/e2e/smokeTest/project/index.html b/packages/tests/src/e2e/smokeTest/project/index.html new file mode 100644 index 000000000..9d63a04cc --- /dev/null +++ b/packages/tests/src/e2e/smokeTest/project/index.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link rel="icon" type="image/svg+xml" sizes="21x21" href="data:image/svg+xml,"> + <title>Basic HTML Page</title> +</head> + +<body> + <h1>Welcome to the {{bundler}} Home page</h1> + <p>This is just some simple text.</p> + + <script src="./dist/{{bundler}}.js"></script> +</body> + +</html> diff --git a/packages/tests/src/_jest/fixtures/file-to-inject.js b/packages/tests/src/e2e/smokeTest/project/index.js similarity index 82% rename from packages/tests/src/_jest/fixtures/file-to-inject.js rename to packages/tests/src/e2e/smokeTest/project/index.js index 8c9d16b3e..d7a4a42ff 100644 --- a/packages/tests/src/_jest/fixtures/file-to-inject.js +++ b/packages/tests/src/e2e/smokeTest/project/index.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, {{bundler}}!'); diff --git a/packages/tests/src/e2e/smokeTest/smokeTest.spec.ts b/packages/tests/src/e2e/smokeTest/smokeTest.spec.ts new file mode 100644 index 000000000..4b2078f11 --- /dev/null +++ b/packages/tests/src/e2e/smokeTest/smokeTest.spec.ts @@ -0,0 +1,65 @@ +// 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 { verifyProjectBuild } from '@dd/tests/_playwright/helpers/buildProject'; +import type { TestOptions } from '@dd/tests/_playwright/testParams'; +import { test } from '@dd/tests/_playwright/testParams'; +import type { Page } from '@playwright/test'; +import path from 'path'; + +// Have a similar experience to Jest. +const { expect, beforeAll, describe } = test; + +const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler']) => { + // Navigate to our page. + await page.goto(`${url}/index.html?context_bundler=${bundler}`); + await page.waitForSelector('body'); +}; + +describe('Smoke Test', () => { + // Build our fixture project. + beforeAll(async ({ publicDir, bundlers, suiteName }) => { + const source = path.resolve(__dirname, 'project'); + const destination = path.resolve(publicDir, suiteName); + await verifyProjectBuild(source, destination, bundlers); + }); + + test('Should load the page without errors', async ({ + page, + bundler, + browserName, + suiteName, + devServerUrl, + }) => { + const errors: string[] = []; + const testBaseUrl = `${devServerUrl}/${suiteName}`; + + // Listen for errors on the page. + page.on('pageerror', (error) => errors.push(error.message)); + page.on('response', async (response) => { + if (!response.ok()) { + const url = response.request().url(); + const prefix = `[${bundler} ${browserName} ${response.status()}]`; + errors.push(`${prefix} ${url}`); + } + }); + + // Verify that we do log the expected things. + const logs: string[] = []; + page.on('console', async (msg) => { + for (const arg of msg.args()) { + // eslint-disable-next-line no-await-in-loop + logs.push(await arg.jsonValue()); + } + }); + + // It should load the correct bundler file too. + const bundleRequest = page.waitForResponse(`${testBaseUrl}/dist/${bundler}.js`); + await userFlow(testBaseUrl, page, bundler); + expect((await bundleRequest).ok()).toBe(true); + + expect(logs).toEqual([`Hello, ${bundler}!`]); + expect(errors).toHaveLength(0); + }); +}); 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<typeof getEntryNames>[0], Map<string, string>][] = [ - [ - '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/injection/index.test.ts b/packages/tests/src/plugins/injection/index.test.ts deleted file mode 100644 index d15e006f5..000000000 --- a/packages/tests/src/plugins/injection/index.test.ts +++ /dev/null @@ -1,169 +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 { Options } from '@dd/core/types'; -import { - debugFilesPlugins, - getComplexBuildOverrides, - getNodeSafeBuildOverrides, -} 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 { execute } from '@dd/tools/helpers'; -import { readFileSync, writeFileSync } from 'fs'; -import { glob } from 'glob'; -import nock from 'nock'; -import path from 'path'; - -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<string, string> = {}; - - 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 }, - ]; - - 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({ - type: 'code', - value: codeContent, - }); - - return [ - { - name: 'get-outdirs', - writeBundle() { - // Store the seeded outdir to inspect the produced files. - outdirs[context.bundler.fullName] = context.bundler.outDir; - - // 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), - ]; - }; - - describe('Basic build', () => { - let nockScope: nock.Scope; - let cleanup: CleanupFn; - - beforeAll(async () => { - nockScope = nock('https://example.com') - .get('/distant-file.js') - .times(BUNDLERS.length) - .reply(200, distantFileContent); - - cleanup = await runBundlers( - { - customPlugins, - }, - getNodeSafeBuildOverrides(), - ); - }); - - afterAll(async () => { - outdirs = {}; - nock.cleanAll(); - await cleanup(); - }); - - test('Should have requested the distant file for each bundler.', () => { - expect(nockScope.isDone()).toBe(true); - }); - - describe.each(BUNDLERS)('$name | $version', ({ name }) => { - 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; - }); - - 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'); - - // 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); - }); - }); - }); - - describe('Complex build', () => { - let nockScope: nock.Scope; - let cleanup: CleanupFn; - - beforeAll(async () => { - nockScope = nock('https://example.com') - .get('/distant-file.js') - .times(BUNDLERS.length) - .reply(200, distantFileContent); - - cleanup = await runBundlers( - { - customPlugins, - }, - getNodeSafeBuildOverrides(getComplexBuildOverrides()), - ); - }); - - afterAll(async () => { - outdirs = {}; - nock.cleanAll(); - await cleanup(); - }); - - test('Should have requested the distant file for each bundler.', () => { - expect(nockScope.isDone()).toBe(true); - }); - - describe.each(BUNDLERS)('$name | $version', ({ name }) => { - 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; - }); - - 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); - }); - }); - }); -}); diff --git a/packages/tests/src/unit/core/helpers.test.ts b/packages/tests/src/unit/core/helpers.test.ts new file mode 100644 index 000000000..d76a1a197 --- /dev/null +++ b/packages/tests/src/unit/core/helpers.test.ts @@ -0,0 +1,362 @@ +// 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 { 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'; + +// Reduce the retry timeout to speed up the tests. +jest.mock('async-retry', () => { + const original = jest.requireActual('async-retry'); + return jest.fn((callback, options) => { + return original(callback, { + ...options, + minTimeout: 0, + maxTimeout: 1, + }); + }); +}); + +// Use mock files. +jest.mock('fs', () => require('memfs').fs); + +describe('Core Helpers', () => { + describe('formatDuration', () => { + test.each([ + [10, '10ms'], + [10010, '10s 10ms'], + [1000010, '16m 40s 10ms'], + [10000010, '2h 46m 40s 10ms'], + [1000000010, '11d 13h 46m 40s 10ms'], + ])('Should format duration %s => %s', async (ms, expected) => { + const { formatDuration } = await import('@dd/core/helpers'); + expect(formatDuration(ms)).toBe(expected); + }); + }); + + 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(); + const stream = new Readable(); + stream.push('Some data'); + stream.push(null); + return stream.pipe(gz); + }; + + const getDataMock = () => ({ + data: getDataStream(), + headers: { + 'Content-Encoding': 'gzip', + }, + }); + + const requestOpts: RequestOpts = { + url: INTAKE_URL, + method: 'POST', + type: 'json', + getData: getDataMock, + }; + + afterEach(() => { + nock.cleanAll(); + }); + + test('Should do a request', async () => { + const { doRequest } = await import('@dd/core/helpers'); + const scope = nock(FAKE_URL).post(API_PATH).reply(200, {}); + + const response = await doRequest(requestOpts); + + expect(scope.isDone()).toBe(true); + expect(response).toEqual({}); + }); + + test('Should retry on error', async () => { + const { doRequest } = await import('@dd/core/helpers'); + // Success after 2 retries. + const scope = nock(FAKE_URL) + .post(API_PATH) + .times(2) + .reply(404) + .post(API_PATH) + .reply(200, { data: 'ok' }); + + const response = await doRequest(requestOpts); + + expect(scope.isDone()).toBe(true); + expect(response).toEqual({ data: 'ok' }); + }); + + test('Should throw on too many retries', async () => { + const { doRequest } = await import('@dd/core/helpers'); + const scope = nock(FAKE_URL) + .post(API_PATH) + .times(6) + .reply(500, 'Internal Server Error'); + + await expect(async () => { + await doRequest(requestOpts); + }).rejects.toThrow('HTTP 500 Internal Server Error'); + expect(scope.isDone()).toBe(true); + }); + + test('Should bail on specific status', async () => { + const { doRequest } = await import('@dd/core/helpers'); + const scope = nock(FAKE_URL).post(API_PATH).reply(400, 'Bad Request'); + + await expect(async () => { + await doRequest(requestOpts); + }).rejects.toThrow('HTTP 400 Bad Request'); + expect(scope.isDone()).toBe(true); + }); + + test('Should bail on unrelated errors', async () => { + const { doRequest } = await import('@dd/core/helpers'); + const scope = nock(FAKE_URL).post(API_PATH).reply(404); + // Creating the data stream outside should make the fetch invocation fail + // on the second pass as it will try to read an already consumed stream. + const data = getDataStream(); + + await expect(async () => { + await doRequest({ ...requestOpts, getData: () => ({ data, headers: {} }) }); + }).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', () => { + test.each([ + // No truncation needed. + ['Short string', 20, '[...]', 'Short string'], + // Keep at least 2 characters on each side. + ['Short string', 2, '[...]', 'Sh[...]ng'], + // Equaly truncate on both sides. + [ + 'A way too long sentence could be truncated a bit.', + 20, + '[...]', + 'A way t[...]d a bit.', + ], + // Custom placeholder. + [ + 'A way too long sentence could be truncated a bit.', + 20, + '***', + 'A way to***ed a bit.', + ], + // Longer sentence. + [ + 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + 50, + '[...]', + 'Lorem ipsu[...]t ut labore et dolore magna aliqua.', + ], + ])( + 'Should truncate string "%s" to max length %d with placeholder "%s" => "%s"', + async (str, maxLength, placeholder, expected) => { + const { truncateString } = await import('@dd/core/helpers'); + expect(truncateString(str, maxLength, placeholder)).toBe(expected); + }, + ); + }); +}); diff --git a/packages/tests/src/factory/helpers.test.ts b/packages/tests/src/unit/factory/helpers.test.ts similarity index 89% rename from packages/tests/src/factory/helpers.test.ts rename to packages/tests/src/unit/factory/helpers.test.ts index 890b533f5..7b782a4e6 100644 --- a/packages/tests/src/factory/helpers.test.ts +++ b/packages/tests/src/unit/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<string, ToInjectItem> = 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/factory/index.test.ts b/packages/tests/src/unit/factory/index.test.ts similarity index 100% rename from packages/tests/src/factory/index.test.ts rename to packages/tests/src/unit/factory/index.test.ts diff --git a/packages/tests/src/plugins/build-report/helpers.test.ts b/packages/tests/src/unit/plugins/build-report/helpers.test.ts similarity index 100% rename from packages/tests/src/plugins/build-report/helpers.test.ts rename to packages/tests/src/unit/plugins/build-report/helpers.test.ts diff --git a/packages/tests/src/plugins/build-report/index.test.ts b/packages/tests/src/unit/plugins/build-report/index.test.ts similarity index 92% rename from packages/tests/src/plugins/build-report/index.test.ts rename to packages/tests/src/unit/plugins/build-report/index.test.ts index 9249b0f87..ef9bf1b96 100644 --- a/packages/tests/src/plugins/build-report/index.test.ts +++ b/packages/tests/src/unit/plugins/build-report/index.test.ts @@ -2,7 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { getResolvedPath } from '@dd/core/helpers'; +import { serializeBuildReport, unserializeBuildReport } from '@dd/core/helpers'; import type { Input, Entry, @@ -12,21 +12,20 @@ import type { BuildReport, SerializedInput, } from '@dd/core/types'; -import { - serializeBuildReport, - unserializeBuildReport, -} from '@dd/internal-build-report-plugin/helpers'; import { generateProject } from '@dd/tests/_jest/helpers/generateMassiveProject'; import { - debugFilesPlugins, defaultEntry, defaultPluginOptions, filterOutParticularities, 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 { debugFilesPlugins } from '@dd/tools/helpers'; import path from 'path'; const sortFiles = (a: File | Output | Entry, b: File | Output | Entry) => { @@ -64,7 +63,7 @@ describe('Build Report Plugin', () => { describe('Basic build', () => { const bundlerOutdir: Record<string, string> = {}; const buildReports: Record<string, BuildReport> = {}; - let cleanup: CleanupFn; + let cleanup: CleanupEverythingFn; beforeAll(async () => { cleanup = await runBundlers(getPluginConfig(bundlerOutdir, buildReports)); @@ -76,8 +75,8 @@ describe('Build Report Plugin', () => { const expectedInput = () => expect.objectContaining<Input>({ - 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 +89,8 @@ describe('Build Report Plugin', () => { filepath: path.join(outDir, 'main.js'), inputs: [ expect.objectContaining<Input>({ - 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 +178,7 @@ describe('Build Report Plugin', () => { // Intercept contexts to verify it at the moment they're used. const bundlerOutdir: Record<string, string> = {}; const buildReports: Record<string, BuildReport> = {}; - let cleanup: CleanupFn; + let cleanup: CleanupEverythingFn; beforeAll(async () => { cleanup = await runBundlers( @@ -194,8 +193,8 @@ describe('Build Report Plugin', () => { const expectedInput = (name: string) => expect.objectContaining<SerializedInput>({ - 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 +226,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 +268,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 +276,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 +307,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 +552,7 @@ describe('Build Report Plugin', () => { beforeAll(async () => { const entries = await generateProject(2, 500); - const bundlerOverrides = { + const bundlerOverrides: BundlerOptionsOverrides = { rollup: { input: entries, }, @@ -565,10 +564,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/unit/plugins/bundler-report/index.test.ts similarity index 76% rename from packages/tests/src/plugins/bundler-report/index.test.ts rename to packages/tests/src/unit/plugins/bundler-report/index.test.ts index bfedc1fc2..44b25101b 100644 --- a/packages/tests/src/plugins/bundler-report/index.test.ts +++ b/packages/tests/src/unit/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<string, BundlerReport> = {}; - let cleanup: CleanupFn; + const contexts: Record<string, Partial<GlobalContext>> = {}; + 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/rum/index.test.ts b/packages/tests/src/unit/plugins/error-tracking/index.test.ts similarity index 83% rename from packages/tests/src/plugins/rum/index.test.ts rename to packages/tests/src/unit/plugins/error-tracking/index.test.ts index e36170705..59585a6be 100644 --- a/packages/tests/src/plugins/rum/index.test.ts +++ b/packages/tests/src/unit/plugins/error-tracking/index.test.ts @@ -2,13 +2,13 @@ // 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 { 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/rum-plugin/sourcemaps/index', () => { +jest.mock('@dd/error-tracking-plugin/sourcemaps/index', () => { return { uploadSourcemaps: jest.fn(), }; @@ -16,7 +16,7 @@ jest.mock('@dd/rum-plugin/sourcemaps/index', () => { const uploadSourcemapsMock = jest.mocked(uploadSourcemaps); -describe('RUM Plugin', () => { +describe('Error Tracking Plugin', () => { const cleanups: CleanupFn[] = []; afterAll(async () => { @@ -26,7 +26,7 @@ describe('RUM Plugin', () => { test('Should process the sourcemaps if enabled.', async () => { cleanups.push( await runBundlers({ - rum: { + errorTracking: { sourcemaps: getSourcemapsConfiguration(), }, }), @@ -37,7 +37,7 @@ describe('RUM Plugin', () => { test('Should not process the sourcemaps with no options.', async () => { cleanups.push( await runBundlers({ - rum: {}, + errorTracking: {}, }), ); diff --git a/packages/tests/src/plugins/rum/sourcemaps/files.test.ts b/packages/tests/src/unit/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/unit/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/unit/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/unit/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/unit/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/unit/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/unit/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/unit/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/unit/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<Buffer> { }); } -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/unit/plugins/error-tracking/testHelpers.ts similarity index 83% rename from packages/tests/src/plugins/rum/testHelpers.ts rename to packages/tests/src/unit/plugins/error-tracking/testHelpers.ts index 3242f6cfb..3860e68c3 100644 --- a/packages/tests/src/plugins/rum/testHelpers.ts +++ b/packages/tests/src/unit/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> = {}, -): RumSourcemapsOptions => { + options: Partial<SourcemapsOptions> = {}, +): 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<RumSourcemapsOptions> = {}, -): RumSourcemapsOptionsWithDefaults => { + options: Partial<SourcemapsOptions> = {}, +): 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> = {}): 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/unit/plugins/error-tracking/validate.test.ts similarity index 86% rename from packages/tests/src/plugins/rum/validate.test.ts rename to packages/tests/src/unit/plugins/error-tracking/validate.test.ts index c12f12777..2eac6ddcd 100644 --- a/packages/tests/src/plugins/rum/validate.test.ts +++ b/packages/tests/src/unit/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/helpers.test.ts b/packages/tests/src/unit/plugins/git/helpers.test.ts similarity index 100% rename from packages/tests/src/plugins/git/helpers.test.ts rename to packages/tests/src/unit/plugins/git/helpers.test.ts diff --git a/packages/tests/src/plugins/git/index.test.ts b/packages/tests/src/unit/plugins/git/index.test.ts similarity index 87% rename from packages/tests/src/plugins/git/index.test.ts rename to packages/tests/src/unit/plugins/git/index.test.ts index a63b6b42f..c308bafca 100644 --- a/packages/tests/src/plugins/git/index.test.ts +++ b/packages/tests/src/unit/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/unit/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/git/trackedFilesMatcher.test.ts b/packages/tests/src/unit/plugins/git/trackedFilesMatcher.test.ts similarity index 100% rename from packages/tests/src/plugins/git/trackedFilesMatcher.test.ts rename to packages/tests/src/unit/plugins/git/trackedFilesMatcher.test.ts diff --git a/packages/tests/src/plugins/injection/helpers.test.ts b/packages/tests/src/unit/plugins/injection/helpers.test.ts similarity index 77% rename from packages/tests/src/plugins/injection/helpers.test.ts rename to packages/tests/src/unit/plugins/injection/helpers.test.ts index 76ce46203..e7aec9b24 100644 --- a/packages/tests/src/plugins/injection/helpers.test.ts +++ b/packages/tests/src/unit/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<string, ToInjectItem> = 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/unit/plugins/injection/index.test.ts b/packages/tests/src/unit/plugins/injection/index.test.ts new file mode 100644 index 000000000..172af7ac4 --- /dev/null +++ b/packages/tests/src/unit/plugins/injection/index.test.ts @@ -0,0 +1,405 @@ +// 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 { 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 { getComplexBuildOverrides, getNodeSafeBuildOverrides } 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 { debugFilesPlugins, 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<File, ExpectedValues>; + content: ExpectedValues; +}; +type EasyExpectation = Assign<BaseExpectation, { logs?: { 'main.js': ExpectedValues } }>; +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<Record<File, string>>; +}; +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', () => { + // 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 toInjectItems: ToInjectItem[] = [ + // Add a special case of import to confirm this is working as expected in the middle of the code. + { + type: 'code', + value: `const chalk = require('chalk');\nconsole.log(chalk.bold.red('${specialLog}'));\n`, + position: InjectPosition.MIDDLE, + }, + ]; + + // 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; + + const injectionLog = getLog(type, position); + const injectionContent = getContent(type, position); + const injection: ToInjectItem = { + type: injectType, + value: injectionContent, + position: positionType, + }; + + // 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]], + }); + + easyWithInjections.push({ + name: `[${position}] ${type} injection in easy build`, + logs: { + 'main.js': [injectionLog, 1], + }, + content: [injectionContent, 1], + }); + + if (type === ContentType.DISTANT) { + injection.value = `${DOMAIN}${getFileUrl(position)}`; + } else if (type === ContentType.LOCAL) { + injection.value = `.${getFileUrl(position)}`; + } + + toInjectItems.push(injection); + } + } + + // 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<Record<BundlerFullName, BuildState>>, + ): Options['customPlugins'] => + (opts, context) => { + for (const injection of injections) { + context.inject(injection); + } + + 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; + + // 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<typeof runBundlers>[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.each(tests)('$name', ({ overrides, positions, injections, expectations }) => { + let nockScope: nock.Scope; + let cleanup: CleanupFn; + let buildStates: Partial<Record<BundlerFullName, BuildState>> = {}; + + beforeAll(async () => { + 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: getPlugins(injections[0], buildStates) }, + overrides, + ); + + // Execute the builds and store some state. + const proms: Promise<void>[] = []; + 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 () => { + buildStates = {}; + nock.cleanAll(); + await cleanup(); + }); + + 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 buildState: BuildState; + + 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/telemetry/.eslintrc.js b/packages/tests/src/unit/plugins/telemetry/.eslintrc.js similarity index 100% rename from packages/tests/src/plugins/telemetry/.eslintrc.js rename to packages/tests/src/unit/plugins/telemetry/.eslintrc.js diff --git a/packages/tests/src/plugins/telemetry/common/aggregator.test.ts b/packages/tests/src/unit/plugins/telemetry/common/aggregator.test.ts similarity index 87% rename from packages/tests/src/plugins/telemetry/common/aggregator.test.ts rename to packages/tests/src/unit/plugins/telemetry/common/aggregator.test.ts index 027459d46..b713ceb45 100644 --- a/packages/tests/src/plugins/telemetry/common/aggregator.test.ts +++ b/packages/tests/src/unit/plugins/telemetry/common/aggregator.test.ts @@ -4,7 +4,7 @@ import { addMetrics } from '@dd/telemetry-plugin/common/aggregator'; import { getContextMock } from '@dd/tests/_jest/helpers/mocks'; -import { mockOptionsDD, mockReport } from '@dd/tests/plugins/telemetry/testHelpers'; +import { mockOptionsDD, mockReport } from '@dd/tests/unit/plugins/telemetry/testHelpers'; describe('Telemetry Aggregator', () => { test('Should aggregate metrics without throwing.', () => { diff --git a/packages/tests/src/plugins/telemetry/common/helpers.test.ts b/packages/tests/src/unit/plugins/telemetry/common/helpers.test.ts similarity index 100% rename from packages/tests/src/plugins/telemetry/common/helpers.test.ts rename to packages/tests/src/unit/plugins/telemetry/common/helpers.test.ts diff --git a/packages/tests/src/plugins/telemetry/common/output/files.test.ts b/packages/tests/src/unit/plugins/telemetry/common/output/files.test.ts similarity index 97% rename from packages/tests/src/plugins/telemetry/common/output/files.test.ts rename to packages/tests/src/unit/plugins/telemetry/common/output/files.test.ts index 7badb15a0..3a406a942 100644 --- a/packages/tests/src/plugins/telemetry/common/output/files.test.ts +++ b/packages/tests/src/unit/plugins/telemetry/common/output/files.test.ts @@ -5,7 +5,7 @@ import { outputFiles } from '@dd/telemetry-plugin/common/output/files'; import type { OutputOptions } from '@dd/telemetry-plugin/types'; import { mockLogger } from '@dd/tests/_jest/helpers/mocks'; -import { mockReport } from '@dd/tests/plugins/telemetry/testHelpers'; +import { mockReport } from '@dd/tests/unit/plugins/telemetry/testHelpers'; import { vol } from 'memfs'; import path from 'path'; diff --git a/packages/tests/src/plugins/telemetry/esbuild-plugin/plugins.test.ts b/packages/tests/src/unit/plugins/telemetry/esbuild-plugin/plugins.test.ts similarity index 94% rename from packages/tests/src/plugins/telemetry/esbuild-plugin/plugins.test.ts rename to packages/tests/src/unit/plugins/telemetry/esbuild-plugin/plugins.test.ts index 0ddd95dc9..df16f3b1b 100644 --- a/packages/tests/src/plugins/telemetry/esbuild-plugin/plugins.test.ts +++ b/packages/tests/src/unit/plugins/telemetry/esbuild-plugin/plugins.test.ts @@ -3,7 +3,7 @@ // Copyright 2019-Present Datadog, Inc. import { wrapPlugins, getResults } from '@dd/telemetry-plugin/esbuild-plugin/plugins'; -import { getMockBuild } from '@dd/tests/plugins/telemetry/testHelpers'; +import { getMockBuild } from '@dd/tests/unit/plugins/telemetry/testHelpers'; import type { PluginBuild, Plugin } from 'esbuild'; describe('Telemetry ESBuild Plugins', () => { diff --git a/packages/tests/src/plugins/telemetry/index.test.ts b/packages/tests/src/unit/plugins/telemetry/index.test.ts similarity index 87% rename from packages/tests/src/plugins/telemetry/index.test.ts rename to packages/tests/src/unit/plugins/telemetry/index.test.ts index 51bd84b27..28b963d79 100644 --- a/packages/tests/src/plugins/telemetry/index.test.ts +++ b/packages/tests/src/unit/plugins/telemetry/index.test.ts @@ -7,12 +7,12 @@ import { addMetrics } from '@dd/telemetry-plugin/common/aggregator'; import type { MetricToSend } from '@dd/telemetry-plugin/types'; import { FAKE_URL, - debugFilesPlugins, filterOutParticularities, getComplexBuildOverrides, } from '@dd/tests/_jest/helpers/mocks'; import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; import type { Bundler, CleanupFn } from '@dd/tests/_jest/helpers/types'; +import { debugFilesPlugins } from '@dd/tools/helpers'; import nock from 'nock'; // Used to intercept metrics. @@ -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/plugins/telemetry/testHelpers.ts b/packages/tests/src/unit/plugins/telemetry/testHelpers.ts similarity index 100% rename from packages/tests/src/plugins/telemetry/testHelpers.ts rename to packages/tests/src/unit/plugins/telemetry/testHelpers.ts diff --git a/packages/tests/src/tools/src/commands/create-plugin/ask.test.ts b/packages/tests/src/unit/tools/src/commands/create-plugin/ask.test.ts similarity index 100% rename from packages/tests/src/tools/src/commands/create-plugin/ask.test.ts rename to packages/tests/src/unit/tools/src/commands/create-plugin/ask.test.ts diff --git a/packages/tests/src/tools/src/commands/create-plugin/hooks.test.ts b/packages/tests/src/unit/tools/src/commands/create-plugin/hooks.test.ts similarity index 100% rename from packages/tests/src/tools/src/commands/create-plugin/hooks.test.ts rename to packages/tests/src/unit/tools/src/commands/create-plugin/hooks.test.ts diff --git a/packages/tests/src/tools/src/commands/create-plugin/index.test.ts b/packages/tests/src/unit/tools/src/commands/create-plugin/index.test.ts similarity index 100% rename from packages/tests/src/tools/src/commands/create-plugin/index.test.ts rename to packages/tests/src/unit/tools/src/commands/create-plugin/index.test.ts diff --git a/packages/tests/src/tools/src/commands/integrity/files.test.ts b/packages/tests/src/unit/tools/src/commands/integrity/files.test.ts similarity index 100% rename from packages/tests/src/tools/src/commands/integrity/files.test.ts rename to packages/tests/src/unit/tools/src/commands/integrity/files.test.ts diff --git a/packages/tests/src/tools/src/helpers.test.ts b/packages/tests/src/unit/tools/src/helpers.test.ts similarity index 100% rename from packages/tests/src/tools/src/helpers.test.ts rename to packages/tests/src/unit/tools/src/helpers.test.ts diff --git a/packages/tests/src/tools/src/rollupConfig.test.ts b/packages/tests/src/unit/tools/src/rollupConfig.test.ts similarity index 56% rename from packages/tests/src/tools/src/rollupConfig.test.ts rename to packages/tests/src/unit/tools/src/rollupConfig.test.ts index f7fffa49a..9fd346efb 100644 --- a/packages/tests/src/tools/src/rollupConfig.test.ts +++ b/packages/tests/src/unit/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,12 @@ 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 { getWebpackPlugin } from '@dd/tests/_jest/helpers/getWebpackPlugin'; import { API_PATH, FAKE_URL, - defaultDestination, defaultEntries, getComplexBuildOverrides, getFullPluginConfig, @@ -32,11 +34,11 @@ 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 { ROOT } from '@dd/tools/constants'; -import { bgYellow, execute, green } from '@dd/tools/helpers'; +import { bgGreen, 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'; @@ -58,8 +60,9 @@ jest.mock('@datadog/webpack-plugin', () => ({ })); // Mock the plugin configuration of webpack to actually use the bundled plugin. -jest.mock('@dd/tests/_jest/helpers/xpackConfigs', () => { - const actual = jest.requireActual('@dd/tests/_jest/helpers/xpackConfigs'); +// And pass the correct bundler to it (webpack4 or webpack5). +jest.mock('@dd/tests/_jest/helpers/getWebpackPlugin', () => { + const actual = jest.requireActual('@dd/tests/_jest/helpers/getWebpackPlugin'); return { ...actual, getWebpackPlugin: jest.fn(), @@ -72,12 +75,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) { @@ -89,6 +98,17 @@ const getPackageDestination = (bundlerName: string) => { const stats = fs.statSync(packageDestination); const lastUpdateDuration = Math.ceil((new Date().getTime() - stats.mtimeMs) / 1000) * 1000; + // If we're in the CI it means we're using cached files. + if (process.env.CI) { + console.log( + bgGreen( + ` [CACHED] ${bundlerName}-plugin was built ${formatDuration(lastUpdateDuration)} ago.\n`, + ), + ); + // We don't want to block/alert on builds in CI. + return packageDestination; + } + // If last build was more than 10 minutes ago, warn the user. if (lastUpdateDuration > 1000 * 60 * 10) { console.log( @@ -115,10 +135,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<Record<BundlerFullName, string>> = {}; let processErrors: string[] = []; - const seededFolders: string[] = []; const pluginConfig = getFullPluginConfig({ logLevel: 'error', customPlugins: (opts, context) => [ @@ -143,12 +178,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 +217,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:unit". '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 +245,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 +340,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 +361,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 +385,30 @@ 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', - ], - }; + // Use a function to generate a new object each time. + const xpackEntries = () => ({ + 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), - entry: webpackEntries, + ...getRspackOptions(rootDir, {}, overrides.rspack), + entry: xpackEntries(), }; const webpack5Config = { - ...getWebpack5Options(SEED, {}, configs.webpack5), - entry: webpackEntries, + ...getWebpack5Options(rootDir, {}, overrides.webpack5), + entry: xpackEntries(), }; const webpack4Config = { - ...getWebpack4Options(SEED, {}, configs.webpack4), - entry: getWebpack4Entries(webpackEntries), + ...getWebpack4Options(rootDir, {}, overrides.webpack4), + entry: xpackEntries(), }; // Build the sequence. @@ -386,14 +416,14 @@ describe('Bundling', () => { const sequence: (() => Promise<SequenceReturn | SequenceReturn[]>)[] = [ () => 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 +445,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..bb0697d86 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -13,28 +13,49 @@ "packageManager": "yarn@4.0.2", "exports": { "./rollupConfig.mjs": "./src/rollupConfig.mjs", + "./commands/oss/templates": "./src/commands/oss/templates.ts", + "./bundlers": "./src/bundlers.ts", "./*": "./src/*.ts" }, "scripts": { "cli": "ts-node -T --project ./tsconfig.json ./src/index.ts" }, "devDependencies": { + "@rollup/plugin-esm-shim": "0.1.7", "@types/chalk": "2.2.0", "@types/glob": "7.1.4", + "@types/lodash.template": "^4", "@types/node": "^18", + "@types/webpack4": "npm:@types/webpack@4.41.38", + "lodash.template": "4.5.0", "ts-node": "10.9.2", "typescript": "5.4.3" }, "dependencies": { + "@datadog/esbuild-plugin": "workspace:*", + "@datadog/rollup-plugin": "workspace:*", + "@datadog/rspack-plugin": "workspace:*", + "@datadog/vite-plugin": "workspace:*", + "@datadog/webpack-plugin": "workspace:*", "@dd/assets": "workspace:*", "@dd/core": "workspace:*", + "@dd/error-tracking-plugin": "workspace:*", + "@dd/telemetry-plugin": "workspace:*", "@inquirer/checkbox": "2.3.3", "@inquirer/input": "2.1.7", "@inquirer/select": "2.3.3", + "@rollup/plugin-commonjs": "28.0.1", + "@rollup/plugin-node-resolve": "15.3.0", + "@rspack/core": "1.1.2", "chalk": "2.3.1", "clipanion": "4.0.0-rc.3", + "esbuild": "0.24.0", "glob": "7.1.6", "outdent": "0.8.0", - "typanion": "3.14.0" + "typanion": "3.14.0", + "vite": "5.4.10", + "webpack": "5.92.1", + "webpack4": "npm:webpack@4.47.0", + "webpack5": "npm:webpack@5.92.1" } } diff --git a/packages/tools/src/bundlers.ts b/packages/tools/src/bundlers.ts new file mode 100644 index 000000000..ecdf356fe --- /dev/null +++ b/packages/tools/src/bundlers.ts @@ -0,0 +1,306 @@ +// 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 { BundlerFullName } from '@dd/core/types'; +import commonjs from '@rollup/plugin-commonjs'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import type { + RspackOptions, + Stats as RspackStats, + StatsCompilation as RspackStatsCompilation, +} from '@rspack/core'; +import type { BuildOptions, BuildResult } from 'esbuild'; +import path from 'path'; +import type { RollupOptions, RollupOutput } from 'rollup'; +import type { InlineConfig } from 'vite'; +import type { Configuration as Configuration4, Stats as Stats4 } from 'webpack4'; +import type { + Configuration as Configuration5, + Stats as Stats5, + StatsCompilation as StatsCompilation5, +} from 'webpack5'; + +export type BundlerOptions = + | RspackOptions + | Configuration4 + | Configuration5 + | BuildOptions + | RollupOptions + | InlineConfig; +export type BundlerConfig = { + workingDir: string; + outDir: string; + entry: { [name: string]: string }; + plugins?: any[]; +}; +export type BundlerConfigFunction = (config: BundlerConfig) => BundlerOptions; +export type BundlerRunFunction = ( + bundlerConfig: any, +) => Promise<{ errors: string[]; result?: any }>; + +const xpackCallback = ( + err: Error | null, + stats: Stats4 | Stats5 | RspackStats | undefined, + resolve: (value: unknown) => void, + reject: (reason?: any) => void, + delay: number = 0, +) => { + if (err) { + reject(err); + return; + } + + if (!stats) { + reject('No stats returned.'); + return; + } + + const { errors, warnings } = stats.compilation; + if (errors?.length) { + reject(errors[0]); + return; + } + + if (warnings?.length) { + console.warn(warnings.join('\n')); + } + + // Delay the resolve to give time to the bundler to finish writing the files. + // Webpack4 in particular is impacted by this and otherwise triggers a + // "Jest did not exit one second after the test run has completed." warning. + // TODO: Investigate this need for a delay after webpack 4's build. + setTimeout(() => { + resolve(stats); + }, delay); +}; + +export const buildWithRspack: BundlerRunFunction = async (bundlerConfig: RspackOptions) => { + const { rspack } = await import('@rspack/core'); + const errors = []; + let result: RspackStatsCompilation | undefined; + + try { + await new Promise((resolve, reject) => { + rspack(bundlerConfig, (err, stats) => { + result = stats?.toJson(); + xpackCallback(err, stats, resolve, reject); + }); + }); + } catch (e: any) { + errors.push(`[RSPACK] : ${e.message}`); + } + + return { errors, result }; +}; + +export const buildWithWebpack5: BundlerRunFunction = async (bundlerConfig: Configuration5) => { + const { default: webpack } = await import('webpack5'); + const errors = []; + let result: StatsCompilation5 | undefined; + + try { + await new Promise((resolve, reject) => { + webpack(bundlerConfig, (err, stats) => { + result = stats?.toJson(); + xpackCallback(err, stats, resolve, reject); + }); + }); + } catch (e: any) { + errors.push(`[WEBPACK5] : ${e.message}`); + } + + return { errors, result }; +}; + +export const buildWithWebpack4: BundlerRunFunction = async (bundlerConfig: Configuration4) => { + const webpack = (await import('webpack4')).default; + const errors = []; + let result: Stats4.ToJsonOutput | undefined; + + try { + await new Promise((resolve, reject) => { + webpack(bundlerConfig, (err, stats) => { + result = stats?.toJson(); + xpackCallback(err, stats, resolve, reject, 600); + }); + }); + } catch (e: any) { + errors.push(`[WEBPACK4] : ${e.message}`); + } + + return { errors, result }; +}; + +export const buildWithEsbuild: BundlerRunFunction = async (bundlerConfigs: BuildOptions) => { + const { build } = await import('esbuild'); + let result: BuildResult | undefined; + const errors = []; + + try { + result = await build(bundlerConfigs); + } catch (e: any) { + errors.push(`[ESBUILD] : ${e.message}`); + } + + return { errors, result }; +}; + +export const buildWithVite: BundlerRunFunction = async (bundlerConfig: InlineConfig) => { + const vite = await import('vite'); + const errors = []; + let result: Awaited<ReturnType<typeof vite.build>> | undefined; + + try { + result = await vite.build(bundlerConfig); + } catch (e: any) { + errors.push(`[VITE] : ${e.message}`); + } + + return { errors, result }; +}; + +export const buildWithRollup: BundlerRunFunction = async (bundlerConfig: RollupOptions) => { + const { rollup } = await import('rollup'); + const errors = []; + let results: RollupOutput[] | undefined; + + try { + const result = await rollup(bundlerConfig); + + // Write out the results. + if (bundlerConfig.output) { + const outputProms = []; + const outputOptions = Array.isArray(bundlerConfig.output) + ? bundlerConfig.output + : [bundlerConfig.output]; + for (const outputOption of outputOptions) { + outputProms.push(result.write(outputOption)); + } + + results = await Promise.all(outputProms); + } + } catch (e: any) { + errors.push(`[ROLLUP] : ${e.message}`); + } + + return { errors, result: results }; +}; + +export const configXpack = ( + config: BundlerConfig, +): Configuration5 & Configuration4 & RspackOptions => { + return { + context: config.workingDir, + entry: config.entry, + mode: 'production', + output: { + path: config.outDir, + filename: `[name].js`, + }, + devtool: 'source-map', + optimization: { + minimize: false, + }, + plugins: config.plugins, + }; +}; + +const configRollupBase = (config: BundlerConfig): RollupOptions => { + // Rollup doesn't have a working dir option. + // So we change the entry name to include the working dir. + const input: RollupOptions['input'] = {}; + for (const [name, entry] of Object.entries(config.entry)) { + input[name] = path.resolve(config.workingDir, entry); + } + + return { + input, + onwarn: (warning, handler) => { + if ( + !/Circular dependency:/.test(warning.message) && + !/Sourcemap is likely to be incorrect/.test(warning.message) + ) { + return handler(warning); + } + }, + output: { + chunkFileNames: 'chunk.[hash].js', + compact: false, + dir: config.outDir, + entryFileNames: '[name].js', + sourcemap: true, + }, + }; +}; + +export const configRspack = (config: BundlerConfig): RspackOptions => { + return configXpack(config); +}; + +export const configWebpack5 = (config: BundlerConfig): Configuration5 => { + return configXpack(config); +}; + +export const configWebpack4 = (config: BundlerConfig): Configuration4 => { + return configXpack(config); +}; + +export const configEsbuild = (config: BundlerConfig): BuildOptions => { + return { + absWorkingDir: config.workingDir, + bundle: true, + chunkNames: 'chunk.[hash]', + entryPoints: config.entry, + entryNames: '[name]', + format: 'cjs', + outdir: config.outDir, + sourcemap: true, + splitting: false, + plugins: config.plugins, + }; +}; + +export const configRollup = (config: BundlerConfig): RollupOptions => { + const baseConfig = configRollupBase(config); + return { + ...baseConfig, + plugins: [ + commonjs(), + nodeResolve({ preferBuiltins: true, browser: true }), + ...(config.plugins || []), + ], + }; +}; + +export const configVite = (config: BundlerConfig): InlineConfig => { + const baseConfig = configRollupBase({ + ...config, + // Remove the plugins to only have Vite ones. + plugins: undefined, + }); + + return { + root: config.workingDir, + build: { + emptyOutDir: false, + assetsDir: '', // Disable assets dir to simplify the test. + minify: false, + rollupOptions: baseConfig, + }, + logLevel: 'silent', + plugins: config.plugins, + }; +}; + +export const allBundlers: Record< + BundlerFullName, + { run: BundlerRunFunction; config: BundlerConfigFunction } +> = { + rspack: { run: buildWithRspack, config: configRspack }, + webpack5: { run: buildWithWebpack5, config: configWebpack5 }, + webpack4: { run: buildWithWebpack4, config: configWebpack4 }, + esbuild: { run: buildWithEsbuild, config: configEsbuild }, + vite: { run: buildWithVite, config: configVite }, + rollup: { run: buildWithRollup, config: configRollup }, +}; 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<OptionsWith${pascalCase}> = ( 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/dev-server/index.ts b/packages/tools/src/commands/dev-server/index.ts new file mode 100644 index 000000000..7c29194cf --- /dev/null +++ b/packages/tools/src/commands/dev-server/index.ts @@ -0,0 +1,173 @@ +// 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 { ROOT } from '@dd/tools/constants'; +import chalk from 'chalk'; +import { Command, Option } from 'clipanion'; +import fs from 'fs'; +import http from 'http'; +import template from 'lodash.template'; +import path from 'path'; + +const MIME_TYPES = { + default: 'application/octet-stream', + html: 'text/html; charset=UTF-8', + js: 'application/javascript', + css: 'text/css', + png: 'image/png', + jpg: 'image/jpg', + gif: 'image/gif', + ico: 'image/x-icon', + svg: 'image/svg+xml', +} as const; + +// Some context to use for templating content with {{something}}. +const CONTEXT: Record<string, readonly string[]> = { + bundler: FULL_NAME_BUNDLERS, +}; + +// Templating regex. +const INTERPOLATE_RX = /{{([\s\S]+?)}}/g; + +// Promise to boolean. +const toBool = [() => true, () => false]; + +type File = { + found: boolean; + ext: keyof typeof MIME_TYPES; + content: string; +}; + +class DevServer extends Command { + static paths = [['dev-server']]; + + static usage = Command.Usage({ + category: `Contribution`, + description: `Run a basic dev server over a specific directory.`, + details: ` + This command will change the package.json values of "exports" so they can be used from another project. + + This is necessary to be sure that the outside project loads the built files and not the dev files. + `, + examples: [ + [`Prepare for link`, `$0 prepare-link`], + [`Revert change`, `$0 prepare-link --revert`], + ], + }); + + port = Option.String('--port', '8000', { + description: 'On which port will the server run.', + }); + + root = Option.String('--root', ROOT, { + description: 'The root directory the server will serve.', + }); + + parseCookie(cookieHeader?: string): Record<string, string> { + if (!cookieHeader) { + return {}; + } + + const cookieString = cookieHeader + .split(';') + .find((c) => c.trim().startsWith('context_cookie=')); + + if (!cookieString) { + return {}; + } + + const [name, ...rest] = cookieString.split('='); + if (!name || !name.trim()) { + return {}; + } + + const value = rest.join('=').trim(); + if (!value) { + return {}; + } + + try { + return JSON.parse(decodeURIComponent(value)); + } catch (e: any) { + throw new Error(`Error parsing cookie: ${e.message}`); + } + } + + getContext(req: http.IncomingMessage): Record<string, string> { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + // Get the initial context from the cookie. + const fileContext: Record<string, string> = this.parseCookie(req.headers.cookie); + + // Verify if we have context passed as parameters (?context_bundler=vite). + for (const [key, value] of url.searchParams) { + if (key.startsWith('context_')) { + const contextKey = key.replace(/^context_/, '') as keyof typeof CONTEXT; + if (Object.keys(CONTEXT).includes(contextKey)) { + if (CONTEXT[contextKey].includes(value)) { + fileContext[contextKey] = value; + } + } + } + } + + return fileContext; + } + + async prepareFile(requestUrl: string, context: Record<string, string>): Promise<File> { + const staticPath = this.root + ? path.isAbsolute(this.root) + ? this.root + : path.resolve(ROOT, this.root) + : ROOT; + const url = new URL(requestUrl, 'http://127.0.0.1'); + const paths = [staticPath, url.pathname]; + + if (url.pathname.endsWith('/')) { + paths.push('index.html'); + } + + const filePath = path.join(...paths); + const pathTraversal = !filePath.startsWith(staticPath); + const exists = await fs.promises.access(filePath).then(...toBool); + const found = !pathTraversal && exists; + const finalPath = found ? filePath : `${staticPath}/404.html`; + const ext = path.extname(finalPath).substring(1).toLowerCase() as File['ext']; + const fileContent = template(await fs.promises.readFile(finalPath, { encoding: 'utf-8' }), { + interpolate: INTERPOLATE_RX, + })(context); + + return { found, ext, content: fileContent }; + } + + async execute() { + http.createServer(async (req, res) => { + try { + const context = this.getContext(req); + const file = await this.prepareFile(req.url || '/', context); + const statusCode = file.found ? 200 : 404; + const mimeType = MIME_TYPES[file.ext] || MIME_TYPES.default; + const c = statusCode === 200 ? chalk.green : chalk.yellow.bold; + + res.writeHead(statusCode, { + 'Set-Cookie': `context_cookie=${encodeURIComponent(JSON.stringify(context))};SameSite=Strict;`, + 'Content-Type': mimeType, + }); + + res.end(file.content); + + console.log(` -> [${c(statusCode.toString())}] ${req.method} ${req.url}`); + } catch (e: any) { + res.writeHead(500, { 'Content-Type': MIME_TYPES.html }); + res.end('Internal Server Error'); + const c = chalk.red.bold; + console.log(` -> [${c('500')}] ${req.method} ${req.url}: ${e.message}`); + console.log(e); + } + }).listen(this.port); + console.log(`Server running at http://127.0.0.1:${this.port}/`); + } +} + +export default [DevServer]; 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` + <details> + + <summary>Configuration</summary> + \`\`\`typescript datadogWebpackPlugin({ ${pluginMeta.config.replace(/;/g, ',')} }); \`\`\` + </details> ` : ''; @@ -152,8 +157,9 @@ const getPluginTemplate = (plugin: Workspace, pluginMeta: PluginMetadata) => { ${titleContent}${bundlerContent ? ` ${bundlerContent}` : ''} > ${intro.split('\n').join('\n> ')} + + #### [📝 Full documentation ➡️](/${plugin.location}#readme) ${configContent} - <kbd>[📝 Full documentation ➡️](/${plugin.location}#readme)</kbd> `; }; @@ -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} - - <kbd>[📝 More details ➡️](/${bundler.location}#readme)</kbd> - `; + 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](?<type>```typescript([\s\S](?!## ))+)/gm; + /## Global Context(!?[\s\S](?!```typescript))+[\s\S](?<type>```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..903ce6a63 100644 --- a/packages/tools/src/helpers.ts +++ b/packages/tools/src/helpers.ts @@ -3,8 +3,17 @@ // Copyright 2019-Present Datadog, Inc. import { ALL_BUNDLERS, SUPPORTED_BUNDLERS } from '@dd/core/constants'; -import { readJsonSync } from '@dd/core/helpers'; -import type { GetPlugins, Logger } from '@dd/core/types'; +import { outputJsonSync, readJsonSync, serializeBuildReport } from '@dd/core/helpers'; +import type { + BuildReport, + BundlerFullName, + BundlerName, + GetCustomPlugins, + GetPlugins, + GlobalContext, + IterableElement, + Logger, +} from '@dd/core/types'; import chalk from 'chalk'; import { execFile, execFileSync } from 'child_process'; import path from 'path'; @@ -18,6 +27,7 @@ export const yellow = chalk.bold.yellow; export const grey = chalk.bold.grey; export const red = chalk.bold.red; export const bgYellow = chalk.bold.bgYellow.black; +export const bgGreen = chalk.bold.bgGreen.black; export const blue = chalk.bold.cyan; export const bold = chalk.bold; export const dim = chalk.dim; @@ -47,7 +57,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}`); }; @@ -111,6 +124,21 @@ export const runAutoFixes = async () => { return errors; }; +export const buildPlugins = (bundlerNames: (BundlerName | BundlerFullName)[]) => { + const bundlersToBuild = Array.from( + new Set(bundlerNames.map((name) => name.replace(/\d/g, ''))), + ); + + return executeSync('yarn', [ + 'workspaces', + 'foreach', + '-Apti', + ...bundlersToBuild.map((bundler) => ['--include', `@datadog/${bundler}-plugin`]).flat(), + 'run', + 'build', + ]); +}; + export const getWorkspaces = async ( filter?: (workspace: SlugLessWorkspace) => boolean, ): Promise<Workspace[]> => { @@ -131,10 +159,15 @@ export const getWorkspaces = async ( // TODO: Update this, it's a bit hacky. export const getSupportedBundlers = (getPlugins: GetPlugins<any>) => { + 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 +180,14 @@ export const getSupportedBundlers = (getPlugins: GetPlugins<any>) => { version: '0', start: 0, bundler: { - name: 'esbuild', - fullName: 'esbuild', + ...bundler, outDir: ROOT, - version: '1.0.0', }, build: { warnings: [], errors: [], logs: [], + bundler, }, inject() {}, pluginNames: [], @@ -217,3 +249,79 @@ export const getBundlerPicture = (bundler: string) => { export const isInternalPluginWorkspace = (workspace: Workspace) => workspace.name.startsWith('@dd/internal-'); + +// Returns a customPlugin to output some debug files. +type CustomPlugins = ReturnType<GetCustomPlugins>; +export const debugFilesPlugins = (context: GlobalContext): CustomPlugins => { + const rollupPlugin: IterableElement<CustomPlugins>['rollup'] = { + writeBundle(options, bundle) { + outputJsonSync( + path.resolve(context.bundler.outDir, `output.${context.bundler.fullName}.json`), + bundle, + ); + }, + }; + + const xpackPlugin: IterableElement<CustomPlugins>['webpack'] & + IterableElement<CustomPlugins>['rspack'] = (compiler) => { + type Stats = Parameters<Parameters<typeof compiler.hooks.done.tap>[1]>[0]; + + compiler.hooks.done.tap('bundler-outputs', (stats: Stats) => { + const statsJson = stats.toJson({ + all: false, + assets: true, + children: true, + chunks: true, + chunkGroupAuxiliary: true, + chunkGroupChildren: true, + chunkGroups: true, + chunkModules: true, + chunkRelations: true, + entrypoints: true, + errors: true, + ids: true, + modules: true, + nestedModules: true, + reasons: true, + relatedAssets: true, + warnings: true, + }); + outputJsonSync( + path.resolve(context.bundler.outDir, `output.${context.bundler.fullName}.json`), + statsJson, + ); + }); + }; + + return [ + { + name: 'build-report', + writeBundle() { + outputJsonSync( + path.resolve(context.bundler.outDir, `report.${context.bundler.fullName}.json`), + serializeBuildReport(context.build), + ); + }, + }, + { + name: 'bundler-outputs', + esbuild: { + setup(build) { + build.onEnd((result) => { + outputJsonSync( + path.resolve( + context.bundler.outDir, + `output.${context.bundler.fullName}.json`, + ), + result.metafile, + ); + }); + }, + }, + rspack: xpackPlugin, + rollup: rollupPlugin, + vite: rollupPlugin, + webpack: xpackPlugin, + }, + ]; +}; diff --git a/packages/tools/src/plugins.ts b/packages/tools/src/plugins.ts new file mode 100644 index 000000000..6cdf93c9c --- /dev/null +++ b/packages/tools/src/plugins.ts @@ -0,0 +1,100 @@ +// 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 { BundlerFullName, Options } from '@dd/core/types'; +import { CONFIG_KEY as ERROR_TRACKING } from '@dd/error-tracking-plugin'; +import { CONFIG_KEY as TELEMETRY } from '@dd/telemetry-plugin'; +import fs from 'fs'; +import path from 'path'; + +import { ROOT } from './constants'; + +export const defaultConfig: Options = { + auth: { + apiKey: process.env.DATADOG_API_KEY, + appKey: process.env.DATADOG_APP_KEY, + }, +}; + +export const fullConfig: Options = { + ...defaultConfig, + [ERROR_TRACKING]: { + sourcemaps: { + bailOnError: false, + dryRun: false, + maxConcurrency: 10, + minifiedPathPrefix: '/', + releaseVersion: '1.0.0', + service: 'error-tracking-build-plugin-sourcemaps', + }, + }, + [TELEMETRY]: { + enableTracing: true, + timestamp: new Date().getTime(), + }, +}; + +// We load the plugins dynamically to avoid esm/cjs issues. +// Using '*/dist/src' to specifically target the bundled files. + +export const getEsbuildPlugin = (config: Options) => { + // eslint-disable-next-line import/no-unresolved + const { datadogEsbuildPlugin } = require('@datadog/esbuild-plugin/dist/src'); + return datadogEsbuildPlugin(config); +}; + +export const getRollupPlugin = (config: Options) => { + // eslint-disable-next-line import/no-unresolved + const { datadogRollupPlugin } = require('@datadog/rollup-plugin/dist/src'); + return datadogRollupPlugin(config); +}; + +export const getRspackPlugin = (config: Options) => { + // eslint-disable-next-line import/no-unresolved + const { datadogRspackPlugin } = require('@datadog/rspack-plugin/dist/src'); + return datadogRspackPlugin(config); +}; + +export const getVitePlugin = (config: Options) => { + // eslint-disable-next-line import/no-unresolved + const { datadogVitePlugin } = require('@datadog/vite-plugin/dist/src'); + return datadogVitePlugin(config); +}; + +export const getWebpack4Plugin = (config: Options) => { + // We'll write a plugin specifically for Webpack4. + const webpackPluginRoot = path.resolve(ROOT, 'packages/published/webpack-plugin/dist/src'); + const webpack4PluginPath = path.resolve(webpackPluginRoot, 'index4.js'); + const webpack5PluginPath = path.resolve(webpackPluginRoot, 'index.js'); + + // First verify if it exists already or not. + if (!fs.existsSync(webpack4PluginPath)) { + // Create the file with the correct imports of Webpack4. + fs.writeFileSync( + webpack4PluginPath, + fs + .readFileSync(webpack5PluginPath, { encoding: 'utf-8' }) + .replace(/require\(('|")webpack("|')\)/g, "require('webpack4')"), + ); + } + + // eslint-disable-next-line import/no-unresolved + const { datadogWebpackPlugin } = require('@datadog/webpack-plugin/dist/src/index4.js'); + return datadogWebpackPlugin(config); +}; + +export const getWebpack5Plugin = (config: Options) => { + // eslint-disable-next-line import/no-unresolved + const { datadogWebpackPlugin } = require('@datadog/webpack-plugin/dist/src/index.js'); + return datadogWebpackPlugin(config); +}; + +export const allPlugins: Record<BundlerFullName, (config: Options) => any> = { + esbuild: getEsbuildPlugin, + rollup: getRollupPlugin, + rspack: getRspackPlugin, + vite: getVitePlugin, + webpack4: getWebpack4Plugin, + webpack5: getWebpack5Plugin, +}; diff --git a/packages/tools/src/rollupConfig.mjs b/packages/tools/src/rollupConfig.mjs index bf6d25d46..0f92613dd 100644 --- a/packages/tools/src/rollupConfig.mjs +++ b/packages/tools/src/rollupConfig.mjs @@ -4,20 +4,27 @@ import babel from '@rollup/plugin-babel'; import commonjs from '@rollup/plugin-commonjs'; +import esmShim from '@rollup/plugin-esm-shim'; 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 +36,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 +55,101 @@ export const bundle = (packageJson, config) => ({ nodeResolve({ preferBuiltins: true }), ...config.plugins, ], - output: { +}); + +/** + * @param {{module: string; main: string;}} packageJson + * @param {Partial<import('rollup').OutputOptions>} overrides + * @returns {import('rollup').OutputOptions} + */ +const getOutput = (packageJson, overrides = {}) => { + const filename = overrides.format === 'esm' ? packageJson.module : packageJson.main; + const plugins = [terser()]; + + // Inject ESM shims to support __dirname and co. + if (overrides.format === 'esm') { + plugins.push(esmShim()); + } + + return { exports: 'named', sourcemap: true, - ...config.output, - }, -}); + entryFileNames: `[name]${path.extname(filename)}`, + dir: path.dirname(filename), + plugins, + format: 'cjs', + globals: { + globalThis: 'window', + }, + // 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..f8690b8fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1449,8 +1449,10 @@ __metadata: "@dd/tools": "workspace:*" "@rollup/plugin-babel": "npm:6.0.4" "@rollup/plugin-commonjs": "npm:28.0.1" + "@rollup/plugin-esm-shim": "npm:0.1.7" "@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" @@ -1482,8 +1484,10 @@ __metadata: "@dd/tools": "workspace:*" "@rollup/plugin-babel": "npm:6.0.4" "@rollup/plugin-commonjs": "npm:28.0.1" + "@rollup/plugin-esm-shim": "npm:0.1.7" "@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 +1508,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: @@ -1515,8 +1519,10 @@ __metadata: "@dd/tools": "workspace:*" "@rollup/plugin-babel": "npm:6.0.4" "@rollup/plugin-commonjs": "npm:28.0.1" + "@rollup/plugin-esm-shim": "npm:0.1.7" "@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" @@ -1548,8 +1554,10 @@ __metadata: "@dd/tools": "workspace:*" "@rollup/plugin-babel": "npm:6.0.4" "@rollup/plugin-commonjs": "npm:28.0.1" + "@rollup/plugin-esm-shim": "npm:0.1.7" "@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" @@ -1581,8 +1589,10 @@ __metadata: "@dd/tools": "workspace:*" "@rollup/plugin-babel": "npm:6.0.4" "@rollup/plugin-commonjs": "npm:28.0.1" + "@rollup/plugin-esm-shim": "npm:0.1.7" "@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,21 +1628,34 @@ __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:*" "@dd/internal-injection-plugin": "workspace:*" - "@dd/rum-plugin": "workspace:*" "@dd/telemetry-plugin": "workspace:*" chalk: "npm:2.3.1" unplugin: "npm:1.16.0" @@ -1644,7 +1667,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 @@ -1674,17 +1696,6 @@ __metadata: languageName: unknown linkType: soft -"@dd/rum-plugin@workspace:*, @dd/rum-plugin@workspace:packages/plugins/rum": - version: 0.0.0-use.local - resolution: "@dd/rum-plugin@workspace:packages/plugins/rum" - 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/telemetry-plugin@workspace:*, @dd/telemetry-plugin@workspace:packages/plugins/telemetry": version: 0.0.0-use.local resolution: "@dd/telemetry-plugin@workspace:packages/plugins/telemetry" @@ -1712,15 +1723,18 @@ __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:*" + "@dd/tools": "workspace:*" + "@playwright/test": "npm:1.49.1" "@rollup/plugin-commonjs": "npm:28.0.1" "@rspack/core": "npm:1.1.2" "@types/faker": "npm:5.5.9" @@ -1749,21 +1763,40 @@ __metadata: version: 0.0.0-use.local resolution: "@dd/tools@workspace:packages/tools" dependencies: + "@datadog/esbuild-plugin": "workspace:*" + "@datadog/rollup-plugin": "workspace:*" + "@datadog/rspack-plugin": "workspace:*" + "@datadog/vite-plugin": "workspace:*" + "@datadog/webpack-plugin": "workspace:*" "@dd/assets": "workspace:*" "@dd/core": "workspace:*" + "@dd/error-tracking-plugin": "workspace:*" + "@dd/telemetry-plugin": "workspace:*" "@inquirer/checkbox": "npm:2.3.3" "@inquirer/input": "npm:2.1.7" "@inquirer/select": "npm:2.3.3" + "@rollup/plugin-commonjs": "npm:28.0.1" + "@rollup/plugin-esm-shim": "npm:0.1.7" + "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rspack/core": "npm:1.1.2" "@types/chalk": "npm:2.2.0" "@types/glob": "npm:7.1.4" + "@types/lodash.template": "npm:^4" "@types/node": "npm:^18" + "@types/webpack4": "npm:@types/webpack@4.41.38" chalk: "npm:2.3.1" clipanion: "npm:4.0.0-rc.3" + esbuild: "npm:0.24.0" glob: "npm:7.1.6" + lodash.template: "npm:4.5.0" outdent: "npm:0.8.0" ts-node: "npm:10.9.2" typanion: "npm:3.14.0" typescript: "npm:5.4.3" + vite: "npm:5.4.10" + webpack: "npm:5.92.1" + webpack4: "npm:webpack@4.47.0" + webpack5: "npm:webpack@5.92.1" languageName: unknown linkType: soft @@ -2685,6 +2718,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:1.49.1": + version: 1.49.1 + resolution: "@playwright/test@npm:1.49.1" + dependencies: + playwright: "npm:1.49.1" + bin: + playwright: cli.js + checksum: 10/bb0d5eda58ee0b5bbca732d2aa57782fadf420d101e08e16d5760179459c667907bd8d224ee3d6f43f3088378e377ef63d32ed605fec37605debf217c3efe8da + languageName: node + linkType: hard + "@rollup/plugin-babel@npm:6.0.4": version: 6.0.4 resolution: "@rollup/plugin-babel@npm:6.0.4" @@ -2724,6 +2768,20 @@ __metadata: languageName: node linkType: hard +"@rollup/plugin-esm-shim@npm:0.1.7": + version: 0.1.7 + resolution: "@rollup/plugin-esm-shim@npm:0.1.7" + dependencies: + magic-string: "npm:^0.30.3" + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10/c3cc762ce729d2f87b9b2d4d3e90af49dc91036860cca95dd103d175cf5425e72068219d7300d2a006b0408cb0a9575a8c79f1534e8625663ed996d9ea58b397 + languageName: node + linkType: hard + "@rollup/plugin-json@npm:6.1.0": version: 6.1.0 resolution: "@rollup/plugin-json@npm:6.1.0" @@ -2756,6 +2814,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" @@ -3258,6 +3332,22 @@ __metadata: languageName: node linkType: hard +"@types/lodash.template@npm:^4": + version: 4.5.3 + resolution: "@types/lodash.template@npm:4.5.3" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/7c9d32b8d81959c8131223da191d2dd55d8ddfd4f0997bf18565b65cb6ba3b8087d39f458071031494b23f5dac857b9cbdba00df8b32c2d8178e89478b0b44ee + languageName: node + linkType: hard + +"@types/lodash@npm:*": + version: 4.17.14 + resolution: "@types/lodash@npm:4.17.14" + checksum: 10/6ee40725f3e192f5ef1f493caca19210aa7acd7adc3136b8dba84d418a35be0abea0668105aed9f696ad62a54310a9c0d328971ad4b157f5bcda700424ed5aae + languageName: node + linkType: hard + "@types/minimatch@npm:*": version: 5.1.2 resolution: "@types/minimatch@npm:5.1.2" @@ -6749,6 +6839,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10/6b5b6f5692372446ff81cf9501c76e3e0459a4852b3b5f1fc72c103198c125a6b8c72f5f166bdd76ffb2fca261e7f6ee5565daf80dca6e571e55bcc589cc1256 + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:^1.2.7": version: 1.2.13 resolution: "fsevents@npm:1.2.13" @@ -6770,6 +6870,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A^1.2.7#optional!builtin<compat/fsevents>": version: 1.2.13 resolution: "fsevents@patch:fsevents@npm%3A1.2.13#optional!builtin<compat/fsevents>::version=1.2.13&hash=d11327" @@ -8638,6 +8747,13 @@ __metadata: languageName: node linkType: hard +"lodash._reinterpolate@npm:^3.0.0": + version: 3.0.0 + resolution: "lodash._reinterpolate@npm:3.0.0" + checksum: 10/06d2d5f33169604fa5e9f27b6067ed9fb85d51a84202a656901e5ffb63b426781a601508466f039c720af111b0c685d12f1a5c14ff8df5d5f27e491e562784b2 + languageName: node + linkType: hard + "lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" @@ -8659,6 +8775,25 @@ __metadata: languageName: node linkType: hard +"lodash.template@npm:4.5.0": + version: 4.5.0 + resolution: "lodash.template@npm:4.5.0" + dependencies: + lodash._reinterpolate: "npm:^3.0.0" + lodash.templatesettings: "npm:^4.0.0" + checksum: 10/56d18ba410ff591f22e4dd2974d21fdcfcba392f2d462ee4b7a7368c3a28ac1cb38a73f1d1c9eb8b8cae26f8e0ae2c28058f7488b4ffa9da84a6096bc77691db + languageName: node + linkType: hard + +"lodash.templatesettings@npm:^4.0.0": + version: 4.2.0 + resolution: "lodash.templatesettings@npm:4.2.0" + dependencies: + lodash._reinterpolate: "npm:^3.0.0" + checksum: 10/ef470fa8b66b6370b08fb0709c1577e4bf72cc3d1e8639196577db827915808ec138861cbc791b295a24fbfe7b78dd26bcfc8f237e5d94df383a3125ae6f5339 + languageName: node + linkType: hard + "log-symbols@npm:^3.0.0": version: 3.0.0 resolution: "log-symbols@npm:3.0.0" @@ -9711,6 +9846,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.49.1": + version: 1.49.1 + resolution: "playwright-core@npm:1.49.1" + bin: + playwright-core: cli.js + checksum: 10/baa39a53024ec7744708410f2b952ac3aa2e1a6d311dabfa303523712848eba142fce5c20f1b2ed2a66fbd9a415d22ea8642b0f70423360aaebd4b41c47d364e + languageName: node + linkType: hard + +"playwright@npm:1.49.1": + version: 1.49.1 + resolution: "playwright@npm:1.49.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.49.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10/49fb063f4a107b8090f66d2d351ebd51fbb66843a8f95a161fa0c0e0b5156515961e75cc10f4249f61b9d2af51f762dda505c62b096d8f61cd47d1ff73ab39d2 + languageName: node + linkType: hard + "please-upgrade-node@npm:^3.2.0": version: 3.2.0 resolution: "please-upgrade-node@npm:3.2.0" @@ -9802,14 +9961,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 +10851,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 +11379,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"