From f8fb9fc64dc597acaba114cc0707daa3027c7854 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 14:48:17 -0700 Subject: [PATCH 01/25] Check that webpack 4 and 5 apps can bundle Web Chat Adds a webpack-test job to pull request validation: a consumer app installs the packed tarballs from the build job with webpack 4.47.0 or 5.107.2 and React 16.8.6 or 18.3.1, bundles them, and loads the bundle in jsdom. webpack 4 legs do not fail the job yet: "@redux-devtools/extension" and "botframework-webchat/schema.js" are only resolvable through "exports". --- .github/workflows/pull-request-validation.yml | 37 ++++++++++++ __tests__/webpack/build.js | 56 +++++++++++++++++++ __tests__/webpack/load.js | 36 ++++++++++++ __tests__/webpack/package.json | 13 +++++ __tests__/webpack/src/index.js | 28 ++++++++++ __tests__/webpack/test.sh | 26 +++++++++ 6 files changed, 196 insertions(+) create mode 100644 __tests__/webpack/build.js create mode 100644 __tests__/webpack/load.js create mode 100644 __tests__/webpack/package.json create mode 100644 __tests__/webpack/src/index.js create mode 100755 __tests__/webpack/test.sh diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 03e63f0b85..251a10d4e6 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -281,6 +281,43 @@ jobs: run: ../../node_modules/.bin/tsd -t ./dist/botframework-webchat-core.d.mts working-directory: ./packages/core + webpack-test: + name: Webpack test (webpack@${{ matrix.webpack }}, react@${{ matrix.react }}) + needs: + - build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + react: [16.8.6, 18.3.1] # TODO: Add React 19 when react-film supports it. + webpack: [4.47.0, 5.107.2] + timeout-minutes: 10 + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + sparse-checkout: __tests__/webpack + + - name: Use Node.js ${{ env.node-version }} + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ env.node-version }} + cache: npm + cache-dependency-path: __tests__/webpack/package.json + + - name: Download tarballs + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: tarballs + + - name: Extract tarballs + run: tar -xvf tarballs.tar + + # TODO: Remove continue-on-error when webpack 4 resolves "@redux-devtools/extension" and "botframework-webchat/schema.js", both are "exports"-only. + - continue-on-error: ${{ startsWith(matrix.webpack, '4.') }} + name: Bundle with webpack@${{ matrix.webpack }} and react@${{ matrix.react }} + run: ./__tests__/webpack/test.sh ${{ matrix.webpack }} ${{ matrix.react }} ./packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz + html-test: name: HTML test (${{ format('{0}/{1}', matrix.shard-index, matrix.shard-count) }}) needs: diff --git a/__tests__/webpack/build.js b/__tests__/webpack/build.js new file mode 100644 index 0000000000..e85342e88a --- /dev/null +++ b/__tests__/webpack/build.js @@ -0,0 +1,56 @@ +// Bundles ./src/index.js with the installed webpack (4 or 5) like a consumer app would: fails on errors, lists warnings. + +const { resolve } = require('path'); +const webpack = require('webpack'); + +// Multi-line messages need "%0A" in GitHub Actions annotations. +const annotate = (command, message) => console.log(`::${command}::${message.replace(/\n/gu, '%0A')}`); + +console.log(`Bundling with webpack@${webpack.version} and react@${require('react/package.json').version}.`); + +webpack( + { + entry: resolve(__dirname, 'src/index.js'), + mode: 'production', + module: { + rules: webpack.version.startsWith('4.') + ? [ + // Like create-react-app@4: webpack 4 cannot parse ES2020 (e.g. `?.`), so apps transpile node_modules too. + { + test: /\.m?js$/u, + type: 'javascript/auto', // Lets .mjs import named exports from CommonJS. + use: { + loader: 'babel-loader', + options: { + babelrc: false, + configFile: false, + presets: [['@babel/preset-env', { modules: false, targets: 'chrome 70' }]], + sourceType: 'unambiguous' + } + } + } + ] + : [] + }, + // "auto" (webpack 5 default) needs `document.currentScript` to load chunks, which jsdom does not set in load.js. + output: { filename: 'main.js', path: resolve(__dirname, 'dist'), publicPath: '/' }, + performance: { hints: false } + }, + (error, stats) => { + if (error) { + console.error(error); + process.exit(1); + } + + // webpack 4 reports strings, webpack 5 reports objects. + const { errors, warnings } = stats.toJson({ all: false, errors: true, warnings: true }); + const toString = entry => (typeof entry === 'string' ? entry : `${entry.moduleName || ''}\n${entry.message}`); + + warnings.forEach(warning => annotate('warning', toString(warning))); + errors.forEach(error => annotate('error', toString(error))); + + console.log(`${errors.length} error(s), ${warnings.length} warning(s).`); + + process.exit(errors.length ? 1 : 0); + } +); diff --git a/__tests__/webpack/load.js b/__tests__/webpack/load.js new file mode 100644 index 0000000000..7192b9df61 --- /dev/null +++ b/__tests__/webpack/load.js @@ -0,0 +1,36 @@ +// Runs the bundle in jsdom: it must not throw and every import must be defined. + +const { readFileSync } = require('fs'); +const { JSDOM, VirtualConsole } = require('jsdom'); +const { resolve } = require('path'); + +const errors = []; +const virtualConsole = new VirtualConsole(); + +virtualConsole.on('jsdomError', error => errors.push(error)); + +const { window } = new JSDOM('', { runScripts: 'outside-only', virtualConsole }); + +// Web Chat needs Web Streams and text encoding, which jsdom does not implement but Node.js does. +['ReadableStream', 'TextDecoder', 'TextEncoder', 'TransformStream', 'WritableStream'].forEach(name => { + window[name] = globalThis[name]; +}); + +try { + window.eval(readFileSync(resolve(__dirname, 'dist/main.js'), 'utf8')); +} catch (error) { + errors.push(error); +} + +window.WebChatConsumer + ? Object.entries(window.WebChatConsumer).forEach( + ([name, value]) => value || errors.push(new Error(`Import "${name}" is ${value}.`)) + ) + : errors.push(new Error('The bundle did not run to the end.')); + +// Multi-line messages need "%0A" in GitHub Actions annotations. +errors.forEach(error => console.log(`::error::${String(error?.stack || error).replace(/\n/gu, '%0A')}`)); + +console.log(errors.length ? `${errors.length} error(s).` : 'Loaded the bundle, every import is defined.'); + +process.exit(errors.length ? 1 : 0); diff --git a/__tests__/webpack/package.json b/__tests__/webpack/package.json new file mode 100644 index 0000000000..97388faac5 --- /dev/null +++ b/__tests__/webpack/package.json @@ -0,0 +1,13 @@ +{ + "name": "@msinternal/botframework-webchat-webpack-consumer", + "version": "0.0.0-0", + "description": "A consumer app bundling Web Chat with webpack 4 or 5, see test.sh", + "private": true, + "license": "MIT", + "devDependencies": { + "@babel/core": "7.29.7", + "@babel/preset-env": "7.29.7", + "babel-loader": "8.4.1", + "jsdom": "30.1.3" + } +} diff --git a/__tests__/webpack/src/index.js b/__tests__/webpack/src/index.js new file mode 100644 index 0000000000..a8727c05ed --- /dev/null +++ b/__tests__/webpack/src/index.js @@ -0,0 +1,28 @@ +import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; +import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; +import { Composer } from 'botframework-webchat/component.js'; +import { useActivities } from 'botframework-webchat/hook.js'; +import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; +import { createElement } from 'react'; +import { render } from 'react-dom'; + +// Renders Web Chat in both themes like apps do. load.js only checks that every import is defined. +window.WebChatConsumer = Object.freeze({ + Composer, + createActivityPolymiddleware, + FluentThemeProvider, + ReactWebChat, + render: token => { + const directLine = createDirectLine({ token }); + + renderWebChat({ directLine }, document.getElementById('webchat')); + + // TODO: Use createRoot() from "react-dom/client" when React 19 joins the matrix. + render( + createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })), + document.getElementById('fluent-webchat') + ); + }, + renderWebChat, + useActivities +}); diff --git a/__tests__/webpack/test.sh b/__tests__/webpack/test.sh new file mode 100755 index 0000000000..b41db9dad3 --- /dev/null +++ b/__tests__/webpack/test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Bundles Web Chat in a consumer app, like apps install it from npm, with the given webpack and React versions. +# +# Usage: test.sh ... +# The app is created under $APP_DIR (default: a new temporary directory) so it does not see the monorepo node_modules. + +set -euo pipefail + +webpack_version=$1 +react_version=$2 +shift 2 + +app_dir=${APP_DIR:-$(mktemp -d)} +mapfile -t tarballs < <(realpath "$@") + +cp -R "$(dirname "$0")/." "$app_dir" +cd "$app_dir" + +npm install --no-audit --no-fund --prefer-offline \ + "react@$react_version" \ + "react-dom@$react_version" \ + "webpack@$webpack_version" \ + "${tarballs[@]}" + +node build.js +node load.js From 4e427d420a9fb050f5b18614a5a347a6d2fedf2a Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 15:14:56 -0700 Subject: [PATCH 02/25] Document the webpack 4 workarounds and check them in CI - webpack 4 legs apply the workarounds from docs/HOSTING.md and must pass - Check names use major versions only - Drop the unused render() from the consumer app, also import decorator.js - Resolve dependencies fresh, without --prefer-offline --- .github/workflows/pull-request-validation.yml | 22 +++++-- __tests__/webpack/build.js | 23 +------ __tests__/webpack/src/index.js | 20 ++---- __tests__/webpack/test.sh | 2 +- __tests__/webpack/webpack4.config.js | 55 ++++++++++++++++ docs/HOSTING.md | 65 +++++++++++++++++++ 6 files changed, 143 insertions(+), 44 deletions(-) create mode 100644 __tests__/webpack/webpack4.config.js diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 251a10d4e6..f9b555583f 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -282,15 +282,25 @@ jobs: working-directory: ./packages/core webpack-test: - name: Webpack test (webpack@${{ matrix.webpack }}, react@${{ matrix.react }}) + name: Webpack test (webpack ${{ matrix.webpack }}, React ${{ matrix.react }}) needs: - build runs-on: ubuntu-latest strategy: fail-fast: false matrix: - react: [16.8.6, 18.3.1] # TODO: Add React 19 when react-film supports it. - webpack: [4.47.0, 5.107.2] + # Major versions only, so check names stay the same when versions are bumped. + react: [16, 18] # TODO: Add React 19 when react-film supports it. + webpack: [4, 5] + include: + - react: 16 + react-version: 16.8.6 + - react: 18 + react-version: 18.3.1 + - webpack: 4 + webpack-version: 4.47.0 + - webpack: 5 + webpack-version: 5.107.2 timeout-minutes: 10 steps: @@ -313,10 +323,8 @@ jobs: - name: Extract tarballs run: tar -xvf tarballs.tar - # TODO: Remove continue-on-error when webpack 4 resolves "@redux-devtools/extension" and "botframework-webchat/schema.js", both are "exports"-only. - - continue-on-error: ${{ startsWith(matrix.webpack, '4.') }} - name: Bundle with webpack@${{ matrix.webpack }} and react@${{ matrix.react }} - run: ./__tests__/webpack/test.sh ${{ matrix.webpack }} ${{ matrix.react }} ./packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz + - name: Bundle with webpack@${{ matrix.webpack-version }} and react@${{ matrix.react-version }} + run: ./__tests__/webpack/test.sh ${{ matrix.webpack-version }} ${{ matrix.react-version }} ./packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz html-test: name: HTML test (${{ format('{0}/{1}', matrix.shard-index, matrix.shard-count) }}) diff --git a/__tests__/webpack/build.js b/__tests__/webpack/build.js index e85342e88a..27da1a3351 100644 --- a/__tests__/webpack/build.js +++ b/__tests__/webpack/build.js @@ -12,29 +12,10 @@ webpack( { entry: resolve(__dirname, 'src/index.js'), mode: 'production', - module: { - rules: webpack.version.startsWith('4.') - ? [ - // Like create-react-app@4: webpack 4 cannot parse ES2020 (e.g. `?.`), so apps transpile node_modules too. - { - test: /\.m?js$/u, - type: 'javascript/auto', // Lets .mjs import named exports from CommonJS. - use: { - loader: 'babel-loader', - options: { - babelrc: false, - configFile: false, - presets: [['@babel/preset-env', { modules: false, targets: 'chrome 70' }]], - sourceType: 'unambiguous' - } - } - } - ] - : [] - }, // "auto" (webpack 5 default) needs `document.currentScript` to load chunks, which jsdom does not set in load.js. output: { filename: 'main.js', path: resolve(__dirname, 'dist'), publicPath: '/' }, - performance: { hints: false } + performance: { hints: false }, + ...(webpack.version.startsWith('4.') && require('./webpack4.config.js')) }, (error, stats) => { if (error) { diff --git a/__tests__/webpack/src/index.js b/__tests__/webpack/src/index.js index a8727c05ed..648c79cfc7 100644 --- a/__tests__/webpack/src/index.js +++ b/__tests__/webpack/src/index.js @@ -1,28 +1,18 @@ import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; import { Composer } from 'botframework-webchat/component.js'; +import { WebChatDecorator } from 'botframework-webchat/decorator.js'; import { useActivities } from 'botframework-webchat/hook.js'; import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; -import { createElement } from 'react'; -import { render } from 'react-dom'; -// Renders Web Chat in both themes like apps do. load.js only checks that every import is defined. +// load.js checks that every import is defined. "botframework-webchat/schema.js" is imported by the Fluent theme. window.WebChatConsumer = Object.freeze({ Composer, createActivityPolymiddleware, + createDirectLine, FluentThemeProvider, ReactWebChat, - render: token => { - const directLine = createDirectLine({ token }); - - renderWebChat({ directLine }, document.getElementById('webchat')); - - // TODO: Use createRoot() from "react-dom/client" when React 19 joins the matrix. - render( - createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })), - document.getElementById('fluent-webchat') - ); - }, renderWebChat, - useActivities + useActivities, + WebChatDecorator }); diff --git a/__tests__/webpack/test.sh b/__tests__/webpack/test.sh index b41db9dad3..736d74679e 100755 --- a/__tests__/webpack/test.sh +++ b/__tests__/webpack/test.sh @@ -16,7 +16,7 @@ mapfile -t tarballs < <(realpath "$@") cp -R "$(dirname "$0")/." "$app_dir" cd "$app_dir" -npm install --no-audit --no-fund --prefer-offline \ +npm install --no-audit --no-fund \ "react@$react_version" \ "react-dom@$react_version" \ "webpack@$webpack_version" \ diff --git a/__tests__/webpack/webpack4.config.js b/__tests__/webpack/webpack4.config.js new file mode 100644 index 0000000000..8191a49faf --- /dev/null +++ b/__tests__/webpack/webpack4.config.js @@ -0,0 +1,55 @@ +// The webpack 4 workarounds from docs/HOSTING.md ("Bundling with webpack 4"), keep both the same: CI checks the documented ones. + +const { sep } = require('path'); + +// Packages that ship syntax newer than ES2019 (e.g. `?.`, `#private`, `1_000`), which webpack 4 cannot parse. +const transpilePackages = [ + 'botframework-webchat', + 'botframework-webchat-api', + 'botframework-webchat-component', + 'botframework-webchat-core', + 'botframework-webchat-fluent-theme', + 'event-as-promise', + 'htmlparser2', + 'iter-fest', + 'katex', + 'micromark-util-decode-numeric-character-reference', + 'micromark-util-sanitize-uri', + 'microsoft-cognitiveservices-speech-sdk', + 'mime', + 'react-dictate-button', + 'react-film', + 'swiper', + 'valibot', + 'web-speech-cognitive-services' +]; + +module.exports = { + module: { + rules: [ + // Lets .mjs import named exports from CommonJS, like create-react-app@4. + { include: /node_modules/u, test: /\.mjs$/u, type: 'javascript/auto' }, + { + // The innermost package in the path: "node_modules/react-film/node_modules/mime/…" is "mime". + include: path => transpilePackages.includes(path.split(`${sep}node_modules${sep}`).pop().split(sep)[0]), + test: /\.m?js$/u, + use: { + loader: 'babel-loader', + options: { + babelrc: false, + configFile: false, + presets: [['@babel/preset-env', { modules: false, targets: 'chrome 70' }]], // ES2018, which webpack 4 parses. + sourceType: 'unambiguous' + } + } + } + ] + }, + resolve: { + alias: { + // Both resolve only through "exports" in package.json, which webpack 4 does not support. + '@redux-devtools/extension$': '@redux-devtools/extension/lib/index.js', + 'botframework-webchat/schema.js$': 'botframework-webchat/dist/botframework-webchat.schema.js' + } + } +}; diff --git a/docs/HOSTING.md b/docs/HOSTING.md index 6c533d5054..e47abc25cf 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -189,6 +189,71 @@ Then, import Web Chat into your code: import { createDirectLine, ReactWebChat, renderWebChat } from 'botframework-webchat'; ``` +#### Bundling with webpack 4 + +webpack 4 supports neither `"exports"` in `package.json` nor syntax newer than ES2019. To bundle Web Chat 4.19, add the following to `webpack.config.js` and install `babel-loader@8` and `@babel/preset-env`. Our CI tests it with webpack 4.47.0. + +```js +const { sep } = require('path'); + +// Packages that ship syntax newer than ES2019 (e.g. `?.`, `#private`, `1_000`), which webpack 4 cannot parse. +const transpilePackages = [ + 'botframework-webchat', + 'botframework-webchat-api', + 'botframework-webchat-component', + 'botframework-webchat-core', + 'botframework-webchat-fluent-theme', + 'event-as-promise', + 'htmlparser2', + 'iter-fest', + 'katex', + 'micromark-util-decode-numeric-character-reference', + 'micromark-util-sanitize-uri', + 'microsoft-cognitiveservices-speech-sdk', + 'mime', + 'react-dictate-button', + 'react-film', + 'swiper', + 'valibot', + 'web-speech-cognitive-services' +]; + +module.exports = { + // ... + module: { + rules: [ + // Lets .mjs import named exports from CommonJS, like create-react-app@4. + { include: /node_modules/u, test: /\.mjs$/u, type: 'javascript/auto' }, + { + // The innermost package in the path: "node_modules/react-film/node_modules/mime/…" is "mime". + include: path => transpilePackages.includes(path.split(`${sep}node_modules${sep}`).pop().split(sep)[0]), + test: /\.m?js$/u, + use: { + loader: 'babel-loader', + options: { + babelrc: false, + configFile: false, + presets: [['@babel/preset-env', { modules: false, targets: 'chrome 70' }]], // ES2018, which webpack 4 parses. + sourceType: 'unambiguous' + } + } + } + ] + }, + resolve: { + alias: { + // Both resolve only through "exports" in package.json, which webpack 4 does not support. + '@redux-devtools/extension$': '@redux-devtools/extension/lib/index.js', + 'botframework-webchat/schema.js$': 'botframework-webchat/dist/botframework-webchat.schema.js' + } + } +}; +``` + +- If your app already transpiles `node_modules`, add these packages to its rule instead. +- `@redux-devtools/extension` 4 has no `"main"`. Instead of the alias, you can pin it to 3.3.0, which has one, with `"overrides"` (npm, pnpm) or `"resolutions"` (Yarn). +- Other subpaths, such as `botframework-webchat/hook.js`, have a file at the package root and need no alias. + ## Step 2: Constructing chat adapter > Note: Web Chat is a UI component, does not contains any network code, and is not responsible for communicating with the chat service. Web Chat render the messages (a.k.a. activities) provided by the chat adapter. From 57273cae8d66ac0d7d522da337e544bc7762a8e7 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 15:34:41 -0700 Subject: [PATCH 03/25] Transpile only the dependencies that need it for webpack 4, not Web Chat --- __tests__/webpack/webpack4.config.js | 9 ++------- docs/HOSTING.md | 8 +------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/__tests__/webpack/webpack4.config.js b/__tests__/webpack/webpack4.config.js index 8191a49faf..677a52658e 100644 --- a/__tests__/webpack/webpack4.config.js +++ b/__tests__/webpack/webpack4.config.js @@ -1,18 +1,13 @@ // The webpack 4 workarounds from docs/HOSTING.md ("Bundling with webpack 4"), keep both the same: CI checks the documented ones. +// Web Chat itself ships ES2019 and is deliberately not transpiled, so these legs fail if it starts shipping newer syntax. const { sep } = require('path'); -// Packages that ship syntax newer than ES2019 (e.g. `?.`, `#private`, `1_000`), which webpack 4 cannot parse. +// Dependencies of Web Chat that ship syntax newer than ES2019 (e.g. `?.`, `#private`, `1_000`), which webpack 4 cannot parse. const transpilePackages = [ - 'botframework-webchat', - 'botframework-webchat-api', - 'botframework-webchat-component', - 'botframework-webchat-core', - 'botframework-webchat-fluent-theme', 'event-as-promise', 'htmlparser2', 'iter-fest', - 'katex', 'micromark-util-decode-numeric-character-reference', 'micromark-util-sanitize-uri', 'microsoft-cognitiveservices-speech-sdk', diff --git a/docs/HOSTING.md b/docs/HOSTING.md index e47abc25cf..c873975ea4 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -196,17 +196,11 @@ webpack 4 supports neither `"exports"` in `package.json` nor syntax newer than E ```js const { sep } = require('path'); -// Packages that ship syntax newer than ES2019 (e.g. `?.`, `#private`, `1_000`), which webpack 4 cannot parse. +// Dependencies of Web Chat that ship syntax newer than ES2019 (e.g. `?.`, `#private`, `1_000`), which webpack 4 cannot parse. const transpilePackages = [ - 'botframework-webchat', - 'botframework-webchat-api', - 'botframework-webchat-component', - 'botframework-webchat-core', - 'botframework-webchat-fluent-theme', 'event-as-promise', 'htmlparser2', 'iter-fest', - 'katex', 'micromark-util-decode-numeric-character-reference', 'micromark-util-sanitize-uri', 'microsoft-cognitiveservices-speech-sdk', From 86604755ff45ce27be79578b2d013e1589e587d3 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 15:46:10 -0700 Subject: [PATCH 04/25] Add the changelog fragment --- .changelog/5977-fix-ci-webpack-consumers.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/5977-fix-ci-webpack-consumers.md diff --git a/.changelog/5977-fix-ci-webpack-consumers.md b/.changelog/5977-fix-ci-webpack-consumers.md new file mode 100644 index 0000000000..d0b292d426 --- /dev/null +++ b/.changelog/5977-fix-ci-webpack-consumers.md @@ -0,0 +1,3 @@ +### Added + +- 👷🏻 Pull request validation: added bundling Web Chat in webpack 4 and 5 apps with React 16 and 18, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) From cadcd12fa457f50be25da70e012a6498ba06454c Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 15:46:45 -0700 Subject: [PATCH 05/25] Add the webpack 4 docs to the changelog fragment --- .changelog/5977-fix-ci-webpack-consumers.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changelog/5977-fix-ci-webpack-consumers.md b/.changelog/5977-fix-ci-webpack-consumers.md index d0b292d426..de238853b3 100644 --- a/.changelog/5977-fix-ci-webpack-consumers.md +++ b/.changelog/5977-fix-ci-webpack-consumers.md @@ -1,3 +1,4 @@ ### Added - 👷🏻 Pull request validation: added bundling Web Chat in webpack 4 and 5 apps with React 16 and 18, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) +- Docs: added [workarounds for webpack 4](./docs/HOSTING.md#bundling-with-webpack-4), in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) From 9f5e5177aff00abbc33da6e918b776fee905380e Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 16:22:02 -0700 Subject: [PATCH 06/25] Build the instrumented directlinespeech with the release browser targets The test env replaced preset-env with targets: { node: 'current' }, so the instrumented lib/ that CI packs kept optional chaining, which webpack 4 cannot parse. The test env now only adds babel-plugin-istanbul and inherits the release preset-env. --- packages/directlinespeech/babel.config.json | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/directlinespeech/babel.config.json b/packages/directlinespeech/babel.config.json index aa7f398c26..00550a7669 100644 --- a/packages/directlinespeech/babel.config.json +++ b/packages/directlinespeech/babel.config.json @@ -1,18 +1,7 @@ { "env": { "test": { - "plugins": ["babel-plugin-istanbul"], - "presets": [ - [ - "@babel/preset-env", - { - "modules": "commonjs", - "targets": { - "node": "current" - } - } - ] - ] + "plugins": ["babel-plugin-istanbul"] } }, "plugins": [ From 78dad1a163eb06b8c976157c6b3e959397a4dbd6 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 16:39:46 -0700 Subject: [PATCH 07/25] Ship a root schema.js so webpack 4 resolves botframework-webchat/schema.js without an alias --- .changelog/5977-fix-ci-webpack-consumers.md | 4 ++++ __tests__/webpack/webpack4.config.js | 5 ++--- docs/HOSTING.md | 7 +++---- packages/bundle/schema.js | 3 +++ 4 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 packages/bundle/schema.js diff --git a/.changelog/5977-fix-ci-webpack-consumers.md b/.changelog/5977-fix-ci-webpack-consumers.md index de238853b3..849c5804f5 100644 --- a/.changelog/5977-fix-ci-webpack-consumers.md +++ b/.changelog/5977-fix-ci-webpack-consumers.md @@ -2,3 +2,7 @@ - 👷🏻 Pull request validation: added bundling Web Chat in webpack 4 and 5 apps with React 16 and 18, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) - Docs: added [workarounds for webpack 4](./docs/HOSTING.md#bundling-with-webpack-4), in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) + +### Fixed + +- Fixed `botframework-webchat/schema.js` failing to resolve in webpack 4, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) diff --git a/__tests__/webpack/webpack4.config.js b/__tests__/webpack/webpack4.config.js index 677a52658e..5345020cca 100644 --- a/__tests__/webpack/webpack4.config.js +++ b/__tests__/webpack/webpack4.config.js @@ -42,9 +42,8 @@ module.exports = { }, resolve: { alias: { - // Both resolve only through "exports" in package.json, which webpack 4 does not support. - '@redux-devtools/extension$': '@redux-devtools/extension/lib/index.js', - 'botframework-webchat/schema.js$': 'botframework-webchat/dist/botframework-webchat.schema.js' + // Resolves only through "exports" in package.json, which webpack 4 does not support. + '@redux-devtools/extension$': '@redux-devtools/extension/lib/index.js' } } }; diff --git a/docs/HOSTING.md b/docs/HOSTING.md index c873975ea4..2f3d4cc482 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -236,9 +236,8 @@ module.exports = { }, resolve: { alias: { - // Both resolve only through "exports" in package.json, which webpack 4 does not support. - '@redux-devtools/extension$': '@redux-devtools/extension/lib/index.js', - 'botframework-webchat/schema.js$': 'botframework-webchat/dist/botframework-webchat.schema.js' + // Resolves only through "exports" in package.json, which webpack 4 does not support. + '@redux-devtools/extension$': '@redux-devtools/extension/lib/index.js' } } }; @@ -246,7 +245,7 @@ module.exports = { - If your app already transpiles `node_modules`, add these packages to its rule instead. - `@redux-devtools/extension` 4 has no `"main"`. Instead of the alias, you can pin it to 3.3.0, which has one, with `"overrides"` (npm, pnpm) or `"resolutions"` (Yarn). -- Other subpaths, such as `botframework-webchat/hook.js`, have a file at the package root and need no alias. +- Subpaths, such as `botframework-webchat/hook.js`, have a file at the package root and need no alias. Web Chat 4.19.2 and earlier lack `schema.js` there: also alias `'botframework-webchat/schema.js$'` to `'botframework-webchat/dist/botframework-webchat.schema.js'`. ## Step 2: Constructing chat adapter diff --git a/packages/bundle/schema.js b/packages/bundle/schema.js new file mode 100644 index 0000000000..ee7003481c --- /dev/null +++ b/packages/bundle/schema.js @@ -0,0 +1,3 @@ +// This is required for Webpack 4 which does not support named exports. +// eslint-disable-next-line no-undef +module.exports = require('./dist/botframework-webchat.schema.js'); From aee07853a711bf882cb608127a5185351eb14c6e Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 16:51:15 -0700 Subject: [PATCH 08/25] Read the Redux DevTools extension global directly and drop @redux-devtools/extension --- .changelog/5977-fix-ci-webpack-consumers.md | 4 ++ __tests__/unit.setup/jest.config.js | 5 +- __tests__/webpack/webpack4.config.js | 6 -- docs/HOSTING.md | 16 +++--- package-lock.json | 10 ---- packages/core/package.json | 1 - .../core/src/createStore.devTools.spec.ts | 56 +++++++++++++++++++ packages/core/src/createStore.ts | 6 +- packages/core/src/env.d.ts | 5 ++ 9 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/createStore.devTools.spec.ts diff --git a/.changelog/5977-fix-ci-webpack-consumers.md b/.changelog/5977-fix-ci-webpack-consumers.md index 849c5804f5..f0832a8a2c 100644 --- a/.changelog/5977-fix-ci-webpack-consumers.md +++ b/.changelog/5977-fix-ci-webpack-consumers.md @@ -3,6 +3,10 @@ - 👷🏻 Pull request validation: added bundling Web Chat in webpack 4 and 5 apps with React 16 and 18, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) - Docs: added [workarounds for webpack 4](./docs/HOSTING.md#bundling-with-webpack-4), in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) +### Changed + +- Removed `@redux-devtools/extension` package in `botframework-webchat-core`, Redux DevTools still works through the browser extension, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) + ### Fixed - Fixed `botframework-webchat/schema.js` failing to resolve in webpack 4, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) diff --git a/__tests__/unit.setup/jest.config.js b/__tests__/unit.setup/jest.config.js index ae66b1fa81..2acd61ec36 100644 --- a/__tests__/unit.setup/jest.config.js +++ b/__tests__/unit.setup/jest.config.js @@ -46,10 +46,7 @@ const TRANSFORM_IGNORE_PACKAGES = [ // Related to Adaptive Cards 'adaptivecards', - 'swiper', - - // Related to Redux - '@redux-devtools/extension' + 'swiper' ]; module.exports = { diff --git a/__tests__/webpack/webpack4.config.js b/__tests__/webpack/webpack4.config.js index 5345020cca..be689a235e 100644 --- a/__tests__/webpack/webpack4.config.js +++ b/__tests__/webpack/webpack4.config.js @@ -39,11 +39,5 @@ module.exports = { } } ] - }, - resolve: { - alias: { - // Resolves only through "exports" in package.json, which webpack 4 does not support. - '@redux-devtools/extension$': '@redux-devtools/extension/lib/index.js' - } } }; diff --git a/docs/HOSTING.md b/docs/HOSTING.md index 2f3d4cc482..c115a090a2 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -191,7 +191,7 @@ import { createDirectLine, ReactWebChat, renderWebChat } from 'botframework-webc #### Bundling with webpack 4 -webpack 4 supports neither `"exports"` in `package.json` nor syntax newer than ES2019. To bundle Web Chat 4.19, add the following to `webpack.config.js` and install `babel-loader@8` and `@babel/preset-env`. Our CI tests it with webpack 4.47.0. +webpack 4 cannot parse syntax newer than ES2019. To bundle Web Chat, add the following to `webpack.config.js` and install `babel-loader@8` and `@babel/preset-env`. Our CI tests it with webpack 4.47.0. ```js const { sep } = require('path'); @@ -233,19 +233,17 @@ module.exports = { } } ] - }, - resolve: { - alias: { - // Resolves only through "exports" in package.json, which webpack 4 does not support. - '@redux-devtools/extension$': '@redux-devtools/extension/lib/index.js' - } } }; ``` - If your app already transpiles `node_modules`, add these packages to its rule instead. -- `@redux-devtools/extension` 4 has no `"main"`. Instead of the alias, you can pin it to 3.3.0, which has one, with `"overrides"` (npm, pnpm) or `"resolutions"` (Yarn). -- Subpaths, such as `botframework-webchat/hook.js`, have a file at the package root and need no alias. Web Chat 4.19.2 and earlier lack `schema.js` there: also alias `'botframework-webchat/schema.js$'` to `'botframework-webchat/dist/botframework-webchat.schema.js'`. +- webpack 4 does not support `"exports"` in `package.json`. Web Chat 4.19.2 and earlier also need these in `resolve.alias`: + + ```js + '@redux-devtools/extension$': '@redux-devtools/extension/lib/index.js', + 'botframework-webchat/schema.js$': 'botframework-webchat/dist/botframework-webchat.schema.js' + ``` ## Step 2: Constructing chat adapter diff --git a/package-lock.json b/package-lock.json index 9541b0699c..ee356b43d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4589,15 +4589,6 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@redux-devtools/extension": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@redux-devtools/extension/-/extension-4.0.0.tgz", - "integrity": "sha512-pLIzgo5MvqdDLe5D1pzHLgmr8THra/DOyRf5MvOEPZnKKDn6RhFbNSS5oXZ3Cal0cpx08kx7sR6zD8QgoXEnZA==", - "license": "MIT", - "peerDependencies": { - "redux": "^3.1.0 || ^4.0.0 || ^5.0.0" - } - }, "node_modules/@redux-saga/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.5.0.tgz", @@ -22143,7 +22134,6 @@ "license": "MIT", "dependencies": { "@babel/runtime": "7.29.7", - "@redux-devtools/extension": "4.0.0", "core-js-pure": "3.49.0", "iter-fest": "0.3.1", "jwt-decode": "4.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index e060290b48..d956943622 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -179,7 +179,6 @@ }, "dependencies": { "@babel/runtime": "7.29.7", - "@redux-devtools/extension": "4.0.0", "core-js-pure": "3.49.0", "iter-fest": "0.3.1", "jwt-decode": "4.0.0", diff --git a/packages/core/src/createStore.devTools.spec.ts b/packages/core/src/createStore.devTools.spec.ts new file mode 100644 index 0000000000..cb20a76aac --- /dev/null +++ b/packages/core/src/createStore.devTools.spec.ts @@ -0,0 +1,56 @@ +/** @jest-environment @happy-dom/jest-environment */ +/// + +import { scenario } from '@testduet/given-when-then'; +import type { StoreEnhancer } from 'redux'; + +import setSendBox from './actions/setSendBox'; +import { withOptions } from './createStore'; + +scenario('withOptions() with Redux DevTools', bdd => { + bdd.given + .oneOf([ + ['devTools: true', () => ({ devTools: true })], + ['devTools: false', () => ({ devTools: false })] + ]) + .and( + 'the Redux DevTools browser extension', + options => { + // Same as the extension: composes its own enhancer with the one passed in. + const composeEnhancers = jest.fn((enhancer: StoreEnhancer) => enhancer); + + window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = composeEnhancers; + + return { composeEnhancers, options }; + }, + () => { + delete window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; + } + ) + .when('a store is created and an action is dispatched', ({ options }) => { + const store = withOptions(options); + + store.dispatch(setSendBox('Hello, World!')); + + return store; + }) + .then('the extension should be called only with devTools: true', ({ composeEnhancers, options }) => + expect(composeEnhancers.mock.calls).toEqual(options.devTools ? [[expect.any(Function)]] : []) + ) + .and('the store should work', (_, store) => + expect(store.getState()).toHaveProperty('sendBoxValue', 'Hello, World!') + ); + + bdd + .given('devTools: true without the Redux DevTools browser extension', () => ({ devTools: true })) + .when('a store is created and an action is dispatched', options => { + const store = withOptions(options); + + store.dispatch(setSendBox('Hello, World!')); + + return store; + }) + .then('the store should work', (_, store) => + expect(store.getState()).toHaveProperty('sendBoxValue', 'Hello, World!') + ); +}); diff --git a/packages/core/src/createStore.ts b/packages/core/src/createStore.ts index 324c0b3931..63d8d4fee3 100644 --- a/packages/core/src/createStore.ts +++ b/packages/core/src/createStore.ts @@ -1,4 +1,3 @@ -import { composeWithDevTools } from '@redux-devtools/extension'; import { applyMiddleware, createStore as createReduxStore, type Middleware, type Store } from 'redux'; import createSagaMiddleware from 'redux-saga'; @@ -126,7 +125,10 @@ export function withOptions( store = createReduxStore( createReducer(ponyfill, restrictedStoreDebugAPI), initialState || {}, - options.devTools ? composeWithDevTools(enhancer) : enhancer + // The Redux DevTools browser extension sets this global, Redux Toolkit reads it the same way. + options.devTools && typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ + ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__(enhancer) + : enhancer ); // TODO: [P1] When we redesign the store and chat adapter, we should have the Debug API stored somewhere else. diff --git a/packages/core/src/env.d.ts b/packages/core/src/env.d.ts index 5b6a34e05e..08ce94782b 100644 --- a/packages/core/src/env.d.ts +++ b/packages/core/src/env.d.ts @@ -1,3 +1,5 @@ +import type { StoreEnhancer } from 'redux'; + // For adding things to `globalThis`, TypeScript need `var`, not `const` or `let`. /* eslint-disable no-var */ declare global { @@ -7,6 +9,9 @@ declare global { var WEB_CHAT_BUILD_INFO_BUILD_TOOL: string | undefined; var WEB_CHAT_BUILD_INFO_MODULE_FORMAT: string | undefined; var WEB_CHAT_BUILD_INFO_VERSION: string | undefined; + + // Set by the Redux DevTools browser extension. + var __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: ((enhancer: StoreEnhancer) => StoreEnhancer) | undefined; } export {}; From 5ae2fdaba1740a076f5efc318e2a3045a8046b8f Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 17:23:42 -0700 Subject: [PATCH 09/25] Bundle with esbuild, Vite and webpack 4/5 on React 16, 18 and 19, and render in Chrome instead of jsdom --- .changelog/5977-fix-ci-webpack-consumers.md | 2 +- .github/workflows/pull-request-validation.yml | 33 ++-- __tests__/bundlers/browser.mjs | 156 ++++++++++++++++++ __tests__/bundlers/build.mjs | 90 ++++++++++ __tests__/bundlers/package.json | 12 ++ __tests__/bundlers/src/app.js | 53 ++++++ __tests__/bundlers/src/index.js | 6 + __tests__/bundlers/src/index.react16.js | 6 + __tests__/{webpack => bundlers}/test.sh | 18 +- .../{webpack => bundlers}/webpack4.config.js | 0 __tests__/webpack/build.js | 37 ----- __tests__/webpack/load.js | 36 ---- __tests__/webpack/package.json | 13 -- __tests__/webpack/src/index.js | 18 -- docs/HOSTING.md | 2 + 15 files changed, 357 insertions(+), 125 deletions(-) create mode 100644 __tests__/bundlers/browser.mjs create mode 100644 __tests__/bundlers/build.mjs create mode 100644 __tests__/bundlers/package.json create mode 100644 __tests__/bundlers/src/app.js create mode 100644 __tests__/bundlers/src/index.js create mode 100644 __tests__/bundlers/src/index.react16.js rename __tests__/{webpack => bundlers}/test.sh (50%) rename __tests__/{webpack => bundlers}/webpack4.config.js (100%) delete mode 100644 __tests__/webpack/build.js delete mode 100644 __tests__/webpack/load.js delete mode 100644 __tests__/webpack/package.json delete mode 100644 __tests__/webpack/src/index.js diff --git a/.changelog/5977-fix-ci-webpack-consumers.md b/.changelog/5977-fix-ci-webpack-consumers.md index f0832a8a2c..7621d355fb 100644 --- a/.changelog/5977-fix-ci-webpack-consumers.md +++ b/.changelog/5977-fix-ci-webpack-consumers.md @@ -1,6 +1,6 @@ ### Added -- 👷🏻 Pull request validation: added bundling Web Chat in webpack 4 and 5 apps with React 16 and 18, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) +- 👷🏻 Pull request validation: added rendering Web Chat bundled by esbuild, Vite, webpack 4 and 5 with React 16, 18 and 19, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) - Docs: added [workarounds for webpack 4](./docs/HOSTING.md#bundling-with-webpack-4), in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) ### Changed diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index f9b555583f..bcf624bf87 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -281,39 +281,45 @@ jobs: run: ../../node_modules/.bin/tsd -t ./dist/botframework-webchat-core.d.mts working-directory: ./packages/core - webpack-test: - name: Webpack test (webpack ${{ matrix.webpack }}, React ${{ matrix.react }}) + bundler-test: + name: Bundler test (${{ matrix.bundler }}, React ${{ matrix.react }}) needs: - build runs-on: ubuntu-latest strategy: fail-fast: false matrix: - # Major versions only, so check names stay the same when versions are bumped. - react: [16, 18] # TODO: Add React 19 when react-film supports it. - webpack: [4, 5] + # Names without versions (webpack: major only), so check names stay the same when versions are bumped. + bundler: [esbuild, vite, webpack 4, webpack 5] + react: [16, 18, 19] include: + - bundler: esbuild + bundler-package: esbuild@0.28.2 + - bundler: vite + bundler-package: vite@8.3.5 + - bundler: webpack 4 + bundler-package: webpack@4.47.0 + - bundler: webpack 5 + bundler-package: webpack@5.107.2 - react: 16 react-version: 16.8.6 - react: 18 react-version: 18.3.1 - - webpack: 4 - webpack-version: 4.47.0 - - webpack: 5 - webpack-version: 5.107.2 + - react: 19 + react-version: 19.3.1 timeout-minutes: 10 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - sparse-checkout: __tests__/webpack + sparse-checkout: __tests__/bundlers - name: Use Node.js ${{ env.node-version }} uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ env.node-version }} cache: npm - cache-dependency-path: __tests__/webpack/package.json + cache-dependency-path: __tests__/bundlers/package.json - name: Download tarballs uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -323,8 +329,9 @@ jobs: - name: Extract tarballs run: tar -xvf tarballs.tar - - name: Bundle with webpack@${{ matrix.webpack-version }} and react@${{ matrix.react-version }} - run: ./__tests__/webpack/test.sh ${{ matrix.webpack-version }} ${{ matrix.react-version }} ./packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz + # Renders in the Google Chrome preinstalled on the runner. + - name: Bundle with ${{ matrix.bundler-package }} and react@${{ matrix.react-version }}, render in Chrome + run: ./__tests__/bundlers/test.sh ${{ matrix.bundler-package }} ${{ matrix.react-version }} ./packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz html-test: name: HTML test (${{ format('{0}/{1}', matrix.shard-index, matrix.shard-count) }}) diff --git a/__tests__/bundlers/browser.mjs b/__tests__/bundlers/browser.mjs new file mode 100644 index 0000000000..f0cb8ef001 --- /dev/null +++ b/__tests__/bundlers/browser.mjs @@ -0,0 +1,156 @@ +// Serves dist/ and loads it in headless Chrome over the DevTools protocol: Web Chat must render the bot message +// without uncaught errors, console errors, or failed requests. +// +// Usage: node browser.mjs, with $CHROME (default: google-chrome, preinstalled on GitHub-hosted Ubuntu runners). + +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { dirname, extname, join, normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const TIMEOUT = 60_000; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, 'dist'); +const errors = []; + +const CONTENT_TYPES = Object.freeze({ + '.css': 'text/css', + '.html': 'text/html', + '.js': 'text/javascript', + '.json': 'application/json', + '.mjs': 'text/javascript', + '.svg': 'image/svg+xml' +}); + +const server = createServer(async ({ url }, res) => { + const path = join(root, normalize(new URL(url, 'http://localhost').pathname.replace(/\/$/u, '/index.html'))); + + try { + const body = await readFile(path); + + res.writeHead(200, { 'content-type': CONTENT_TYPES[extname(path)] || 'application/octet-stream' }).end(body); + } catch { + res.writeHead(404).end(); + } +}); + +await new Promise(resolvePromise => server.listen(0, '127.0.0.1', resolvePromise)); + +const pageURL = `http://127.0.0.1:${server.address().port}/`; + +const chrome = spawn( + process.env.CHROME || 'google-chrome', + [ + '--headless=new', + '--no-first-run', + '--no-default-browser-check', + // The page is our own bundle, and runners may not allow the user namespaces the sandbox needs. + '--no-sandbox', + '--remote-debugging-port=0', + // A new profile in the app directory, which test.sh creates for each run. + `--user-data-dir=${resolve(here, '.chrome')}`, + 'about:blank' + ], + { stdio: ['ignore', 'ignore', 'pipe'] } +); + +const browserURL = await new Promise((resolvePromise, reject) => { + let stderr = ''; + + chrome.once('error', reject); + chrome.once('exit', code => reject(new Error(`Chrome exited with code ${code}.\n${stderr}`))); + chrome.stderr.on('data', chunk => { + stderr += chunk; + + const match = /DevTools listening on (ws:\S+)/u.exec(stderr); + + match && resolvePromise(match[1]); + }); +}); + +const socket = new WebSocket(browserURL); +const pending = new Map(); +const listeners = []; +let lastId = 0; + +socket.addEventListener('message', ({ data }) => { + const message = JSON.parse(data); + + if (message.id) { + const { reject, resolve: resolvePromise } = pending.get(message.id); + + pending.delete(message.id); + message.error ? reject(new Error(JSON.stringify(message.error))) : resolvePromise(message.result); + } else { + listeners.forEach(listener => listener(message)); + } +}); + +await new Promise((resolvePromise, reject) => { + socket.addEventListener('open', resolvePromise, { once: true }); + socket.addEventListener('error', reject, { once: true }); +}); + +const send = (method, params = {}, sessionId) => + new Promise((resolvePromise, reject) => { + const id = ++lastId; + + pending.set(id, { reject, resolve: resolvePromise }); + socket.send(JSON.stringify({ id, method, params, sessionId })); + }); + +const requestURLs = new Map(); + +listeners.push(({ method, params }) => { + if (method === 'Runtime.exceptionThrown') { + const { exception, text } = params.exceptionDetails; + + errors.push(`Uncaught: ${exception?.description || text}`); + } else if (method === 'Runtime.consoleAPICalled' && (params.type === 'assert' || params.type === 'error')) { + errors.push(`console.${params.type}: ${params.args.map(arg => arg.value ?? arg.description).join(' ')}`); + } else if (method === 'Network.requestWillBeSent') { + requestURLs.set(params.requestId, params.request.url); + } else if (method === 'Network.loadingFailed') { + errors.push(`Request failed: ${requestURLs.get(params.requestId)} ${params.errorText}`); + } else if (method === 'Network.responseReceived' && params.response.status >= 400) { + errors.push(`HTTP ${params.response.status}: ${params.response.url}`); + } +}); + +const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); +const { sessionId } = await send('Target.attachToTarget', { flatten: true, targetId }); + +await Promise.all(['Network.enable', 'Runtime.enable'].map(method => send(method, {}, sessionId))); +await send('Page.navigate', { url: pageURL }, sessionId); + +// Rendered: the transcript shows the message from the fake chat adapter in src/app.js, and the send box is there. +const RENDERED = `document.readyState === 'complete' && + !!document.querySelector('[role="feed"]')?.textContent.includes('Hello from the bot.') && + !!document.querySelector('textarea')`; + +let rendered = false; + +for (const startTime = Date.now(); !rendered && !errors.length && Date.now() - startTime < TIMEOUT; ) { + const { result } = await send('Runtime.evaluate', { expression: RENDERED, returnByValue: true }, sessionId); + + rendered = result.value === true; + rendered || (await new Promise(resolvePromise => setTimeout(resolvePromise, 100))); +} + +rendered || errors.length || errors.push(`Web Chat did not render the bot message in ${TIMEOUT / 1000} seconds.`); + +socket.close(); +chrome.removeAllListeners('exit'); + +await new Promise(resolvePromise => chrome.once('exit', resolvePromise).kill()); + +server.close(); + +// Multi-line messages need "%0A" in GitHub Actions annotations. +errors.forEach(error => console.log(`::error::${error.replace(/\n/gu, '%0A')}`)); + +console.log(errors.length ? `${errors.length} error(s).` : 'Rendered Web Chat in Chrome, with the bot message.'); + +process.exit(errors.length ? 1 : 0); diff --git a/__tests__/bundlers/build.mjs b/__tests__/bundlers/build.mjs new file mode 100644 index 0000000000..3047b91c11 --- /dev/null +++ b/__tests__/bundlers/build.mjs @@ -0,0 +1,90 @@ +// Bundles src/ like a consumer app would, with the installed bundler named on the command line, into dist/. +// Fails on errors, lists warnings. +// +// Usage: node build.mjs + +import { mkdir, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const [bundler] = process.argv.slice(2); +const here = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +const { version: bundlerVersion } = require(`${bundler}/package.json`); +const { version: reactVersion } = require('react/package.json'); + +// React 16 has no "react-dom/client". +const entry = reactVersion.startsWith('16.') ? 'src/index.react16.js' : 'src/index.js'; + +// Multi-line messages need "%0A" in GitHub Actions annotations. +const annotate = (command, message) => console.log(`::${command}::${message.replace(/\n/gu, '%0A')}`); + +const html = script => ` + + + + + Web Chat bundled with ${bundler} + + +
+ ${script} + + +`; + +console.log(`Bundling ${entry} with ${bundler}@${bundlerVersion} and react@${reactVersion}.`); + +if (bundler === 'esbuild') { + const { build } = await import('esbuild'); + + // Throws on errors, prints warnings. + await build({ + bundle: true, + entryPoints: [resolve(here, entry)], + minify: true, + outfile: resolve(here, 'dist/main.js') + }); + await writeFile(resolve(here, 'dist/index.html'), html('')); +} else if (bundler === 'vite') { + const { build } = await import('vite'); + + // Vite bundles from index.html. Throws on errors, prints warnings. + await writeFile(resolve(here, 'index.html'), html(``)); + await build({ logLevel: 'warn', root: here }); +} else if (bundler === 'webpack') { + const webpack = require('webpack'); + + const stats = await new Promise((resolvePromise, reject) => + webpack( + { + entry: resolve(here, entry), + mode: 'production', + output: { filename: 'main.js', path: resolve(here, 'dist') }, + performance: { hints: false }, + ...(bundlerVersion.startsWith('4.') && require('./webpack4.config.js')) + }, + (error, stats) => (error ? reject(error) : resolvePromise(stats)) + ) + ); + + // webpack 4 reports strings, webpack 5 reports objects. + const { errors, warnings } = stats.toJson({ all: false, errors: true, warnings: true }); + const toString = entry => (typeof entry === 'string' ? entry : `${entry.moduleName || ''}\n${entry.message}`); + + warnings.forEach(warning => annotate('warning', toString(warning))); + errors.forEach(error => annotate('error', toString(error))); + + console.log(`${errors.length} error(s), ${warnings.length} warning(s).`); + + errors.length && process.exit(1); + + await mkdir(resolve(here, 'dist'), { recursive: true }); + await writeFile(resolve(here, 'dist/index.html'), html('')); +} else { + throw new Error(`Unknown bundler "${bundler}".`); +} + +console.log('Bundled into dist/.'); diff --git a/__tests__/bundlers/package.json b/__tests__/bundlers/package.json new file mode 100644 index 0000000000..5fc4a490d4 --- /dev/null +++ b/__tests__/bundlers/package.json @@ -0,0 +1,12 @@ +{ + "name": "@msinternal/botframework-webchat-bundlers-consumer", + "version": "0.0.0-0", + "description": "A consumer app bundling Web Chat with esbuild, Vite, or webpack 4 or 5, see test.sh", + "private": true, + "license": "MIT", + "devDependencies": { + "@babel/core": "7.29.7", + "@babel/preset-env": "7.29.7", + "babel-loader": "8.4.1" + } +} diff --git a/__tests__/bundlers/src/app.js b/__tests__/bundlers/src/app.js new file mode 100644 index 0000000000..a27c0f02b3 --- /dev/null +++ b/__tests__/bundlers/src/app.js @@ -0,0 +1,53 @@ +import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; +import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; +import { Composer } from 'botframework-webchat/component.js'; +import { WebChatDecorator } from 'botframework-webchat/decorator.js'; +import { useActivities } from 'botframework-webchat/hook.js'; +import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; +import { createElement } from 'react'; + +// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. +// "botframework-webchat/schema.js" is imported by the Fluent theme. +Object.entries({ + Composer, + createActivityPolymiddleware, + createDirectLine, + FluentThemeProvider, + ReactWebChat, + renderWebChat, + useActivities, + WebChatDecorator +}).forEach(([name, value]) => { + if (!value) { + throw new Error(`Import "${name}" is ${value}.`); + } +}); + +// Emits the values on every subscription, asynchronously like DirectLineJS. +function observableOf(...values) { + return Object.freeze({ + subscribe: ({ next }) => { + const timeout = setTimeout(() => values.forEach(value => next(value))); + + return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); + } + }); +} + +// A chat adapter that connects at once and receives one message from the bot, browser.mjs waits for it. +const directLine = Object.freeze({ + activity$: observableOf({ + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello from the bot.', + timestamp: new Date().toISOString(), + type: 'message' + }), + connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. + end: () => undefined, + postActivity: () => observableOf('a-00002') +}); + +export default function createApp() { + return createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })); +} diff --git a/__tests__/bundlers/src/index.js b/__tests__/bundlers/src/index.js new file mode 100644 index 0000000000..f655a2b7f1 --- /dev/null +++ b/__tests__/bundlers/src/index.js @@ -0,0 +1,6 @@ +import { createRoot } from 'react-dom/client'; + +import createApp from './app.js'; + +// React 18 and later. +createRoot(document.getElementById('webchat')).render(createApp()); diff --git a/__tests__/bundlers/src/index.react16.js b/__tests__/bundlers/src/index.react16.js new file mode 100644 index 0000000000..5a2568ce62 --- /dev/null +++ b/__tests__/bundlers/src/index.react16.js @@ -0,0 +1,6 @@ +import { render } from 'react-dom'; + +import createApp from './app.js'; + +// React 16 has no "react-dom/client". +render(createApp(), document.getElementById('webchat')); diff --git a/__tests__/webpack/test.sh b/__tests__/bundlers/test.sh similarity index 50% rename from __tests__/webpack/test.sh rename to __tests__/bundlers/test.sh index 736d74679e..fe2141d7bf 100755 --- a/__tests__/webpack/test.sh +++ b/__tests__/bundlers/test.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash -# Bundles Web Chat in a consumer app, like apps install it from npm, with the given webpack and React versions. +# Bundles Web Chat in a consumer app, like apps install it from npm, with the given bundler and React versions, +# then renders it in Chrome. # -# Usage: test.sh ... +# Usage: test.sh @ ... # The app is created under $APP_DIR (default: a new temporary directory) so it does not see the monorepo node_modules. set -euo pipefail -webpack_version=$1 +bundler_package=$1 react_version=$2 shift 2 @@ -16,11 +17,14 @@ mapfile -t tarballs < <(realpath "$@") cp -R "$(dirname "$0")/." "$app_dir" cd "$app_dir" -npm install --no-audit --no-fund \ +# The devDependencies in package.json are for webpack4.config.js only. +[[ $bundler_package == webpack@4.* ]] || omit_dev=--omit=dev + +npm install --no-audit --no-fund ${omit_dev:-} \ + "$bundler_package" \ "react@$react_version" \ "react-dom@$react_version" \ - "webpack@$webpack_version" \ "${tarballs[@]}" -node build.js -node load.js +node build.mjs "${bundler_package%@*}" +node browser.mjs diff --git a/__tests__/webpack/webpack4.config.js b/__tests__/bundlers/webpack4.config.js similarity index 100% rename from __tests__/webpack/webpack4.config.js rename to __tests__/bundlers/webpack4.config.js diff --git a/__tests__/webpack/build.js b/__tests__/webpack/build.js deleted file mode 100644 index 27da1a3351..0000000000 --- a/__tests__/webpack/build.js +++ /dev/null @@ -1,37 +0,0 @@ -// Bundles ./src/index.js with the installed webpack (4 or 5) like a consumer app would: fails on errors, lists warnings. - -const { resolve } = require('path'); -const webpack = require('webpack'); - -// Multi-line messages need "%0A" in GitHub Actions annotations. -const annotate = (command, message) => console.log(`::${command}::${message.replace(/\n/gu, '%0A')}`); - -console.log(`Bundling with webpack@${webpack.version} and react@${require('react/package.json').version}.`); - -webpack( - { - entry: resolve(__dirname, 'src/index.js'), - mode: 'production', - // "auto" (webpack 5 default) needs `document.currentScript` to load chunks, which jsdom does not set in load.js. - output: { filename: 'main.js', path: resolve(__dirname, 'dist'), publicPath: '/' }, - performance: { hints: false }, - ...(webpack.version.startsWith('4.') && require('./webpack4.config.js')) - }, - (error, stats) => { - if (error) { - console.error(error); - process.exit(1); - } - - // webpack 4 reports strings, webpack 5 reports objects. - const { errors, warnings } = stats.toJson({ all: false, errors: true, warnings: true }); - const toString = entry => (typeof entry === 'string' ? entry : `${entry.moduleName || ''}\n${entry.message}`); - - warnings.forEach(warning => annotate('warning', toString(warning))); - errors.forEach(error => annotate('error', toString(error))); - - console.log(`${errors.length} error(s), ${warnings.length} warning(s).`); - - process.exit(errors.length ? 1 : 0); - } -); diff --git a/__tests__/webpack/load.js b/__tests__/webpack/load.js deleted file mode 100644 index 7192b9df61..0000000000 --- a/__tests__/webpack/load.js +++ /dev/null @@ -1,36 +0,0 @@ -// Runs the bundle in jsdom: it must not throw and every import must be defined. - -const { readFileSync } = require('fs'); -const { JSDOM, VirtualConsole } = require('jsdom'); -const { resolve } = require('path'); - -const errors = []; -const virtualConsole = new VirtualConsole(); - -virtualConsole.on('jsdomError', error => errors.push(error)); - -const { window } = new JSDOM('', { runScripts: 'outside-only', virtualConsole }); - -// Web Chat needs Web Streams and text encoding, which jsdom does not implement but Node.js does. -['ReadableStream', 'TextDecoder', 'TextEncoder', 'TransformStream', 'WritableStream'].forEach(name => { - window[name] = globalThis[name]; -}); - -try { - window.eval(readFileSync(resolve(__dirname, 'dist/main.js'), 'utf8')); -} catch (error) { - errors.push(error); -} - -window.WebChatConsumer - ? Object.entries(window.WebChatConsumer).forEach( - ([name, value]) => value || errors.push(new Error(`Import "${name}" is ${value}.`)) - ) - : errors.push(new Error('The bundle did not run to the end.')); - -// Multi-line messages need "%0A" in GitHub Actions annotations. -errors.forEach(error => console.log(`::error::${String(error?.stack || error).replace(/\n/gu, '%0A')}`)); - -console.log(errors.length ? `${errors.length} error(s).` : 'Loaded the bundle, every import is defined.'); - -process.exit(errors.length ? 1 : 0); diff --git a/__tests__/webpack/package.json b/__tests__/webpack/package.json deleted file mode 100644 index 97388faac5..0000000000 --- a/__tests__/webpack/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@msinternal/botframework-webchat-webpack-consumer", - "version": "0.0.0-0", - "description": "A consumer app bundling Web Chat with webpack 4 or 5, see test.sh", - "private": true, - "license": "MIT", - "devDependencies": { - "@babel/core": "7.29.7", - "@babel/preset-env": "7.29.7", - "babel-loader": "8.4.1", - "jsdom": "30.1.3" - } -} diff --git a/__tests__/webpack/src/index.js b/__tests__/webpack/src/index.js deleted file mode 100644 index 648c79cfc7..0000000000 --- a/__tests__/webpack/src/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; -import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; -import { Composer } from 'botframework-webchat/component.js'; -import { WebChatDecorator } from 'botframework-webchat/decorator.js'; -import { useActivities } from 'botframework-webchat/hook.js'; -import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; - -// load.js checks that every import is defined. "botframework-webchat/schema.js" is imported by the Fluent theme. -window.WebChatConsumer = Object.freeze({ - Composer, - createActivityPolymiddleware, - createDirectLine, - FluentThemeProvider, - ReactWebChat, - renderWebChat, - useActivities, - WebChatDecorator -}); diff --git a/docs/HOSTING.md b/docs/HOSTING.md index c115a090a2..dd93a0954a 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -191,6 +191,8 @@ import { createDirectLine, ReactWebChat, renderWebChat } from 'botframework-webc #### Bundling with webpack 4 +webpack 5, esbuild and Vite need no changes to bundle Web Chat; our CI renders Web Chat bundled by each of them in Chrome. + webpack 4 cannot parse syntax newer than ES2019. To bundle Web Chat, add the following to `webpack.config.js` and install `babel-loader@8` and `@babel/preset-env`. Our CI tests it with webpack 4.47.0. ```js From eff6b86b6f1e024ce78efc0a72adcb64e07c4b50 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 17:40:44 -0700 Subject: [PATCH 10/25] Exclude webpack 5 with React 19 until react-film is removed --- .github/workflows/pull-request-validation.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index bcf624bf87..01966e8860 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -307,6 +307,11 @@ jobs: react-version: 18.3.1 - react: 19 react-version: 19.3.1 + # react-film 4.0.0 imports `render` from react-dom, which React 19 removed, so webpack 5 fails on the missing export. + # Re-enable when react-film is removed: https://github.com/microsoft/BotFramework-WebChat/issues/5552. + exclude: + - bundler: webpack 5 + react: 19 timeout-minutes: 10 steps: From 2f91c036294fc81a4607c462b0fed2fd923dc689 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 17:53:57 -0700 Subject: [PATCH 11/25] Describe the Redux DevTools change without implying a breaking change --- .changelog/5977-fix-ci-webpack-consumers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changelog/5977-fix-ci-webpack-consumers.md b/.changelog/5977-fix-ci-webpack-consumers.md index 7621d355fb..3bdd8d65a1 100644 --- a/.changelog/5977-fix-ci-webpack-consumers.md +++ b/.changelog/5977-fix-ci-webpack-consumers.md @@ -5,7 +5,7 @@ ### Changed -- Removed `@redux-devtools/extension` package in `botframework-webchat-core`, Redux DevTools still works through the browser extension, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) +- `botframework-webchat-core` connects to Redux DevTools without the `@redux-devtools/extension` package, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) ### Fixed From aa4bc07963ca7ed3aee77c044bac49826d16b41c Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 18:08:43 -0700 Subject: [PATCH 12/25] Note that webpack 5 with React 19 needs exportsPresence until react-film is removed, and name both webpack 4 fixes --- .changelog/5977-fix-ci-webpack-consumers.md | 2 +- docs/HOSTING.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changelog/5977-fix-ci-webpack-consumers.md b/.changelog/5977-fix-ci-webpack-consumers.md index 3bdd8d65a1..cbc80158cb 100644 --- a/.changelog/5977-fix-ci-webpack-consumers.md +++ b/.changelog/5977-fix-ci-webpack-consumers.md @@ -9,4 +9,4 @@ ### Fixed -- Fixed `botframework-webchat/schema.js` failing to resolve in webpack 4, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) +- Fixed webpack 4 failing to resolve `botframework-webchat/schema.js` and `@redux-devtools/extension`, in PR [#5977](https://github.com/microsoft/BotFramework-WebChat/pull/5977), by [@OEvgeny](https://github.com/OEvgeny) diff --git a/docs/HOSTING.md b/docs/HOSTING.md index dd93a0954a..2194e38631 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -189,9 +189,9 @@ Then, import Web Chat into your code: import { createDirectLine, ReactWebChat, renderWebChat } from 'botframework-webchat'; ``` -#### Bundling with webpack 4 +webpack 5, esbuild and Vite need no changes to bundle Web Chat; our CI renders Web Chat bundled by each of them in Chrome. The exception is webpack 5 with React 19, which fails on react-film importing `render` from `react-dom` until react-film is removed ([#5552](https://github.com/microsoft/BotFramework-WebChat/issues/5552)); until then, set `module.parser.javascript.exportsPresence: 'warn'` in `webpack.config.js`. -webpack 5, esbuild and Vite need no changes to bundle Web Chat; our CI renders Web Chat bundled by each of them in Chrome. +#### Bundling with webpack 4 webpack 4 cannot parse syntax newer than ES2019. To bundle Web Chat, add the following to `webpack.config.js` and install `babel-loader@8` and `@babel/preset-env`. Our CI tests it with webpack 4.47.0. From 3cfbed8387b6808911b8d6f6eb3825b458d59417 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 18:08:44 -0700 Subject: [PATCH 13/25] Time out Chrome start and DevTools calls in browser.mjs, and always clean up Chrome, the server and the profile --- __tests__/bundlers/browser.mjs | 223 ++++++++++++++++++++------------- 1 file changed, 136 insertions(+), 87 deletions(-) diff --git a/__tests__/bundlers/browser.mjs b/__tests__/bundlers/browser.mjs index f0cb8ef001..a83755d4f9 100644 --- a/__tests__/bundlers/browser.mjs +++ b/__tests__/bundlers/browser.mjs @@ -4,16 +4,18 @@ // Usage: node browser.mjs, with $CHROME (default: google-chrome, preinstalled on GitHub-hosted Ubuntu runners). import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; import { dirname, extname, join, normalize, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -const TIMEOUT = 60_000; - -const here = dirname(fileURLToPath(import.meta.url)); -const root = resolve(here, 'dist'); -const errors = []; +// Well under the job's timeout-minutes, which also covers npm install and bundling. +const CDP_TIMEOUT = 10_000; +const CHROME_START_TIMEOUT = 30_000; +const RENDER_TIMEOUT = 60_000; +const TOTAL_TIMEOUT = 120_000; const CONTENT_TYPES = Object.freeze({ '.css': 'text/css', @@ -24,6 +26,26 @@ const CONTENT_TYPES = Object.freeze({ '.svg': 'image/svg+xml' }); +// Rendered: the transcript shows the message from the fake chat adapter in src/app.js, and the send box is there. +const RENDERED = `document.readyState === 'complete' && + !!document.querySelector('[role="feed"]')?.textContent.includes('Hello from the bot.') && + !!document.querySelector('textarea')`; + +const errors = []; +const profileDir = mkdtempSync(join(tmpdir(), 'webchat-bundler-test-')); +const root = resolve(dirname(fileURLToPath(import.meta.url)), 'dist'); + +function withTimeout(promise, timeout, message) { + let timer; + + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeout); + }) + ]).finally(() => clearTimeout(timer)); +} + const server = createServer(async ({ url }, res) => { const path = join(root, normalize(new URL(url, 'http://localhost').pathname.replace(/\/$/u, '/index.html'))); @@ -36,117 +58,144 @@ const server = createServer(async ({ url }, res) => { } }); -await new Promise(resolvePromise => server.listen(0, '127.0.0.1', resolvePromise)); - -const pageURL = `http://127.0.0.1:${server.address().port}/`; - const chrome = spawn( process.env.CHROME || 'google-chrome', [ '--headless=new', '--no-first-run', '--no-default-browser-check', - // The page is our own bundle, and runners may not allow the user namespaces the sandbox needs. + // The page only loads our own bundle, on an ephemeral CI runner, so we do not depend on the runner allowing the sandbox. '--no-sandbox', '--remote-debugging-port=0', - // A new profile in the app directory, which test.sh creates for each run. - `--user-data-dir=${resolve(here, '.chrome')}`, + `--user-data-dir=${profileDir}`, 'about:blank' ], - { stdio: ['ignore', 'ignore', 'pipe'] } + // In its own process group, so the cleanup below also kills its renderer and GPU processes. + { detached: true, stdio: ['ignore', 'ignore', 'pipe'] } ); -const browserURL = await new Promise((resolvePromise, reject) => { - let stderr = ''; - - chrome.once('error', reject); - chrome.once('exit', code => reject(new Error(`Chrome exited with code ${code}.\n${stderr}`))); - chrome.stderr.on('data', chunk => { - stderr += chunk; - - const match = /DevTools listening on (ws:\S+)/u.exec(stderr); - - match && resolvePromise(match[1]); - }); -}); - -const socket = new WebSocket(browserURL); -const pending = new Map(); -const listeners = []; -let lastId = 0; +const chromeExited = new Promise(resolvePromise => chrome.once('error', resolvePromise).once('exit', resolvePromise)); -socket.addEventListener('message', ({ data }) => { - const message = JSON.parse(data); - - if (message.id) { - const { reject, resolve: resolvePromise } = pending.get(message.id); - - pending.delete(message.id); - message.error ? reject(new Error(JSON.stringify(message.error))) : resolvePromise(message.result); - } else { - listeners.forEach(listener => listener(message)); +// Never leaves Chrome or its profile behind, also when the script throws, times out, or is interrupted. +function killChrome(signal) { + try { + process.kill(-chrome.pid, signal); + } catch { + // Already exited, or never started. } -}); +} -await new Promise((resolvePromise, reject) => { - socket.addEventListener('open', resolvePromise, { once: true }); - socket.addEventListener('error', reject, { once: true }); +process.once('exit', () => { + killChrome('SIGKILL'); + rmSync(profileDir, { force: true, maxRetries: 5, recursive: true }); }); -const send = (method, params = {}, sessionId) => - new Promise((resolvePromise, reject) => { - const id = ++lastId; - - pending.set(id, { reject, resolve: resolvePromise }); - socket.send(JSON.stringify({ id, method, params, sessionId })); +['SIGINT', 'SIGTERM'].forEach(signal => process.once(signal, () => process.exit(1))); + +async function run() { + await new Promise(resolvePromise => server.listen(0, '127.0.0.1', resolvePromise)); + + const browserURL = await withTimeout( + new Promise((resolvePromise, reject) => { + let stderr = ''; + + chrome.once('error', reject); + chromeExited.then(code => reject(new Error(`Chrome exited with code ${code}.\n${stderr}`))); + chrome.stderr.on('data', chunk => { + stderr += chunk; + + const match = /DevTools listening on (ws:\S+)/u.exec(stderr); + + match && resolvePromise(match[1]); + }); + }), + CHROME_START_TIMEOUT, + `Chrome did not open its DevTools endpoint in ${CHROME_START_TIMEOUT / 1000} seconds.` + ); + + const socket = new WebSocket(browserURL); + const pending = new Map(); + const requestURLs = new Map(); + let lastId = 0; + + socket.addEventListener('message', ({ data }) => { + const { error, id, method, params, result } = JSON.parse(data); + + if (id) { + const { reject, resolve: resolvePromise } = pending.get(id); + + pending.delete(id); + error ? reject(new Error(JSON.stringify(error))) : resolvePromise(result); + } else if (method === 'Runtime.exceptionThrown') { + const { exception, text } = params.exceptionDetails; + + errors.push(`Uncaught: ${exception?.description || text}`); + } else if (method === 'Runtime.consoleAPICalled' && (params.type === 'assert' || params.type === 'error')) { + errors.push(`console.${params.type}: ${params.args.map(arg => arg.value ?? arg.description).join(' ')}`); + } else if (method === 'Network.requestWillBeSent') { + requestURLs.set(params.requestId, params.request.url); + } else if (method === 'Network.loadingFailed') { + errors.push(`Request failed: ${requestURLs.get(params.requestId)} ${params.errorText}`); + } else if (method === 'Network.responseReceived' && params.response.status >= 400) { + errors.push(`HTTP ${params.response.status}: ${params.response.url}`); + } }); -const requestURLs = new Map(); - -listeners.push(({ method, params }) => { - if (method === 'Runtime.exceptionThrown') { - const { exception, text } = params.exceptionDetails; - - errors.push(`Uncaught: ${exception?.description || text}`); - } else if (method === 'Runtime.consoleAPICalled' && (params.type === 'assert' || params.type === 'error')) { - errors.push(`console.${params.type}: ${params.args.map(arg => arg.value ?? arg.description).join(' ')}`); - } else if (method === 'Network.requestWillBeSent') { - requestURLs.set(params.requestId, params.request.url); - } else if (method === 'Network.loadingFailed') { - errors.push(`Request failed: ${requestURLs.get(params.requestId)} ${params.errorText}`); - } else if (method === 'Network.responseReceived' && params.response.status >= 400) { - errors.push(`HTTP ${params.response.status}: ${params.response.url}`); - } -}); + await withTimeout( + new Promise((resolvePromise, reject) => { + socket.addEventListener('open', resolvePromise, { once: true }); + socket.addEventListener('error', reject, { once: true }); + }), + CDP_TIMEOUT, + `Could not connect to Chrome DevTools in ${CDP_TIMEOUT / 1000} seconds.` + ); -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { flatten: true, targetId }); + const send = (method, params = {}, sessionId) => + withTimeout( + new Promise((resolvePromise, reject) => { + const id = ++lastId; -await Promise.all(['Network.enable', 'Runtime.enable'].map(method => send(method, {}, sessionId))); -await send('Page.navigate', { url: pageURL }, sessionId); + pending.set(id, { reject, resolve: resolvePromise }); + socket.send(JSON.stringify({ id, method, params, sessionId })); + }), + CDP_TIMEOUT, + `Chrome did not answer ${method} in ${CDP_TIMEOUT / 1000} seconds.` + ); -// Rendered: the transcript shows the message from the fake chat adapter in src/app.js, and the send box is there. -const RENDERED = `document.readyState === 'complete' && - !!document.querySelector('[role="feed"]')?.textContent.includes('Hello from the bot.') && - !!document.querySelector('textarea')`; + const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await send('Target.attachToTarget', { flatten: true, targetId }); -let rendered = false; + await Promise.all(['Network.enable', 'Runtime.enable'].map(method => send(method, {}, sessionId))); + await send('Page.navigate', { url: `http://127.0.0.1:${server.address().port}/` }, sessionId); -for (const startTime = Date.now(); !rendered && !errors.length && Date.now() - startTime < TIMEOUT; ) { - const { result } = await send('Runtime.evaluate', { expression: RENDERED, returnByValue: true }, sessionId); + let rendered = false; - rendered = result.value === true; - rendered || (await new Promise(resolvePromise => setTimeout(resolvePromise, 100))); -} + for (const startTime = Date.now(); !rendered && !errors.length && Date.now() - startTime < RENDER_TIMEOUT; ) { + const { result } = await send('Runtime.evaluate', { expression: RENDERED, returnByValue: true }, sessionId); -rendered || errors.length || errors.push(`Web Chat did not render the bot message in ${TIMEOUT / 1000} seconds.`); + rendered = result.value === true; + rendered || (await new Promise(resolvePromise => setTimeout(resolvePromise, 100))); + } -socket.close(); -chrome.removeAllListeners('exit'); + rendered || + errors.length || + errors.push(`Web Chat did not render the bot message in ${RENDER_TIMEOUT / 1000} seconds.`); -await new Promise(resolvePromise => chrome.once('exit', resolvePromise).kill()); + socket.close(); +} -server.close(); +try { + await withTimeout(run(), TOTAL_TIMEOUT, `browser.mjs did not finish in ${TOTAL_TIMEOUT / 1000} seconds.`); +} catch (error) { + errors.push(error.message); +} finally { + server.close(); + server.closeAllConnections(); + + // SIGTERM lets Chrome remove its own temporary files. The profile is removed on exit, after Chrome stopped writing to it. + killChrome('SIGTERM'); + await withTimeout(chromeExited, CDP_TIMEOUT, 'Chrome did not exit.').catch(error => errors.push(error.message)); +} // Multi-line messages need "%0A" in GitHub Actions annotations. errors.forEach(error => console.log(`::error::${error.replace(/\n/gu, '%0A')}`)); From 3bb3c22b849bdf00a83385652c5ebfeb83e54aa8 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 18:08:44 -0700 Subject: [PATCH 14/25] Check that createStore uses the enhancer returned by the Redux DevTools extension --- .../core/src/createStore.devTools.spec.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/core/src/createStore.devTools.spec.ts b/packages/core/src/createStore.devTools.spec.ts index cb20a76aac..6d73c14ed2 100644 --- a/packages/core/src/createStore.devTools.spec.ts +++ b/packages/core/src/createStore.devTools.spec.ts @@ -16,12 +16,22 @@ scenario('withOptions() with Redux DevTools', bdd => { .and( 'the Redux DevTools browser extension', options => { - // Same as the extension: composes its own enhancer with the one passed in. - const composeEnhancers = jest.fn((enhancer: StoreEnhancer) => enhancer); + const devToolsCreateStore = jest.fn(); + + // Same as the extension: wraps the enhancer passed in, here to record the store it creates. + const composeEnhancers = jest.fn( + (enhancer: StoreEnhancer): StoreEnhancer => + createStore => + (...args) => { + devToolsCreateStore(); + + return enhancer(createStore)(...args); + } + ); window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = composeEnhancers; - return { composeEnhancers, options }; + return { composeEnhancers, devToolsCreateStore, options }; }, () => { delete window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; @@ -37,6 +47,9 @@ scenario('withOptions() with Redux DevTools', bdd => { .then('the extension should be called only with devTools: true', ({ composeEnhancers, options }) => expect(composeEnhancers.mock.calls).toEqual(options.devTools ? [[expect.any(Function)]] : []) ) + .and('the store should be created by the enhancer the extension returned', ({ devToolsCreateStore, options }) => + expect(devToolsCreateStore).toHaveBeenCalledTimes(options.devTools ? 1 : 0) + ) .and('the store should work', (_, store) => expect(store.getState()).toHaveProperty('sendBoxValue', 'Hello, World!') ); From bd8d545580400d74c1e4ebb64bb2dad9d5f7c43c Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 18:50:14 -0700 Subject: [PATCH 15/25] Type the Redux DevTools global in createStore instead of globally, so tsd compiles it without env.d.ts --- .../core/src/createStore.devTools.spec.ts | 11 +++++++-- packages/core/src/createStore.ts | 23 +++++++++++++++---- packages/core/src/env.d.ts | 5 ---- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/core/src/createStore.devTools.spec.ts b/packages/core/src/createStore.devTools.spec.ts index 6d73c14ed2..a93cf8bfc3 100644 --- a/packages/core/src/createStore.devTools.spec.ts +++ b/packages/core/src/createStore.devTools.spec.ts @@ -7,6 +7,13 @@ import type { StoreEnhancer } from 'redux'; import setSendBox from './actions/setSendBox'; import { withOptions } from './createStore'; +// The global the Redux DevTools browser extension sets. +type WindowWithReduxDevTools = Window & { + __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: ((enhancer: StoreEnhancer) => StoreEnhancer) | undefined; +}; + +const devToolsWindow: WindowWithReduxDevTools = window; + scenario('withOptions() with Redux DevTools', bdd => { bdd.given .oneOf([ @@ -29,12 +36,12 @@ scenario('withOptions() with Redux DevTools', bdd => { } ); - window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = composeEnhancers; + devToolsWindow.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = composeEnhancers; return { composeEnhancers, devToolsCreateStore, options }; }, () => { - delete window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; + delete devToolsWindow.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; } ) .when('a store is created and an action is dispatched', ({ options }) => { diff --git a/packages/core/src/createStore.ts b/packages/core/src/createStore.ts index 63d8d4fee3..4b4cfc26a5 100644 --- a/packages/core/src/createStore.ts +++ b/packages/core/src/createStore.ts @@ -1,4 +1,10 @@ -import { applyMiddleware, createStore as createReduxStore, type Middleware, type Store } from 'redux'; +import { + applyMiddleware, + createStore as createReduxStore, + type Middleware, + type Store, + type StoreEnhancer +} from 'redux'; import createSagaMiddleware from 'redux-saga'; import sagaError from './actions/sagaError'; @@ -9,6 +15,12 @@ import { RestrictedStoreDebugAPI } from './types/StoreDebugAPI'; import type { GlobalScopePonyfill } from './types/GlobalScopePonyfill'; import StoreDebugAPIRegistry from './internal/StoreDebugAPIRegistry'; +// The Redux DevTools browser extension sets this global, Redux Toolkit reads it the same way. +// Typed here instead of globally, so it is not part of the published typings. +type WindowWithReduxDevTools = Window & { + readonly __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: ((enhancer: StoreEnhancer) => StoreEnhancer) | undefined; +}; + type CreateStoreOptions = { /** * True, to enable Redux development tools, otherwise, false. @@ -122,13 +134,14 @@ export function withOptions( // We are sure the "getStore" (first argument) is not called on "createEnhancerAndSagaMiddleware()". const { enhancer, sagaMiddleware } = createEnhancerAndSagaMiddleware(() => store, ...middlewares); + const devToolsWindow: WindowWithReduxDevTools | undefined = + options.devTools && typeof window !== 'undefined' ? window : undefined; + const composeWithDevTools = devToolsWindow?.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; + store = createReduxStore( createReducer(ponyfill, restrictedStoreDebugAPI), initialState || {}, - // The Redux DevTools browser extension sets this global, Redux Toolkit reads it the same way. - options.devTools && typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ - ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__(enhancer) - : enhancer + composeWithDevTools ? composeWithDevTools(enhancer) : enhancer ); // TODO: [P1] When we redesign the store and chat adapter, we should have the Debug API stored somewhere else. diff --git a/packages/core/src/env.d.ts b/packages/core/src/env.d.ts index 08ce94782b..5b6a34e05e 100644 --- a/packages/core/src/env.d.ts +++ b/packages/core/src/env.d.ts @@ -1,5 +1,3 @@ -import type { StoreEnhancer } from 'redux'; - // For adding things to `globalThis`, TypeScript need `var`, not `const` or `let`. /* eslint-disable no-var */ declare global { @@ -9,9 +7,6 @@ declare global { var WEB_CHAT_BUILD_INFO_BUILD_TOOL: string | undefined; var WEB_CHAT_BUILD_INFO_MODULE_FORMAT: string | undefined; var WEB_CHAT_BUILD_INFO_VERSION: string | undefined; - - // Set by the Redux DevTools browser extension. - var __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: ((enhancer: StoreEnhancer) => StoreEnhancer) | undefined; } export {}; From 7dc9d9770abbe88bd951ffe24a93ad653a23ce79 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 18:50:15 -0700 Subject: [PATCH 16/25] Bundle the consumer apps in production mode, also when NODE_ENV is test --- .github/workflows/pull-request-validation.yml | 2 ++ __tests__/bundlers/build.mjs | 7 ++++++- __tests__/bundlers/test.sh | 7 ++++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 01966e8860..21a0bbb50b 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -313,6 +313,8 @@ jobs: - bundler: webpack 5 react: 19 timeout-minutes: 10 + env: + NODE_ENV: production # Consumer apps ship production builds; with the workflow's "test", Vite would bundle React's development build. steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 diff --git a/__tests__/bundlers/build.mjs b/__tests__/bundlers/build.mjs index 3047b91c11..63eb9808cd 100644 --- a/__tests__/bundlers/build.mjs +++ b/__tests__/bundlers/build.mjs @@ -35,6 +35,10 @@ const html = script => ` `; +// Production builds, like consumer apps ship, regardless of NODE_ENV (Vite would otherwise use it, e.g. "test"). +// Development builds of React log warnings, which browser.mjs fails on. +const define = Object.freeze({ 'process.env.NODE_ENV': JSON.stringify('production') }); + console.log(`Bundling ${entry} with ${bundler}@${bundlerVersion} and react@${reactVersion}.`); if (bundler === 'esbuild') { @@ -43,6 +47,7 @@ if (bundler === 'esbuild') { // Throws on errors, prints warnings. await build({ bundle: true, + define, entryPoints: [resolve(here, entry)], minify: true, outfile: resolve(here, 'dist/main.js') @@ -53,7 +58,7 @@ if (bundler === 'esbuild') { // Vite bundles from index.html. Throws on errors, prints warnings. await writeFile(resolve(here, 'index.html'), html(``)); - await build({ logLevel: 'warn', root: here }); + await build({ define, logLevel: 'warn', mode: 'production', root: here }); } else if (bundler === 'webpack') { const webpack = require('webpack'); diff --git a/__tests__/bundlers/test.sh b/__tests__/bundlers/test.sh index fe2141d7bf..fca6ecec3f 100755 --- a/__tests__/bundlers/test.sh +++ b/__tests__/bundlers/test.sh @@ -17,10 +17,11 @@ mapfile -t tarballs < <(realpath "$@") cp -R "$(dirname "$0")/." "$app_dir" cd "$app_dir" -# The devDependencies in package.json are for webpack4.config.js only. -[[ $bundler_package == webpack@4.* ]] || omit_dev=--omit=dev +# The devDependencies in package.json are for webpack4.config.js only (explicit: NODE_ENV=production omits them by default). +dev=--omit=dev +[[ $bundler_package == webpack@4.* ]] && dev=--include=dev -npm install --no-audit --no-fund ${omit_dev:-} \ +npm install --no-audit --no-fund "$dev" \ "$bundler_package" \ "react@$react_version" \ "react-dom@$react_version" \ From a8d5455d9b9b753d612145aa1196d29a8a9488d7 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 19:43:04 -0700 Subject: [PATCH 17/25] Shorten the bundler-test workflow comments --- .github/workflows/pull-request-validation.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 21a0bbb50b..1dc0ec2fcd 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -308,13 +308,13 @@ jobs: - react: 19 react-version: 19.3.1 # react-film 4.0.0 imports `render` from react-dom, which React 19 removed, so webpack 5 fails on the missing export. - # Re-enable when react-film is removed: https://github.com/microsoft/BotFramework-WebChat/issues/5552. + # Re-enable when react-film is updated. exclude: - bundler: webpack 5 react: 19 timeout-minutes: 10 env: - NODE_ENV: production # Consumer apps ship production builds; with the workflow's "test", Vite would bundle React's development build. + NODE_ENV: production # Apps ship production builds; with "test", Vite bundles React's development build. steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 From 4acc9c6d2614e2d32cdc21fb52557061a647bc44 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 21:38:02 -0700 Subject: [PATCH 18/25] Bundler test: one app per bundler as in its getting-started docs, checked in Chrome by run.mjs with chrome-launcher Each folder under __tests__/bundlers/ (esbuild, vite, webpack4, webpack5) is a self-contained app with its own package.json, config and src/ (the same in every folder, CI diffs it). npm scripts react16/18/19 install a React version; the configs alias react-dom/client to a shim when React 16 is installed. run.mjs serves dist/ with report.js injected and opens it with chrome-launcher, replacing browser.mjs (CDP), build.mjs and test.sh. --- .github/workflows/pull-request-validation.yml | 49 +++-- __tests__/bundlers/.gitignore | 2 + __tests__/bundlers/README.md | 28 +++ __tests__/bundlers/browser.mjs | 205 ------------------ __tests__/bundlers/build.mjs | 95 -------- __tests__/bundlers/esbuild/.gitignore | 4 + __tests__/bundlers/esbuild/build.mjs | 15 ++ __tests__/bundlers/esbuild/dist/index.html | 11 + __tests__/bundlers/esbuild/package.json | 22 ++ .../{src/app.js => esbuild/src/index.js} | 11 +- .../esbuild/src/react-dom-client.react16.js | 7 + __tests__/bundlers/package.json | 10 +- __tests__/bundlers/report.js | 37 ++++ __tests__/bundlers/run.mjs | 58 +++++ __tests__/bundlers/src/index.js | 6 - __tests__/bundlers/src/index.react16.js | 6 - __tests__/bundlers/test.sh | 31 --- __tests__/bundlers/vite/.gitignore | 3 + __tests__/bundlers/vite/index.html | 11 + __tests__/bundlers/vite/package.json | 25 +++ __tests__/bundlers/vite/src/index.js | 56 +++++ .../vite/src/react-dom-client.react16.js | 7 + __tests__/bundlers/vite/vite.config.js | 14 ++ __tests__/bundlers/webpack4/.gitignore | 4 + __tests__/bundlers/webpack4/dist/index.html | 11 + __tests__/bundlers/webpack4/package.json | 26 +++ __tests__/bundlers/webpack4/src/index.js | 56 +++++ .../webpack4/src/react-dom-client.react16.js | 7 + .../webpack.config.js} | 19 +- __tests__/bundlers/webpack5/.gitignore | 4 + __tests__/bundlers/webpack5/dist/index.html | 11 + __tests__/bundlers/webpack5/package.json | 23 ++ __tests__/bundlers/webpack5/src/index.js | 56 +++++ .../webpack5/src/react-dom-client.react16.js | 7 + __tests__/bundlers/webpack5/webpack.config.js | 18 ++ 35 files changed, 580 insertions(+), 375 deletions(-) create mode 100644 __tests__/bundlers/.gitignore create mode 100644 __tests__/bundlers/README.md delete mode 100644 __tests__/bundlers/browser.mjs delete mode 100644 __tests__/bundlers/build.mjs create mode 100644 __tests__/bundlers/esbuild/.gitignore create mode 100644 __tests__/bundlers/esbuild/build.mjs create mode 100644 __tests__/bundlers/esbuild/dist/index.html create mode 100644 __tests__/bundlers/esbuild/package.json rename __tests__/bundlers/{src/app.js => esbuild/src/index.js} (81%) create mode 100644 __tests__/bundlers/esbuild/src/react-dom-client.react16.js create mode 100644 __tests__/bundlers/report.js create mode 100644 __tests__/bundlers/run.mjs delete mode 100644 __tests__/bundlers/src/index.js delete mode 100644 __tests__/bundlers/src/index.react16.js delete mode 100755 __tests__/bundlers/test.sh create mode 100644 __tests__/bundlers/vite/.gitignore create mode 100644 __tests__/bundlers/vite/index.html create mode 100644 __tests__/bundlers/vite/package.json create mode 100644 __tests__/bundlers/vite/src/index.js create mode 100644 __tests__/bundlers/vite/src/react-dom-client.react16.js create mode 100644 __tests__/bundlers/vite/vite.config.js create mode 100644 __tests__/bundlers/webpack4/.gitignore create mode 100644 __tests__/bundlers/webpack4/dist/index.html create mode 100644 __tests__/bundlers/webpack4/package.json create mode 100644 __tests__/bundlers/webpack4/src/index.js create mode 100644 __tests__/bundlers/webpack4/src/react-dom-client.react16.js rename __tests__/bundlers/{webpack4.config.js => webpack4/webpack.config.js} (68%) create mode 100644 __tests__/bundlers/webpack5/.gitignore create mode 100644 __tests__/bundlers/webpack5/dist/index.html create mode 100644 __tests__/bundlers/webpack5/package.json create mode 100644 __tests__/bundlers/webpack5/src/index.js create mode 100644 __tests__/bundlers/webpack5/src/react-dom-client.react16.js create mode 100644 __tests__/bundlers/webpack5/webpack.config.js diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 1dc0ec2fcd..32a1fa0dc1 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -289,32 +289,20 @@ jobs: strategy: fail-fast: false matrix: - # Names without versions (webpack: major only), so check names stay the same when versions are bumped. - bundler: [esbuild, vite, webpack 4, webpack 5] + bundler: [esbuild, vite, webpack4, webpack5] # Folders under __tests__/bundlers/. react: [16, 18, 19] - include: - - bundler: esbuild - bundler-package: esbuild@0.28.2 - - bundler: vite - bundler-package: vite@8.3.5 - - bundler: webpack 4 - bundler-package: webpack@4.47.0 - - bundler: webpack 5 - bundler-package: webpack@5.107.2 - - react: 16 - react-version: 16.8.6 - - react: 18 - react-version: 18.3.1 - - react: 19 - react-version: 19.3.1 # react-film 4.0.0 imports `render` from react-dom, which React 19 removed, so webpack 5 fails on the missing export. # Re-enable when react-film is updated. exclude: - - bundler: webpack 5 + - bundler: webpack5 react: 19 timeout-minutes: 10 env: NODE_ENV: production # Apps ship production builds; with "test", Vite bundles React's development build. + npm_config_include: dev # NODE_ENV=production makes npm omit devDependencies, which have the bundlers. + defaults: + run: + working-directory: __tests__/bundlers/${{ matrix.bundler }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -326,7 +314,9 @@ jobs: with: node-version: ${{ env.node-version }} cache: npm - cache-dependency-path: __tests__/bundlers/package.json + cache-dependency-path: | + __tests__/bundlers/package.json + __tests__/bundlers/${{ matrix.bundler }}/package.json - name: Download tarballs uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -335,10 +325,27 @@ jobs: - name: Extract tarballs run: tar -xvf tarballs.tar + working-directory: . + + - name: Check that the app is the same in every folder + run: diff --recursive ../esbuild/src src + + - run: npm install + + - run: npm run react${{ matrix.react }} + + - name: Install tarballs + run: npm install --no-save ../../../packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz + + - run: npm run build + + - name: Install the runner + run: npm install + working-directory: __tests__/bundlers # Renders in the Google Chrome preinstalled on the runner. - - name: Bundle with ${{ matrix.bundler-package }} and react@${{ matrix.react-version }}, render in Chrome - run: ./__tests__/bundlers/test.sh ${{ matrix.bundler-package }} ${{ matrix.react-version }} ./packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz + - name: Render in Chrome + run: node ../run.mjs ${{ matrix.bundler }} html-test: name: HTML test (${{ format('{0}/{1}', matrix.shard-index, matrix.shard-count) }}) diff --git a/__tests__/bundlers/.gitignore b/__tests__/bundlers/.gitignore new file mode 100644 index 0000000000..d50251241e --- /dev/null +++ b/__tests__/bundlers/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/package-lock.json diff --git a/__tests__/bundlers/README.md b/__tests__/bundlers/README.md new file mode 100644 index 0000000000..fa13b3b6c9 --- /dev/null +++ b/__tests__/bundlers/README.md @@ -0,0 +1,28 @@ +# Bundler tests + +Each folder is a Web Chat app, set up the way the getting-started docs of its bundler do it: `esbuild/`, `vite/`, `webpack4/` (with the workarounds from [`docs/HOSTING.md`](../../docs/HOSTING.md#bundling-with-webpack-4)) and `webpack5/`. `src/` is the same in every folder: CI checks it. + +CI builds every app with React 16, 18 and 19 and the tarballs of the pull request, then `run.mjs` opens it in Chrome: the app must show the bot message, without errors in the console, uncaught errors or failed loads. + +## Debugging an app + +Build the tarballs first (`npm run build`, then `npm exec --workspaces -- npm pack` in the repository root), then: + +```sh +cd __tests__/bundlers/vite +npm install +npm run react16 # Or react18 (default) or react19, it changes package.json +npm install --no-save ../../../packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz +npm run build +``` + +Then open `dist/index.html` in a browser (Vite: `npm run preview`), or run the check like CI does: + +```sh +(cd .. && npm install) +node ../run.mjs vite +``` + +`run.mjs` starts the Chrome that [chrome-launcher](https://www.npmjs.com/package/chrome-launcher) finds, or the one in `CHROME_PATH` (on WSL, chrome-launcher looks for Chrome on Windows). + +Install the tarballs after the React version: `npm install` puts back the published Web Chat from `package.json`. diff --git a/__tests__/bundlers/browser.mjs b/__tests__/bundlers/browser.mjs deleted file mode 100644 index a83755d4f9..0000000000 --- a/__tests__/bundlers/browser.mjs +++ /dev/null @@ -1,205 +0,0 @@ -// Serves dist/ and loads it in headless Chrome over the DevTools protocol: Web Chat must render the bot message -// without uncaught errors, console errors, or failed requests. -// -// Usage: node browser.mjs, with $CHROME (default: google-chrome, preinstalled on GitHub-hosted Ubuntu runners). - -import { spawn } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import { tmpdir } from 'node:os'; -import { dirname, extname, join, normalize, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// Well under the job's timeout-minutes, which also covers npm install and bundling. -const CDP_TIMEOUT = 10_000; -const CHROME_START_TIMEOUT = 30_000; -const RENDER_TIMEOUT = 60_000; -const TOTAL_TIMEOUT = 120_000; - -const CONTENT_TYPES = Object.freeze({ - '.css': 'text/css', - '.html': 'text/html', - '.js': 'text/javascript', - '.json': 'application/json', - '.mjs': 'text/javascript', - '.svg': 'image/svg+xml' -}); - -// Rendered: the transcript shows the message from the fake chat adapter in src/app.js, and the send box is there. -const RENDERED = `document.readyState === 'complete' && - !!document.querySelector('[role="feed"]')?.textContent.includes('Hello from the bot.') && - !!document.querySelector('textarea')`; - -const errors = []; -const profileDir = mkdtempSync(join(tmpdir(), 'webchat-bundler-test-')); -const root = resolve(dirname(fileURLToPath(import.meta.url)), 'dist'); - -function withTimeout(promise, timeout, message) { - let timer; - - return Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(message)), timeout); - }) - ]).finally(() => clearTimeout(timer)); -} - -const server = createServer(async ({ url }, res) => { - const path = join(root, normalize(new URL(url, 'http://localhost').pathname.replace(/\/$/u, '/index.html'))); - - try { - const body = await readFile(path); - - res.writeHead(200, { 'content-type': CONTENT_TYPES[extname(path)] || 'application/octet-stream' }).end(body); - } catch { - res.writeHead(404).end(); - } -}); - -const chrome = spawn( - process.env.CHROME || 'google-chrome', - [ - '--headless=new', - '--no-first-run', - '--no-default-browser-check', - // The page only loads our own bundle, on an ephemeral CI runner, so we do not depend on the runner allowing the sandbox. - '--no-sandbox', - '--remote-debugging-port=0', - `--user-data-dir=${profileDir}`, - 'about:blank' - ], - // In its own process group, so the cleanup below also kills its renderer and GPU processes. - { detached: true, stdio: ['ignore', 'ignore', 'pipe'] } -); - -const chromeExited = new Promise(resolvePromise => chrome.once('error', resolvePromise).once('exit', resolvePromise)); - -// Never leaves Chrome or its profile behind, also when the script throws, times out, or is interrupted. -function killChrome(signal) { - try { - process.kill(-chrome.pid, signal); - } catch { - // Already exited, or never started. - } -} - -process.once('exit', () => { - killChrome('SIGKILL'); - rmSync(profileDir, { force: true, maxRetries: 5, recursive: true }); -}); - -['SIGINT', 'SIGTERM'].forEach(signal => process.once(signal, () => process.exit(1))); - -async function run() { - await new Promise(resolvePromise => server.listen(0, '127.0.0.1', resolvePromise)); - - const browserURL = await withTimeout( - new Promise((resolvePromise, reject) => { - let stderr = ''; - - chrome.once('error', reject); - chromeExited.then(code => reject(new Error(`Chrome exited with code ${code}.\n${stderr}`))); - chrome.stderr.on('data', chunk => { - stderr += chunk; - - const match = /DevTools listening on (ws:\S+)/u.exec(stderr); - - match && resolvePromise(match[1]); - }); - }), - CHROME_START_TIMEOUT, - `Chrome did not open its DevTools endpoint in ${CHROME_START_TIMEOUT / 1000} seconds.` - ); - - const socket = new WebSocket(browserURL); - const pending = new Map(); - const requestURLs = new Map(); - let lastId = 0; - - socket.addEventListener('message', ({ data }) => { - const { error, id, method, params, result } = JSON.parse(data); - - if (id) { - const { reject, resolve: resolvePromise } = pending.get(id); - - pending.delete(id); - error ? reject(new Error(JSON.stringify(error))) : resolvePromise(result); - } else if (method === 'Runtime.exceptionThrown') { - const { exception, text } = params.exceptionDetails; - - errors.push(`Uncaught: ${exception?.description || text}`); - } else if (method === 'Runtime.consoleAPICalled' && (params.type === 'assert' || params.type === 'error')) { - errors.push(`console.${params.type}: ${params.args.map(arg => arg.value ?? arg.description).join(' ')}`); - } else if (method === 'Network.requestWillBeSent') { - requestURLs.set(params.requestId, params.request.url); - } else if (method === 'Network.loadingFailed') { - errors.push(`Request failed: ${requestURLs.get(params.requestId)} ${params.errorText}`); - } else if (method === 'Network.responseReceived' && params.response.status >= 400) { - errors.push(`HTTP ${params.response.status}: ${params.response.url}`); - } - }); - - await withTimeout( - new Promise((resolvePromise, reject) => { - socket.addEventListener('open', resolvePromise, { once: true }); - socket.addEventListener('error', reject, { once: true }); - }), - CDP_TIMEOUT, - `Could not connect to Chrome DevTools in ${CDP_TIMEOUT / 1000} seconds.` - ); - - const send = (method, params = {}, sessionId) => - withTimeout( - new Promise((resolvePromise, reject) => { - const id = ++lastId; - - pending.set(id, { reject, resolve: resolvePromise }); - socket.send(JSON.stringify({ id, method, params, sessionId })); - }), - CDP_TIMEOUT, - `Chrome did not answer ${method} in ${CDP_TIMEOUT / 1000} seconds.` - ); - - const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); - const { sessionId } = await send('Target.attachToTarget', { flatten: true, targetId }); - - await Promise.all(['Network.enable', 'Runtime.enable'].map(method => send(method, {}, sessionId))); - await send('Page.navigate', { url: `http://127.0.0.1:${server.address().port}/` }, sessionId); - - let rendered = false; - - for (const startTime = Date.now(); !rendered && !errors.length && Date.now() - startTime < RENDER_TIMEOUT; ) { - const { result } = await send('Runtime.evaluate', { expression: RENDERED, returnByValue: true }, sessionId); - - rendered = result.value === true; - rendered || (await new Promise(resolvePromise => setTimeout(resolvePromise, 100))); - } - - rendered || - errors.length || - errors.push(`Web Chat did not render the bot message in ${RENDER_TIMEOUT / 1000} seconds.`); - - socket.close(); -} - -try { - await withTimeout(run(), TOTAL_TIMEOUT, `browser.mjs did not finish in ${TOTAL_TIMEOUT / 1000} seconds.`); -} catch (error) { - errors.push(error.message); -} finally { - server.close(); - server.closeAllConnections(); - - // SIGTERM lets Chrome remove its own temporary files. The profile is removed on exit, after Chrome stopped writing to it. - killChrome('SIGTERM'); - await withTimeout(chromeExited, CDP_TIMEOUT, 'Chrome did not exit.').catch(error => errors.push(error.message)); -} - -// Multi-line messages need "%0A" in GitHub Actions annotations. -errors.forEach(error => console.log(`::error::${error.replace(/\n/gu, '%0A')}`)); - -console.log(errors.length ? `${errors.length} error(s).` : 'Rendered Web Chat in Chrome, with the bot message.'); - -process.exit(errors.length ? 1 : 0); diff --git a/__tests__/bundlers/build.mjs b/__tests__/bundlers/build.mjs deleted file mode 100644 index 63eb9808cd..0000000000 --- a/__tests__/bundlers/build.mjs +++ /dev/null @@ -1,95 +0,0 @@ -// Bundles src/ like a consumer app would, with the installed bundler named on the command line, into dist/. -// Fails on errors, lists warnings. -// -// Usage: node build.mjs - -import { mkdir, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const [bundler] = process.argv.slice(2); -const here = dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); - -const { version: bundlerVersion } = require(`${bundler}/package.json`); -const { version: reactVersion } = require('react/package.json'); - -// React 16 has no "react-dom/client". -const entry = reactVersion.startsWith('16.') ? 'src/index.react16.js' : 'src/index.js'; - -// Multi-line messages need "%0A" in GitHub Actions annotations. -const annotate = (command, message) => console.log(`::${command}::${message.replace(/\n/gu, '%0A')}`); - -const html = script => ` - - - - - Web Chat bundled with ${bundler} - - -
- ${script} - - -`; - -// Production builds, like consumer apps ship, regardless of NODE_ENV (Vite would otherwise use it, e.g. "test"). -// Development builds of React log warnings, which browser.mjs fails on. -const define = Object.freeze({ 'process.env.NODE_ENV': JSON.stringify('production') }); - -console.log(`Bundling ${entry} with ${bundler}@${bundlerVersion} and react@${reactVersion}.`); - -if (bundler === 'esbuild') { - const { build } = await import('esbuild'); - - // Throws on errors, prints warnings. - await build({ - bundle: true, - define, - entryPoints: [resolve(here, entry)], - minify: true, - outfile: resolve(here, 'dist/main.js') - }); - await writeFile(resolve(here, 'dist/index.html'), html('')); -} else if (bundler === 'vite') { - const { build } = await import('vite'); - - // Vite bundles from index.html. Throws on errors, prints warnings. - await writeFile(resolve(here, 'index.html'), html(``)); - await build({ define, logLevel: 'warn', mode: 'production', root: here }); -} else if (bundler === 'webpack') { - const webpack = require('webpack'); - - const stats = await new Promise((resolvePromise, reject) => - webpack( - { - entry: resolve(here, entry), - mode: 'production', - output: { filename: 'main.js', path: resolve(here, 'dist') }, - performance: { hints: false }, - ...(bundlerVersion.startsWith('4.') && require('./webpack4.config.js')) - }, - (error, stats) => (error ? reject(error) : resolvePromise(stats)) - ) - ); - - // webpack 4 reports strings, webpack 5 reports objects. - const { errors, warnings } = stats.toJson({ all: false, errors: true, warnings: true }); - const toString = entry => (typeof entry === 'string' ? entry : `${entry.moduleName || ''}\n${entry.message}`); - - warnings.forEach(warning => annotate('warning', toString(warning))); - errors.forEach(error => annotate('error', toString(error))); - - console.log(`${errors.length} error(s), ${warnings.length} warning(s).`); - - errors.length && process.exit(1); - - await mkdir(resolve(here, 'dist'), { recursive: true }); - await writeFile(resolve(here, 'dist/index.html'), html('')); -} else { - throw new Error(`Unknown bundler "${bundler}".`); -} - -console.log('Bundled into dist/.'); diff --git a/__tests__/bundlers/esbuild/.gitignore b/__tests__/bundlers/esbuild/.gitignore new file mode 100644 index 0000000000..021c35a3a6 --- /dev/null +++ b/__tests__/bundlers/esbuild/.gitignore @@ -0,0 +1,4 @@ +/dist/* +!/dist/index.html +/node_modules +/package-lock.json diff --git a/__tests__/bundlers/esbuild/build.mjs b/__tests__/bundlers/esbuild/build.mjs new file mode 100644 index 0000000000..f02aae37a5 --- /dev/null +++ b/__tests__/bundlers/esbuild/build.mjs @@ -0,0 +1,15 @@ +// esbuild's build script (https://esbuild.github.io/getting-started/#build-scripts): the CLI cannot pick an alias by the installed React. + +import * as esbuild from 'esbuild'; +import { createRequire } from 'node:module'; + +const { version: reactDOMVersion } = createRequire(import.meta.url)('react-dom/package.json'); + +await esbuild.build({ + // React 16 has no "react-dom/client". + alias: reactDOMVersion.startsWith('16.') ? { 'react-dom/client': './src/react-dom-client.react16.js' } : {}, + bundle: true, + entryPoints: ['src/index.js'], + minify: true, + outfile: 'dist/main.js' +}); diff --git a/__tests__/bundlers/esbuild/dist/index.html b/__tests__/bundlers/esbuild/dist/index.html new file mode 100644 index 0000000000..5dbe6e20af --- /dev/null +++ b/__tests__/bundlers/esbuild/dist/index.html @@ -0,0 +1,11 @@ + + + + + Web Chat bundled with esbuild + + +
+ + + diff --git a/__tests__/bundlers/esbuild/package.json b/__tests__/bundlers/esbuild/package.json new file mode 100644 index 0000000000..1f2029af31 --- /dev/null +++ b/__tests__/bundlers/esbuild/package.json @@ -0,0 +1,22 @@ +{ + "name": "@msinternal/botframework-webchat-bundlers-esbuild", + "version": "0.0.0-0", + "description": "A Web Chat app bundled with esbuild, see ../README.md", + "private": true, + "license": "MIT", + "scripts": { + "build": "node build.mjs", + "react16": "npm install --save-exact react@16.8.6 react-dom@16.8.6", + "react18": "npm install --save-exact react@18.3.1 react-dom@18.3.1", + "react19": "npm install --save-exact react@19.3.1 react-dom@19.3.1" + }, + "dependencies": { + "botframework-webchat": "4.19.2", + "botframework-webchat-fluent-theme": "4.19.2", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "esbuild": "0.28.2" + } +} diff --git a/__tests__/bundlers/src/app.js b/__tests__/bundlers/esbuild/src/index.js similarity index 81% rename from __tests__/bundlers/src/app.js rename to __tests__/bundlers/esbuild/src/index.js index a27c0f02b3..3c69690f70 100644 --- a/__tests__/bundlers/src/app.js +++ b/__tests__/bundlers/esbuild/src/index.js @@ -1,3 +1,5 @@ +// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. + import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; import { Composer } from 'botframework-webchat/component.js'; @@ -5,6 +7,7 @@ import { WebChatDecorator } from 'botframework-webchat/decorator.js'; import { useActivities } from 'botframework-webchat/hook.js'; import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; // On React 16, the bundler config points it to react-dom-client.react16.js. // Every import must be defined: a bundler can resolve a subpath to an empty module without an error. // "botframework-webchat/schema.js" is imported by the Fluent theme. @@ -34,7 +37,7 @@ function observableOf(...values) { }); } -// A chat adapter that connects at once and receives one message from the bot, browser.mjs waits for it. +// A chat adapter that connects at once and receives one message from the bot. const directLine = Object.freeze({ activity$: observableOf({ from: { id: 'bot', role: 'bot' }, @@ -48,6 +51,6 @@ const directLine = Object.freeze({ postActivity: () => observableOf('a-00002') }); -export default function createApp() { - return createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })); -} +createRoot(document.getElementById('webchat')).render( + createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })) +); diff --git a/__tests__/bundlers/esbuild/src/react-dom-client.react16.js b/__tests__/bundlers/esbuild/src/react-dom-client.react16.js new file mode 100644 index 0000000000..7224e9b0c6 --- /dev/null +++ b/__tests__/bundlers/esbuild/src/react-dom-client.react16.js @@ -0,0 +1,7 @@ +// React 16 has no "react-dom/client": the bundler config points it here when React 16 is installed. + +import { render } from 'react-dom'; + +export function createRoot(container) { + return Object.freeze({ render: children => render(children, container) }); +} diff --git a/__tests__/bundlers/package.json b/__tests__/bundlers/package.json index 5fc4a490d4..fb69606df5 100644 --- a/__tests__/bundlers/package.json +++ b/__tests__/bundlers/package.json @@ -1,12 +1,10 @@ { - "name": "@msinternal/botframework-webchat-bundlers-consumer", + "name": "@msinternal/botframework-webchat-bundlers", "version": "0.0.0-0", - "description": "A consumer app bundling Web Chat with esbuild, Vite, or webpack 4 or 5, see test.sh", + "description": "Checks that the Web Chat apps in the folders here render in Chrome, see README.md", "private": true, "license": "MIT", - "devDependencies": { - "@babel/core": "7.29.7", - "@babel/preset-env": "7.29.7", - "babel-loader": "8.4.1" + "dependencies": { + "chrome-launcher": "1.2.2" } } diff --git a/__tests__/bundlers/report.js b/__tests__/bundlers/report.js new file mode 100644 index 0000000000..ce0b5390d6 --- /dev/null +++ b/__tests__/bundlers/report.js @@ -0,0 +1,37 @@ +// Injected by run.mjs before the app's scripts: records errors, and reports once Web Chat shows the bot message. + +(() => { + const errors = []; + const { error } = console; + + console.error = (...args) => { + errors.push(args.map(arg => arg?.stack || String(arg)).join(' ')); + error.apply(console, args); + }; + + // Capture phase: a script or style sheet that fails to load fires a non-bubbling "error" on its element. + addEventListener( + 'error', + event => + errors.push( + event.target === window + ? event.error?.stack || event.message + : `Failed to load ${event.target.src || event.target.href}.` + ), + true + ); + + addEventListener('unhandledrejection', ({ reason }) => errors.push(reason?.stack || String(reason))); + + const interval = setInterval(() => { + const rendered = + document.readyState === 'complete' && + !!document.querySelector('[role="feed"]')?.textContent.includes('Hello from the bot.') && + !!document.querySelector('textarea'); + + if (rendered || errors.length) { + clearInterval(interval); + fetch('/__runner/report', { body: JSON.stringify({ errors, ok: rendered && !errors.length }), method: 'POST' }); + } + }, 100); +})(); diff --git a/__tests__/bundlers/run.mjs b/__tests__/bundlers/run.mjs new file mode 100644 index 0000000000..b0364301ad --- /dev/null +++ b/__tests__/bundlers/run.mjs @@ -0,0 +1,58 @@ +// Serves /dist/ with report.js injected before the app's scripts, and opens it in headless Chrome: passes when +// report.js reports that Web Chat shows the bot message without errors. +// +// Usage: node run.mjs + +import { launch } from 'chrome-launcher'; +import { access, readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, join, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CONTENT_TYPES = Object.freeze({ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8' }); +const TIMEOUT = 60_000; + +const root = fileURLToPath(new URL(`${process.argv[2]}/dist/`, import.meta.url)); +const { promise: reported, resolve: report } = Promise.withResolvers(); + +await access(join(root, 'index.html')); // Fails with the path when the app is not built. + +setTimeout(() => report({ errors: [`No report from the page in ${TIMEOUT / 1000} seconds.`] }), TIMEOUT).unref(); + +const server = createServer(async (request, response) => { + const { pathname } = new URL(request.url, 'http://localhost'); + + if (pathname === '/__runner/report') { + report(JSON.parse(Buffer.concat(await Array.fromAsync(request)))); + + return response.end(); + } + + try { + const path = + pathname === '/__runner/report.js' + ? fileURLToPath(new URL('report.js', import.meta.url)) + : join(root, normalize(pathname).replace(/\/$/u, '/index.html')); + const body = await readFile(path); + + response + .writeHead(200, { 'content-type': CONTENT_TYPES[extname(path)] || 'application/octet-stream' }) + .end( + path.endsWith('.html') ? `${body}`.replace(//iu, '$&') : body + ); + } catch { + response.writeHead(404).end(); + } +}); + +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + +const chrome = await launch({ chromeFlags: ['--headless'], startingUrl: `http://127.0.0.1:${server.address().port}/` }); +const { errors, ok } = await reported; + +chrome.kill(); +server.close(); + +errors.forEach(error => console.error(error)); +console.log(ok ? `Web Chat bundled in ${root} rendered the bot message.` : `Failed to render ${root}.`); +process.exitCode = ok ? 0 : 1; diff --git a/__tests__/bundlers/src/index.js b/__tests__/bundlers/src/index.js deleted file mode 100644 index f655a2b7f1..0000000000 --- a/__tests__/bundlers/src/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import { createRoot } from 'react-dom/client'; - -import createApp from './app.js'; - -// React 18 and later. -createRoot(document.getElementById('webchat')).render(createApp()); diff --git a/__tests__/bundlers/src/index.react16.js b/__tests__/bundlers/src/index.react16.js deleted file mode 100644 index 5a2568ce62..0000000000 --- a/__tests__/bundlers/src/index.react16.js +++ /dev/null @@ -1,6 +0,0 @@ -import { render } from 'react-dom'; - -import createApp from './app.js'; - -// React 16 has no "react-dom/client". -render(createApp(), document.getElementById('webchat')); diff --git a/__tests__/bundlers/test.sh b/__tests__/bundlers/test.sh deleted file mode 100755 index fca6ecec3f..0000000000 --- a/__tests__/bundlers/test.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# Bundles Web Chat in a consumer app, like apps install it from npm, with the given bundler and React versions, -# then renders it in Chrome. -# -# Usage: test.sh @ ... -# The app is created under $APP_DIR (default: a new temporary directory) so it does not see the monorepo node_modules. - -set -euo pipefail - -bundler_package=$1 -react_version=$2 -shift 2 - -app_dir=${APP_DIR:-$(mktemp -d)} -mapfile -t tarballs < <(realpath "$@") - -cp -R "$(dirname "$0")/." "$app_dir" -cd "$app_dir" - -# The devDependencies in package.json are for webpack4.config.js only (explicit: NODE_ENV=production omits them by default). -dev=--omit=dev -[[ $bundler_package == webpack@4.* ]] && dev=--include=dev - -npm install --no-audit --no-fund "$dev" \ - "$bundler_package" \ - "react@$react_version" \ - "react-dom@$react_version" \ - "${tarballs[@]}" - -node build.mjs "${bundler_package%@*}" -node browser.mjs diff --git a/__tests__/bundlers/vite/.gitignore b/__tests__/bundlers/vite/.gitignore new file mode 100644 index 0000000000..8e206f58f4 --- /dev/null +++ b/__tests__/bundlers/vite/.gitignore @@ -0,0 +1,3 @@ +/dist +/node_modules +/package-lock.json diff --git a/__tests__/bundlers/vite/index.html b/__tests__/bundlers/vite/index.html new file mode 100644 index 0000000000..177d98e1b1 --- /dev/null +++ b/__tests__/bundlers/vite/index.html @@ -0,0 +1,11 @@ + + + + + Web Chat bundled with Vite + + +
+ + + diff --git a/__tests__/bundlers/vite/package.json b/__tests__/bundlers/vite/package.json new file mode 100644 index 0000000000..428d5b96de --- /dev/null +++ b/__tests__/bundlers/vite/package.json @@ -0,0 +1,25 @@ +{ + "name": "@msinternal/botframework-webchat-bundlers-vite", + "version": "0.0.0-0", + "description": "A Web Chat app bundled with Vite, see ../README.md", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "react16": "npm install --save-exact react@16.8.6 react-dom@16.8.6", + "react18": "npm install --save-exact react@18.3.1 react-dom@18.3.1", + "react19": "npm install --save-exact react@19.3.1 react-dom@19.3.1" + }, + "dependencies": { + "botframework-webchat": "4.19.2", + "botframework-webchat-fluent-theme": "4.19.2", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "vite": "8.3.5" + } +} diff --git a/__tests__/bundlers/vite/src/index.js b/__tests__/bundlers/vite/src/index.js new file mode 100644 index 0000000000..3c69690f70 --- /dev/null +++ b/__tests__/bundlers/vite/src/index.js @@ -0,0 +1,56 @@ +// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. + +import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; +import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; +import { Composer } from 'botframework-webchat/component.js'; +import { WebChatDecorator } from 'botframework-webchat/decorator.js'; +import { useActivities } from 'botframework-webchat/hook.js'; +import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; // On React 16, the bundler config points it to react-dom-client.react16.js. + +// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. +// "botframework-webchat/schema.js" is imported by the Fluent theme. +Object.entries({ + Composer, + createActivityPolymiddleware, + createDirectLine, + FluentThemeProvider, + ReactWebChat, + renderWebChat, + useActivities, + WebChatDecorator +}).forEach(([name, value]) => { + if (!value) { + throw new Error(`Import "${name}" is ${value}.`); + } +}); + +// Emits the values on every subscription, asynchronously like DirectLineJS. +function observableOf(...values) { + return Object.freeze({ + subscribe: ({ next }) => { + const timeout = setTimeout(() => values.forEach(value => next(value))); + + return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); + } + }); +} + +// A chat adapter that connects at once and receives one message from the bot. +const directLine = Object.freeze({ + activity$: observableOf({ + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello from the bot.', + timestamp: new Date().toISOString(), + type: 'message' + }), + connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. + end: () => undefined, + postActivity: () => observableOf('a-00002') +}); + +createRoot(document.getElementById('webchat')).render( + createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })) +); diff --git a/__tests__/bundlers/vite/src/react-dom-client.react16.js b/__tests__/bundlers/vite/src/react-dom-client.react16.js new file mode 100644 index 0000000000..7224e9b0c6 --- /dev/null +++ b/__tests__/bundlers/vite/src/react-dom-client.react16.js @@ -0,0 +1,7 @@ +// React 16 has no "react-dom/client": the bundler config points it here when React 16 is installed. + +import { render } from 'react-dom'; + +export function createRoot(container) { + return Object.freeze({ render: children => render(children, container) }); +} diff --git a/__tests__/bundlers/vite/vite.config.js b/__tests__/bundlers/vite/vite.config.js new file mode 100644 index 0000000000..70e8a65cee --- /dev/null +++ b/__tests__/bundlers/vite/vite.config.js @@ -0,0 +1,14 @@ +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; + +const { version: reactDOMVersion } = createRequire(import.meta.url)('react-dom/package.json'); + +export default defineConfig({ + resolve: { + // React 16 has no "react-dom/client". + alias: reactDOMVersion.startsWith('16.') + ? { 'react-dom/client': fileURLToPath(new URL('./src/react-dom-client.react16.js', import.meta.url)) } + : {} + } +}); diff --git a/__tests__/bundlers/webpack4/.gitignore b/__tests__/bundlers/webpack4/.gitignore new file mode 100644 index 0000000000..021c35a3a6 --- /dev/null +++ b/__tests__/bundlers/webpack4/.gitignore @@ -0,0 +1,4 @@ +/dist/* +!/dist/index.html +/node_modules +/package-lock.json diff --git a/__tests__/bundlers/webpack4/dist/index.html b/__tests__/bundlers/webpack4/dist/index.html new file mode 100644 index 0000000000..88f9028e51 --- /dev/null +++ b/__tests__/bundlers/webpack4/dist/index.html @@ -0,0 +1,11 @@ + + + + + Web Chat bundled with webpack 4 + + +
+ + + diff --git a/__tests__/bundlers/webpack4/package.json b/__tests__/bundlers/webpack4/package.json new file mode 100644 index 0000000000..d05053318b --- /dev/null +++ b/__tests__/bundlers/webpack4/package.json @@ -0,0 +1,26 @@ +{ + "name": "@msinternal/botframework-webchat-bundlers-webpack4", + "version": "0.0.0-0", + "description": "A Web Chat app bundled with webpack 4, see ../README.md", + "private": true, + "license": "MIT", + "scripts": { + "build": "webpack", + "react16": "npm install --save-exact react@16.8.6 react-dom@16.8.6", + "react18": "npm install --save-exact react@18.3.1 react-dom@18.3.1", + "react19": "npm install --save-exact react@19.3.1 react-dom@19.3.1" + }, + "dependencies": { + "botframework-webchat": "4.19.2", + "botframework-webchat-fluent-theme": "4.19.2", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@babel/core": "7.29.7", + "@babel/preset-env": "7.29.7", + "babel-loader": "8.4.1", + "webpack": "4.47.0", + "webpack-cli": "3.3.12" + } +} diff --git a/__tests__/bundlers/webpack4/src/index.js b/__tests__/bundlers/webpack4/src/index.js new file mode 100644 index 0000000000..3c69690f70 --- /dev/null +++ b/__tests__/bundlers/webpack4/src/index.js @@ -0,0 +1,56 @@ +// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. + +import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; +import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; +import { Composer } from 'botframework-webchat/component.js'; +import { WebChatDecorator } from 'botframework-webchat/decorator.js'; +import { useActivities } from 'botframework-webchat/hook.js'; +import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; // On React 16, the bundler config points it to react-dom-client.react16.js. + +// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. +// "botframework-webchat/schema.js" is imported by the Fluent theme. +Object.entries({ + Composer, + createActivityPolymiddleware, + createDirectLine, + FluentThemeProvider, + ReactWebChat, + renderWebChat, + useActivities, + WebChatDecorator +}).forEach(([name, value]) => { + if (!value) { + throw new Error(`Import "${name}" is ${value}.`); + } +}); + +// Emits the values on every subscription, asynchronously like DirectLineJS. +function observableOf(...values) { + return Object.freeze({ + subscribe: ({ next }) => { + const timeout = setTimeout(() => values.forEach(value => next(value))); + + return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); + } + }); +} + +// A chat adapter that connects at once and receives one message from the bot. +const directLine = Object.freeze({ + activity$: observableOf({ + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello from the bot.', + timestamp: new Date().toISOString(), + type: 'message' + }), + connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. + end: () => undefined, + postActivity: () => observableOf('a-00002') +}); + +createRoot(document.getElementById('webchat')).render( + createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })) +); diff --git a/__tests__/bundlers/webpack4/src/react-dom-client.react16.js b/__tests__/bundlers/webpack4/src/react-dom-client.react16.js new file mode 100644 index 0000000000..7224e9b0c6 --- /dev/null +++ b/__tests__/bundlers/webpack4/src/react-dom-client.react16.js @@ -0,0 +1,7 @@ +// React 16 has no "react-dom/client": the bundler config points it here when React 16 is installed. + +import { render } from 'react-dom'; + +export function createRoot(container) { + return Object.freeze({ render: children => render(children, container) }); +} diff --git a/__tests__/bundlers/webpack4.config.js b/__tests__/bundlers/webpack4/webpack.config.js similarity index 68% rename from __tests__/bundlers/webpack4.config.js rename to __tests__/bundlers/webpack4/webpack.config.js index be689a235e..73fb7d89d3 100644 --- a/__tests__/bundlers/webpack4.config.js +++ b/__tests__/bundlers/webpack4/webpack.config.js @@ -1,6 +1,11 @@ -// The webpack 4 workarounds from docs/HOSTING.md ("Bundling with webpack 4"), keep both the same: CI checks the documented ones. +// webpack's getting-started config, plus the webpack 4 workarounds from docs/HOSTING.md ("Bundling with webpack 4") from +// `const { sep }` on: keep them the same, CI checks the documented ones. // Web Chat itself ships ES2019 and is deliberately not transpiled, so these legs fail if it starts shipping newer syntax. +const path = require('path'); + +const { version: reactDOMVersion } = require('react-dom/package.json'); + const { sep } = require('path'); // Dependencies of Web Chat that ship syntax newer than ES2019 (e.g. `?.`, `#private`, `1_000`), which webpack 4 cannot parse. @@ -20,6 +25,18 @@ const transpilePackages = [ ]; module.exports = { + entry: './src/index.js', + mode: 'production', + output: { + filename: 'main.js', + path: path.resolve(__dirname, 'dist') + }, + resolve: { + // React 16 has no "react-dom/client". + alias: reactDOMVersion.startsWith('16.') + ? { 'react-dom/client$': path.resolve(__dirname, 'src/react-dom-client.react16.js') } + : {} + }, module: { rules: [ // Lets .mjs import named exports from CommonJS, like create-react-app@4. diff --git a/__tests__/bundlers/webpack5/.gitignore b/__tests__/bundlers/webpack5/.gitignore new file mode 100644 index 0000000000..021c35a3a6 --- /dev/null +++ b/__tests__/bundlers/webpack5/.gitignore @@ -0,0 +1,4 @@ +/dist/* +!/dist/index.html +/node_modules +/package-lock.json diff --git a/__tests__/bundlers/webpack5/dist/index.html b/__tests__/bundlers/webpack5/dist/index.html new file mode 100644 index 0000000000..76a4ac5245 --- /dev/null +++ b/__tests__/bundlers/webpack5/dist/index.html @@ -0,0 +1,11 @@ + + + + + Web Chat bundled with webpack 5 + + +
+ + + diff --git a/__tests__/bundlers/webpack5/package.json b/__tests__/bundlers/webpack5/package.json new file mode 100644 index 0000000000..b3e9b28191 --- /dev/null +++ b/__tests__/bundlers/webpack5/package.json @@ -0,0 +1,23 @@ +{ + "name": "@msinternal/botframework-webchat-bundlers-webpack5", + "version": "0.0.0-0", + "description": "A Web Chat app bundled with webpack 5, see ../README.md", + "private": true, + "license": "MIT", + "scripts": { + "build": "webpack", + "react16": "npm install --save-exact react@16.8.6 react-dom@16.8.6", + "react18": "npm install --save-exact react@18.3.1 react-dom@18.3.1", + "react19": "npm install --save-exact react@19.3.1 react-dom@19.3.1" + }, + "dependencies": { + "botframework-webchat": "4.19.2", + "botframework-webchat-fluent-theme": "4.19.2", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "webpack": "5.107.2", + "webpack-cli": "7.2.3" + } +} diff --git a/__tests__/bundlers/webpack5/src/index.js b/__tests__/bundlers/webpack5/src/index.js new file mode 100644 index 0000000000..3c69690f70 --- /dev/null +++ b/__tests__/bundlers/webpack5/src/index.js @@ -0,0 +1,56 @@ +// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. + +import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; +import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; +import { Composer } from 'botframework-webchat/component.js'; +import { WebChatDecorator } from 'botframework-webchat/decorator.js'; +import { useActivities } from 'botframework-webchat/hook.js'; +import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; // On React 16, the bundler config points it to react-dom-client.react16.js. + +// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. +// "botframework-webchat/schema.js" is imported by the Fluent theme. +Object.entries({ + Composer, + createActivityPolymiddleware, + createDirectLine, + FluentThemeProvider, + ReactWebChat, + renderWebChat, + useActivities, + WebChatDecorator +}).forEach(([name, value]) => { + if (!value) { + throw new Error(`Import "${name}" is ${value}.`); + } +}); + +// Emits the values on every subscription, asynchronously like DirectLineJS. +function observableOf(...values) { + return Object.freeze({ + subscribe: ({ next }) => { + const timeout = setTimeout(() => values.forEach(value => next(value))); + + return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); + } + }); +} + +// A chat adapter that connects at once and receives one message from the bot. +const directLine = Object.freeze({ + activity$: observableOf({ + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello from the bot.', + timestamp: new Date().toISOString(), + type: 'message' + }), + connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. + end: () => undefined, + postActivity: () => observableOf('a-00002') +}); + +createRoot(document.getElementById('webchat')).render( + createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })) +); diff --git a/__tests__/bundlers/webpack5/src/react-dom-client.react16.js b/__tests__/bundlers/webpack5/src/react-dom-client.react16.js new file mode 100644 index 0000000000..7224e9b0c6 --- /dev/null +++ b/__tests__/bundlers/webpack5/src/react-dom-client.react16.js @@ -0,0 +1,7 @@ +// React 16 has no "react-dom/client": the bundler config points it here when React 16 is installed. + +import { render } from 'react-dom'; + +export function createRoot(container) { + return Object.freeze({ render: children => render(children, container) }); +} diff --git a/__tests__/bundlers/webpack5/webpack.config.js b/__tests__/bundlers/webpack5/webpack.config.js new file mode 100644 index 0000000000..b746688f83 --- /dev/null +++ b/__tests__/bundlers/webpack5/webpack.config.js @@ -0,0 +1,18 @@ +const path = require('path'); + +const { version: reactDOMVersion } = require('react-dom/package.json'); + +module.exports = { + entry: './src/index.js', + mode: 'production', + output: { + filename: 'main.js', + path: path.resolve(__dirname, 'dist') + }, + resolve: { + // React 16 has no "react-dom/client". + alias: reactDOMVersion.startsWith('16.') + ? { 'react-dom/client$': path.resolve(__dirname, 'src/react-dom-client.react16.js') } + : {} + } +}; From 5277f3bdc6782eee71bda13814f7a56b87a130ca Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Wed, 28 Oct 2026 22:00:08 -0700 Subject: [PATCH 19/25] Bundler test: React scripts install the tarballs in the same npm install, run.mjs answers an invalid report with 400 `npm run react16 -- ` keeps package.json untouched: a separate install would put back package.json's React. Also README: WSL needs CHROME_PATH; webpack4 config comment. --- .github/workflows/pull-request-validation.yml | 6 ++---- __tests__/bundlers/README.md | 7 +++---- __tests__/bundlers/esbuild/package.json | 6 +++--- __tests__/bundlers/run.mjs | 10 ++++++++-- __tests__/bundlers/vite/package.json | 6 +++--- __tests__/bundlers/webpack4/package.json | 6 +++--- __tests__/bundlers/webpack4/webpack.config.js | 2 +- __tests__/bundlers/webpack5/package.json | 6 +++--- 8 files changed, 26 insertions(+), 23 deletions(-) diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 32a1fa0dc1..62852c10e6 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -332,10 +332,8 @@ jobs: - run: npm install - - run: npm run react${{ matrix.react }} - - - name: Install tarballs - run: npm install --no-save ../../../packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz + - name: Install React ${{ matrix.react }} and the tarballs + run: npm run react${{ matrix.react }} -- ../../../packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz - run: npm run build diff --git a/__tests__/bundlers/README.md b/__tests__/bundlers/README.md index fa13b3b6c9..92576e1a3e 100644 --- a/__tests__/bundlers/README.md +++ b/__tests__/bundlers/README.md @@ -11,8 +11,7 @@ Build the tarballs first (`npm run build`, then `npm exec --workspaces -- npm pa ```sh cd __tests__/bundlers/vite npm install -npm run react16 # Or react18 (default) or react19, it changes package.json -npm install --no-save ../../../packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz +npm run react16 -- ../../../packages/{api,bundle,component,core,directlinespeech,fluent-theme}/*.tgz # Or react18, react19 npm run build ``` @@ -23,6 +22,6 @@ Then open `dist/index.html` in a browser (Vite: `npm run preview`), or run the c node ../run.mjs vite ``` -`run.mjs` starts the Chrome that [chrome-launcher](https://www.npmjs.com/package/chrome-launcher) finds, or the one in `CHROME_PATH` (on WSL, chrome-launcher looks for Chrome on Windows). +`run.mjs` starts the Chrome that [chrome-launcher](https://www.npmjs.com/package/chrome-launcher) finds, or the one in `CHROME_PATH`. On WSL, chrome-launcher looks for Chrome on Windows and fails if it is not installed there: set `CHROME_PATH` to a Linux Chrome, then chrome-launcher leaves a `C:\Users\…\lighthouse.*` folder in the current folder. -Install the tarballs after the React version: `npm install` puts back the published Web Chat from `package.json`. +The React scripts install the tarballs in the same `npm install`: a separate `npm install` would put back the React and Web Chat versions from `package.json`. diff --git a/__tests__/bundlers/esbuild/package.json b/__tests__/bundlers/esbuild/package.json index 1f2029af31..5ac55d5f1a 100644 --- a/__tests__/bundlers/esbuild/package.json +++ b/__tests__/bundlers/esbuild/package.json @@ -6,9 +6,9 @@ "license": "MIT", "scripts": { "build": "node build.mjs", - "react16": "npm install --save-exact react@16.8.6 react-dom@16.8.6", - "react18": "npm install --save-exact react@18.3.1 react-dom@18.3.1", - "react19": "npm install --save-exact react@19.3.1 react-dom@19.3.1" + "react16": "npm install --no-save react@16.8.6 react-dom@16.8.6", + "react18": "npm install --no-save react@18.3.1 react-dom@18.3.1", + "react19": "npm install --no-save react@19.3.1 react-dom@19.3.1" }, "dependencies": { "botframework-webchat": "4.19.2", diff --git a/__tests__/bundlers/run.mjs b/__tests__/bundlers/run.mjs index b0364301ad..e7724eb939 100644 --- a/__tests__/bundlers/run.mjs +++ b/__tests__/bundlers/run.mjs @@ -23,9 +23,15 @@ const server = createServer(async (request, response) => { const { pathname } = new URL(request.url, 'http://localhost'); if (pathname === '/__runner/report') { - report(JSON.parse(Buffer.concat(await Array.fromAsync(request)))); + try { + report(JSON.parse(Buffer.concat(await Array.fromAsync(request)))); + response.end(); + } catch (error) { + report({ errors: [`Invalid report from the page: ${error.message}`] }); + response.writeHead(400).end(); + } - return response.end(); + return; } try { diff --git a/__tests__/bundlers/vite/package.json b/__tests__/bundlers/vite/package.json index 428d5b96de..da1f989890 100644 --- a/__tests__/bundlers/vite/package.json +++ b/__tests__/bundlers/vite/package.json @@ -9,9 +9,9 @@ "build": "vite build", "dev": "vite", "preview": "vite preview", - "react16": "npm install --save-exact react@16.8.6 react-dom@16.8.6", - "react18": "npm install --save-exact react@18.3.1 react-dom@18.3.1", - "react19": "npm install --save-exact react@19.3.1 react-dom@19.3.1" + "react16": "npm install --no-save react@16.8.6 react-dom@16.8.6", + "react18": "npm install --no-save react@18.3.1 react-dom@18.3.1", + "react19": "npm install --no-save react@19.3.1 react-dom@19.3.1" }, "dependencies": { "botframework-webchat": "4.19.2", diff --git a/__tests__/bundlers/webpack4/package.json b/__tests__/bundlers/webpack4/package.json index d05053318b..5f98229aef 100644 --- a/__tests__/bundlers/webpack4/package.json +++ b/__tests__/bundlers/webpack4/package.json @@ -6,9 +6,9 @@ "license": "MIT", "scripts": { "build": "webpack", - "react16": "npm install --save-exact react@16.8.6 react-dom@16.8.6", - "react18": "npm install --save-exact react@18.3.1 react-dom@18.3.1", - "react19": "npm install --save-exact react@19.3.1 react-dom@19.3.1" + "react16": "npm install --no-save react@16.8.6 react-dom@16.8.6", + "react18": "npm install --no-save react@18.3.1 react-dom@18.3.1", + "react19": "npm install --no-save react@19.3.1 react-dom@19.3.1" }, "dependencies": { "botframework-webchat": "4.19.2", diff --git a/__tests__/bundlers/webpack4/webpack.config.js b/__tests__/bundlers/webpack4/webpack.config.js index 73fb7d89d3..1a3b3586a8 100644 --- a/__tests__/bundlers/webpack4/webpack.config.js +++ b/__tests__/bundlers/webpack4/webpack.config.js @@ -1,5 +1,5 @@ // webpack's getting-started config, plus the webpack 4 workarounds from docs/HOSTING.md ("Bundling with webpack 4") from -// `const { sep }` on: keep them the same, CI checks the documented ones. +// `const { sep }` on: keep them the same, CI tests them. // Web Chat itself ships ES2019 and is deliberately not transpiled, so these legs fail if it starts shipping newer syntax. const path = require('path'); diff --git a/__tests__/bundlers/webpack5/package.json b/__tests__/bundlers/webpack5/package.json index b3e9b28191..a54ac8419d 100644 --- a/__tests__/bundlers/webpack5/package.json +++ b/__tests__/bundlers/webpack5/package.json @@ -6,9 +6,9 @@ "license": "MIT", "scripts": { "build": "webpack", - "react16": "npm install --save-exact react@16.8.6 react-dom@16.8.6", - "react18": "npm install --save-exact react@18.3.1 react-dom@18.3.1", - "react19": "npm install --save-exact react@19.3.1 react-dom@19.3.1" + "react16": "npm install --no-save react@16.8.6 react-dom@16.8.6", + "react18": "npm install --no-save react@18.3.1 react-dom@18.3.1", + "react19": "npm install --no-save react@19.3.1 react-dom@19.3.1" }, "dependencies": { "botframework-webchat": "4.19.2", From 9588d4c5be2a45a30e3d788e582119821ccd1848 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 29 Oct 2026 00:38:13 -0700 Subject: [PATCH 20/25] Bundler test: React 16 apps render with ReactDOM.render from their own entry instead of a createRoot shim --- __tests__/bundlers/esbuild/build.mjs | 6 +- __tests__/bundlers/esbuild/src/app.js | 54 ++++++++++++++++++ __tests__/bundlers/esbuild/src/index.js | 57 ++----------------- .../bundlers/esbuild/src/index.react16.js | 7 +++ .../esbuild/src/react-dom-client.react16.js | 7 --- __tests__/bundlers/vite/index.html | 2 +- __tests__/bundlers/vite/src/app.js | 54 ++++++++++++++++++ __tests__/bundlers/vite/src/index.js | 57 ++----------------- __tests__/bundlers/vite/src/index.react16.js | 7 +++ .../vite/src/react-dom-client.react16.js | 7 --- __tests__/bundlers/vite/vite.config.js | 11 ++-- __tests__/bundlers/webpack4/src/app.js | 54 ++++++++++++++++++ __tests__/bundlers/webpack4/src/index.js | 57 ++----------------- .../bundlers/webpack4/src/index.react16.js | 7 +++ .../webpack4/src/react-dom-client.react16.js | 7 --- __tests__/bundlers/webpack4/webpack.config.js | 8 +-- __tests__/bundlers/webpack5/src/app.js | 54 ++++++++++++++++++ __tests__/bundlers/webpack5/src/index.js | 57 ++----------------- .../bundlers/webpack5/src/index.react16.js | 7 +++ .../webpack5/src/react-dom-client.react16.js | 7 --- __tests__/bundlers/webpack5/webpack.config.js | 8 +-- 21 files changed, 270 insertions(+), 265 deletions(-) create mode 100644 __tests__/bundlers/esbuild/src/app.js create mode 100644 __tests__/bundlers/esbuild/src/index.react16.js delete mode 100644 __tests__/bundlers/esbuild/src/react-dom-client.react16.js create mode 100644 __tests__/bundlers/vite/src/app.js create mode 100644 __tests__/bundlers/vite/src/index.react16.js delete mode 100644 __tests__/bundlers/vite/src/react-dom-client.react16.js create mode 100644 __tests__/bundlers/webpack4/src/app.js create mode 100644 __tests__/bundlers/webpack4/src/index.react16.js delete mode 100644 __tests__/bundlers/webpack4/src/react-dom-client.react16.js create mode 100644 __tests__/bundlers/webpack5/src/app.js create mode 100644 __tests__/bundlers/webpack5/src/index.react16.js delete mode 100644 __tests__/bundlers/webpack5/src/react-dom-client.react16.js diff --git a/__tests__/bundlers/esbuild/build.mjs b/__tests__/bundlers/esbuild/build.mjs index f02aae37a5..ac38a05988 100644 --- a/__tests__/bundlers/esbuild/build.mjs +++ b/__tests__/bundlers/esbuild/build.mjs @@ -1,4 +1,4 @@ -// esbuild's build script (https://esbuild.github.io/getting-started/#build-scripts): the CLI cannot pick an alias by the installed React. +// esbuild's build script (https://esbuild.github.io/getting-started/#build-scripts): the CLI cannot pick the entry by the installed React. import * as esbuild from 'esbuild'; import { createRequire } from 'node:module'; @@ -6,10 +6,8 @@ import { createRequire } from 'node:module'; const { version: reactDOMVersion } = createRequire(import.meta.url)('react-dom/package.json'); await esbuild.build({ - // React 16 has no "react-dom/client". - alias: reactDOMVersion.startsWith('16.') ? { 'react-dom/client': './src/react-dom-client.react16.js' } : {}, bundle: true, - entryPoints: ['src/index.js'], + entryPoints: [reactDOMVersion.startsWith('16.') ? 'src/index.react16.js' : 'src/index.js'], minify: true, outfile: 'dist/main.js' }); diff --git a/__tests__/bundlers/esbuild/src/app.js b/__tests__/bundlers/esbuild/src/app.js new file mode 100644 index 0000000000..ec5f492b9c --- /dev/null +++ b/__tests__/bundlers/esbuild/src/app.js @@ -0,0 +1,54 @@ +// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. +// index.js (React 18 and later) and index.react16.js render it. + +import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; +import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; +import { Composer } from 'botframework-webchat/component.js'; +import { WebChatDecorator } from 'botframework-webchat/decorator.js'; +import { useActivities } from 'botframework-webchat/hook.js'; +import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; +import { createElement } from 'react'; + +// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. +// "botframework-webchat/schema.js" is imported by the Fluent theme. +Object.entries({ + Composer, + createActivityPolymiddleware, + createDirectLine, + FluentThemeProvider, + ReactWebChat, + renderWebChat, + useActivities, + WebChatDecorator +}).forEach(([name, value]) => { + if (!value) { + throw new Error(`Import "${name}" is ${value}.`); + } +}); + +// Emits the values on every subscription, asynchronously like DirectLineJS. +function observableOf(...values) { + return Object.freeze({ + subscribe: ({ next }) => { + const timeout = setTimeout(() => values.forEach(value => next(value))); + + return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); + } + }); +} + +// A chat adapter that connects at once and receives one message from the bot. +const directLine = Object.freeze({ + activity$: observableOf({ + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello from the bot.', + timestamp: new Date().toISOString(), + type: 'message' + }), + connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. + end: () => undefined, + postActivity: () => observableOf('a-00002') +}); + +export default createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })); diff --git a/__tests__/bundlers/esbuild/src/index.js b/__tests__/bundlers/esbuild/src/index.js index 3c69690f70..12dfd2254b 100644 --- a/__tests__/bundlers/esbuild/src/index.js +++ b/__tests__/bundlers/esbuild/src/index.js @@ -1,56 +1,7 @@ -// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. +// The entry for React 18 and later. -import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; -import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; -import { Composer } from 'botframework-webchat/component.js'; -import { WebChatDecorator } from 'botframework-webchat/decorator.js'; -import { useActivities } from 'botframework-webchat/hook.js'; -import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; -import { createElement } from 'react'; -import { createRoot } from 'react-dom/client'; // On React 16, the bundler config points it to react-dom-client.react16.js. +import { createRoot } from 'react-dom/client'; -// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. -// "botframework-webchat/schema.js" is imported by the Fluent theme. -Object.entries({ - Composer, - createActivityPolymiddleware, - createDirectLine, - FluentThemeProvider, - ReactWebChat, - renderWebChat, - useActivities, - WebChatDecorator -}).forEach(([name, value]) => { - if (!value) { - throw new Error(`Import "${name}" is ${value}.`); - } -}); +import app from './app.js'; -// Emits the values on every subscription, asynchronously like DirectLineJS. -function observableOf(...values) { - return Object.freeze({ - subscribe: ({ next }) => { - const timeout = setTimeout(() => values.forEach(value => next(value))); - - return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); - } - }); -} - -// A chat adapter that connects at once and receives one message from the bot. -const directLine = Object.freeze({ - activity$: observableOf({ - from: { id: 'bot', role: 'bot' }, - id: 'a-00001', - text: 'Hello from the bot.', - timestamp: new Date().toISOString(), - type: 'message' - }), - connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. - end: () => undefined, - postActivity: () => observableOf('a-00002') -}); - -createRoot(document.getElementById('webchat')).render( - createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })) -); +createRoot(document.getElementById('webchat')).render(app); diff --git a/__tests__/bundlers/esbuild/src/index.react16.js b/__tests__/bundlers/esbuild/src/index.react16.js new file mode 100644 index 0000000000..a4eaa09691 --- /dev/null +++ b/__tests__/bundlers/esbuild/src/index.react16.js @@ -0,0 +1,7 @@ +// The entry for React 16, as apps were written then: the bundler config picks it when React 16 is installed. + +import ReactDOM from 'react-dom'; + +import app from './app.js'; + +ReactDOM.render(app, document.getElementById('webchat')); diff --git a/__tests__/bundlers/esbuild/src/react-dom-client.react16.js b/__tests__/bundlers/esbuild/src/react-dom-client.react16.js deleted file mode 100644 index 7224e9b0c6..0000000000 --- a/__tests__/bundlers/esbuild/src/react-dom-client.react16.js +++ /dev/null @@ -1,7 +0,0 @@ -// React 16 has no "react-dom/client": the bundler config points it here when React 16 is installed. - -import { render } from 'react-dom'; - -export function createRoot(container) { - return Object.freeze({ render: children => render(children, container) }); -} diff --git a/__tests__/bundlers/vite/index.html b/__tests__/bundlers/vite/index.html index 177d98e1b1..a6c2163c22 100644 --- a/__tests__/bundlers/vite/index.html +++ b/__tests__/bundlers/vite/index.html @@ -6,6 +6,6 @@
- + diff --git a/__tests__/bundlers/vite/src/app.js b/__tests__/bundlers/vite/src/app.js new file mode 100644 index 0000000000..ec5f492b9c --- /dev/null +++ b/__tests__/bundlers/vite/src/app.js @@ -0,0 +1,54 @@ +// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. +// index.js (React 18 and later) and index.react16.js render it. + +import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; +import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; +import { Composer } from 'botframework-webchat/component.js'; +import { WebChatDecorator } from 'botframework-webchat/decorator.js'; +import { useActivities } from 'botframework-webchat/hook.js'; +import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; +import { createElement } from 'react'; + +// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. +// "botframework-webchat/schema.js" is imported by the Fluent theme. +Object.entries({ + Composer, + createActivityPolymiddleware, + createDirectLine, + FluentThemeProvider, + ReactWebChat, + renderWebChat, + useActivities, + WebChatDecorator +}).forEach(([name, value]) => { + if (!value) { + throw new Error(`Import "${name}" is ${value}.`); + } +}); + +// Emits the values on every subscription, asynchronously like DirectLineJS. +function observableOf(...values) { + return Object.freeze({ + subscribe: ({ next }) => { + const timeout = setTimeout(() => values.forEach(value => next(value))); + + return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); + } + }); +} + +// A chat adapter that connects at once and receives one message from the bot. +const directLine = Object.freeze({ + activity$: observableOf({ + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello from the bot.', + timestamp: new Date().toISOString(), + type: 'message' + }), + connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. + end: () => undefined, + postActivity: () => observableOf('a-00002') +}); + +export default createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })); diff --git a/__tests__/bundlers/vite/src/index.js b/__tests__/bundlers/vite/src/index.js index 3c69690f70..12dfd2254b 100644 --- a/__tests__/bundlers/vite/src/index.js +++ b/__tests__/bundlers/vite/src/index.js @@ -1,56 +1,7 @@ -// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. +// The entry for React 18 and later. -import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; -import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; -import { Composer } from 'botframework-webchat/component.js'; -import { WebChatDecorator } from 'botframework-webchat/decorator.js'; -import { useActivities } from 'botframework-webchat/hook.js'; -import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; -import { createElement } from 'react'; -import { createRoot } from 'react-dom/client'; // On React 16, the bundler config points it to react-dom-client.react16.js. +import { createRoot } from 'react-dom/client'; -// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. -// "botframework-webchat/schema.js" is imported by the Fluent theme. -Object.entries({ - Composer, - createActivityPolymiddleware, - createDirectLine, - FluentThemeProvider, - ReactWebChat, - renderWebChat, - useActivities, - WebChatDecorator -}).forEach(([name, value]) => { - if (!value) { - throw new Error(`Import "${name}" is ${value}.`); - } -}); +import app from './app.js'; -// Emits the values on every subscription, asynchronously like DirectLineJS. -function observableOf(...values) { - return Object.freeze({ - subscribe: ({ next }) => { - const timeout = setTimeout(() => values.forEach(value => next(value))); - - return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); - } - }); -} - -// A chat adapter that connects at once and receives one message from the bot. -const directLine = Object.freeze({ - activity$: observableOf({ - from: { id: 'bot', role: 'bot' }, - id: 'a-00001', - text: 'Hello from the bot.', - timestamp: new Date().toISOString(), - type: 'message' - }), - connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. - end: () => undefined, - postActivity: () => observableOf('a-00002') -}); - -createRoot(document.getElementById('webchat')).render( - createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })) -); +createRoot(document.getElementById('webchat')).render(app); diff --git a/__tests__/bundlers/vite/src/index.react16.js b/__tests__/bundlers/vite/src/index.react16.js new file mode 100644 index 0000000000..a4eaa09691 --- /dev/null +++ b/__tests__/bundlers/vite/src/index.react16.js @@ -0,0 +1,7 @@ +// The entry for React 16, as apps were written then: the bundler config picks it when React 16 is installed. + +import ReactDOM from 'react-dom'; + +import app from './app.js'; + +ReactDOM.render(app, document.getElementById('webchat')); diff --git a/__tests__/bundlers/vite/src/react-dom-client.react16.js b/__tests__/bundlers/vite/src/react-dom-client.react16.js deleted file mode 100644 index 7224e9b0c6..0000000000 --- a/__tests__/bundlers/vite/src/react-dom-client.react16.js +++ /dev/null @@ -1,7 +0,0 @@ -// React 16 has no "react-dom/client": the bundler config points it here when React 16 is installed. - -import { render } from 'react-dom'; - -export function createRoot(container) { - return Object.freeze({ render: children => render(children, container) }); -} diff --git a/__tests__/bundlers/vite/vite.config.js b/__tests__/bundlers/vite/vite.config.js index 70e8a65cee..07f6e78a23 100644 --- a/__tests__/bundlers/vite/vite.config.js +++ b/__tests__/bundlers/vite/vite.config.js @@ -1,14 +1,13 @@ import { createRequire } from 'node:module'; -import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; const { version: reactDOMVersion } = createRequire(import.meta.url)('react-dom/package.json'); export default defineConfig({ - resolve: { - // React 16 has no "react-dom/client". - alias: reactDOMVersion.startsWith('16.') - ? { 'react-dom/client': fileURLToPath(new URL('./src/react-dom-client.react16.js', import.meta.url)) } - : {} + define: { + // The entry in index.html ("%VITE_ENTRY%", Vite's HTML constant replacement). + 'import.meta.env.VITE_ENTRY': JSON.stringify( + reactDOMVersion.startsWith('16.') ? '/src/index.react16.js' : '/src/index.js' + ) } }); diff --git a/__tests__/bundlers/webpack4/src/app.js b/__tests__/bundlers/webpack4/src/app.js new file mode 100644 index 0000000000..ec5f492b9c --- /dev/null +++ b/__tests__/bundlers/webpack4/src/app.js @@ -0,0 +1,54 @@ +// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. +// index.js (React 18 and later) and index.react16.js render it. + +import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; +import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; +import { Composer } from 'botframework-webchat/component.js'; +import { WebChatDecorator } from 'botframework-webchat/decorator.js'; +import { useActivities } from 'botframework-webchat/hook.js'; +import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; +import { createElement } from 'react'; + +// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. +// "botframework-webchat/schema.js" is imported by the Fluent theme. +Object.entries({ + Composer, + createActivityPolymiddleware, + createDirectLine, + FluentThemeProvider, + ReactWebChat, + renderWebChat, + useActivities, + WebChatDecorator +}).forEach(([name, value]) => { + if (!value) { + throw new Error(`Import "${name}" is ${value}.`); + } +}); + +// Emits the values on every subscription, asynchronously like DirectLineJS. +function observableOf(...values) { + return Object.freeze({ + subscribe: ({ next }) => { + const timeout = setTimeout(() => values.forEach(value => next(value))); + + return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); + } + }); +} + +// A chat adapter that connects at once and receives one message from the bot. +const directLine = Object.freeze({ + activity$: observableOf({ + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello from the bot.', + timestamp: new Date().toISOString(), + type: 'message' + }), + connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. + end: () => undefined, + postActivity: () => observableOf('a-00002') +}); + +export default createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })); diff --git a/__tests__/bundlers/webpack4/src/index.js b/__tests__/bundlers/webpack4/src/index.js index 3c69690f70..12dfd2254b 100644 --- a/__tests__/bundlers/webpack4/src/index.js +++ b/__tests__/bundlers/webpack4/src/index.js @@ -1,56 +1,7 @@ -// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. +// The entry for React 18 and later. -import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; -import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; -import { Composer } from 'botframework-webchat/component.js'; -import { WebChatDecorator } from 'botframework-webchat/decorator.js'; -import { useActivities } from 'botframework-webchat/hook.js'; -import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; -import { createElement } from 'react'; -import { createRoot } from 'react-dom/client'; // On React 16, the bundler config points it to react-dom-client.react16.js. +import { createRoot } from 'react-dom/client'; -// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. -// "botframework-webchat/schema.js" is imported by the Fluent theme. -Object.entries({ - Composer, - createActivityPolymiddleware, - createDirectLine, - FluentThemeProvider, - ReactWebChat, - renderWebChat, - useActivities, - WebChatDecorator -}).forEach(([name, value]) => { - if (!value) { - throw new Error(`Import "${name}" is ${value}.`); - } -}); +import app from './app.js'; -// Emits the values on every subscription, asynchronously like DirectLineJS. -function observableOf(...values) { - return Object.freeze({ - subscribe: ({ next }) => { - const timeout = setTimeout(() => values.forEach(value => next(value))); - - return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); - } - }); -} - -// A chat adapter that connects at once and receives one message from the bot. -const directLine = Object.freeze({ - activity$: observableOf({ - from: { id: 'bot', role: 'bot' }, - id: 'a-00001', - text: 'Hello from the bot.', - timestamp: new Date().toISOString(), - type: 'message' - }), - connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. - end: () => undefined, - postActivity: () => observableOf('a-00002') -}); - -createRoot(document.getElementById('webchat')).render( - createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })) -); +createRoot(document.getElementById('webchat')).render(app); diff --git a/__tests__/bundlers/webpack4/src/index.react16.js b/__tests__/bundlers/webpack4/src/index.react16.js new file mode 100644 index 0000000000..a4eaa09691 --- /dev/null +++ b/__tests__/bundlers/webpack4/src/index.react16.js @@ -0,0 +1,7 @@ +// The entry for React 16, as apps were written then: the bundler config picks it when React 16 is installed. + +import ReactDOM from 'react-dom'; + +import app from './app.js'; + +ReactDOM.render(app, document.getElementById('webchat')); diff --git a/__tests__/bundlers/webpack4/src/react-dom-client.react16.js b/__tests__/bundlers/webpack4/src/react-dom-client.react16.js deleted file mode 100644 index 7224e9b0c6..0000000000 --- a/__tests__/bundlers/webpack4/src/react-dom-client.react16.js +++ /dev/null @@ -1,7 +0,0 @@ -// React 16 has no "react-dom/client": the bundler config points it here when React 16 is installed. - -import { render } from 'react-dom'; - -export function createRoot(container) { - return Object.freeze({ render: children => render(children, container) }); -} diff --git a/__tests__/bundlers/webpack4/webpack.config.js b/__tests__/bundlers/webpack4/webpack.config.js index 1a3b3586a8..9e89bc9651 100644 --- a/__tests__/bundlers/webpack4/webpack.config.js +++ b/__tests__/bundlers/webpack4/webpack.config.js @@ -25,18 +25,12 @@ const transpilePackages = [ ]; module.exports = { - entry: './src/index.js', + entry: reactDOMVersion.startsWith('16.') ? './src/index.react16.js' : './src/index.js', mode: 'production', output: { filename: 'main.js', path: path.resolve(__dirname, 'dist') }, - resolve: { - // React 16 has no "react-dom/client". - alias: reactDOMVersion.startsWith('16.') - ? { 'react-dom/client$': path.resolve(__dirname, 'src/react-dom-client.react16.js') } - : {} - }, module: { rules: [ // Lets .mjs import named exports from CommonJS, like create-react-app@4. diff --git a/__tests__/bundlers/webpack5/src/app.js b/__tests__/bundlers/webpack5/src/app.js new file mode 100644 index 0000000000..ec5f492b9c --- /dev/null +++ b/__tests__/bundlers/webpack5/src/app.js @@ -0,0 +1,54 @@ +// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. +// index.js (React 18 and later) and index.react16.js render it. + +import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; +import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; +import { Composer } from 'botframework-webchat/component.js'; +import { WebChatDecorator } from 'botframework-webchat/decorator.js'; +import { useActivities } from 'botframework-webchat/hook.js'; +import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; +import { createElement } from 'react'; + +// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. +// "botframework-webchat/schema.js" is imported by the Fluent theme. +Object.entries({ + Composer, + createActivityPolymiddleware, + createDirectLine, + FluentThemeProvider, + ReactWebChat, + renderWebChat, + useActivities, + WebChatDecorator +}).forEach(([name, value]) => { + if (!value) { + throw new Error(`Import "${name}" is ${value}.`); + } +}); + +// Emits the values on every subscription, asynchronously like DirectLineJS. +function observableOf(...values) { + return Object.freeze({ + subscribe: ({ next }) => { + const timeout = setTimeout(() => values.forEach(value => next(value))); + + return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); + } + }); +} + +// A chat adapter that connects at once and receives one message from the bot. +const directLine = Object.freeze({ + activity$: observableOf({ + from: { id: 'bot', role: 'bot' }, + id: 'a-00001', + text: 'Hello from the bot.', + timestamp: new Date().toISOString(), + type: 'message' + }), + connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. + end: () => undefined, + postActivity: () => observableOf('a-00002') +}); + +export default createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })); diff --git a/__tests__/bundlers/webpack5/src/index.js b/__tests__/bundlers/webpack5/src/index.js index 3c69690f70..12dfd2254b 100644 --- a/__tests__/bundlers/webpack5/src/index.js +++ b/__tests__/bundlers/webpack5/src/index.js @@ -1,56 +1,7 @@ -// Same in every app folder (CI diffs them): a Web Chat app, rendering a message from a fake chat adapter. +// The entry for React 18 and later. -import ReactWebChat, { createDirectLine, renderWebChat } from 'botframework-webchat'; -import { FluentThemeProvider } from 'botframework-webchat-fluent-theme'; -import { Composer } from 'botframework-webchat/component.js'; -import { WebChatDecorator } from 'botframework-webchat/decorator.js'; -import { useActivities } from 'botframework-webchat/hook.js'; -import { createActivityPolymiddleware } from 'botframework-webchat/middleware.js'; -import { createElement } from 'react'; -import { createRoot } from 'react-dom/client'; // On React 16, the bundler config points it to react-dom-client.react16.js. +import { createRoot } from 'react-dom/client'; -// Every import must be defined: a bundler can resolve a subpath to an empty module without an error. -// "botframework-webchat/schema.js" is imported by the Fluent theme. -Object.entries({ - Composer, - createActivityPolymiddleware, - createDirectLine, - FluentThemeProvider, - ReactWebChat, - renderWebChat, - useActivities, - WebChatDecorator -}).forEach(([name, value]) => { - if (!value) { - throw new Error(`Import "${name}" is ${value}.`); - } -}); +import app from './app.js'; -// Emits the values on every subscription, asynchronously like DirectLineJS. -function observableOf(...values) { - return Object.freeze({ - subscribe: ({ next }) => { - const timeout = setTimeout(() => values.forEach(value => next(value))); - - return Object.freeze({ unsubscribe: () => clearTimeout(timeout) }); - } - }); -} - -// A chat adapter that connects at once and receives one message from the bot. -const directLine = Object.freeze({ - activity$: observableOf({ - from: { id: 'bot', role: 'bot' }, - id: 'a-00001', - text: 'Hello from the bot.', - timestamp: new Date().toISOString(), - type: 'message' - }), - connectionStatus$: observableOf(0, 1, 2), // Uninitialized, connecting, online. - end: () => undefined, - postActivity: () => observableOf('a-00002') -}); - -createRoot(document.getElementById('webchat')).render( - createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine })) -); +createRoot(document.getElementById('webchat')).render(app); diff --git a/__tests__/bundlers/webpack5/src/index.react16.js b/__tests__/bundlers/webpack5/src/index.react16.js new file mode 100644 index 0000000000..a4eaa09691 --- /dev/null +++ b/__tests__/bundlers/webpack5/src/index.react16.js @@ -0,0 +1,7 @@ +// The entry for React 16, as apps were written then: the bundler config picks it when React 16 is installed. + +import ReactDOM from 'react-dom'; + +import app from './app.js'; + +ReactDOM.render(app, document.getElementById('webchat')); diff --git a/__tests__/bundlers/webpack5/src/react-dom-client.react16.js b/__tests__/bundlers/webpack5/src/react-dom-client.react16.js deleted file mode 100644 index 7224e9b0c6..0000000000 --- a/__tests__/bundlers/webpack5/src/react-dom-client.react16.js +++ /dev/null @@ -1,7 +0,0 @@ -// React 16 has no "react-dom/client": the bundler config points it here when React 16 is installed. - -import { render } from 'react-dom'; - -export function createRoot(container) { - return Object.freeze({ render: children => render(children, container) }); -} diff --git a/__tests__/bundlers/webpack5/webpack.config.js b/__tests__/bundlers/webpack5/webpack.config.js index b746688f83..4358175e01 100644 --- a/__tests__/bundlers/webpack5/webpack.config.js +++ b/__tests__/bundlers/webpack5/webpack.config.js @@ -3,16 +3,10 @@ const path = require('path'); const { version: reactDOMVersion } = require('react-dom/package.json'); module.exports = { - entry: './src/index.js', + entry: reactDOMVersion.startsWith('16.') ? './src/index.react16.js' : './src/index.js', mode: 'production', output: { filename: 'main.js', path: path.resolve(__dirname, 'dist') - }, - resolve: { - // React 16 has no "react-dom/client". - alias: reactDOMVersion.startsWith('16.') - ? { 'react-dom/client$': path.resolve(__dirname, 'src/react-dom-client.react16.js') } - : {} } }; From 14e2b3296b61f37429083bed4bf54338685bca47 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 29 Oct 2026 00:47:50 -0700 Subject: [PATCH 21/25] Bundler test: add bump scripts, and install React and Web Chat only from the React scripts --- __tests__/bundlers/README.md | 2 +- __tests__/bundlers/esbuild/package.json | 7 +----- __tests__/bundlers/package.json | 3 +++ __tests__/bundlers/vite/package.json | 7 +----- __tests__/bundlers/webpack4/package.json | 27 +++++++++++++++++++----- __tests__/bundlers/webpack5/package.json | 7 +----- 6 files changed, 29 insertions(+), 24 deletions(-) diff --git a/__tests__/bundlers/README.md b/__tests__/bundlers/README.md index 92576e1a3e..2c8ab2038a 100644 --- a/__tests__/bundlers/README.md +++ b/__tests__/bundlers/README.md @@ -24,4 +24,4 @@ node ../run.mjs vite `run.mjs` starts the Chrome that [chrome-launcher](https://www.npmjs.com/package/chrome-launcher) finds, or the one in `CHROME_PATH`. On WSL, chrome-launcher looks for Chrome on Windows and fails if it is not installed there: set `CHROME_PATH` to a Linux Chrome, then chrome-launcher leaves a `C:\Users\…\lighthouse.*` folder in the current folder. -The React scripts install the tarballs in the same `npm install`: a separate `npm install` would put back the React and Web Chat versions from `package.json`. +The React scripts install the tarballs in the same `npm install`: a separate `npm install` would remove React and Web Chat, which are not in `package.json`. diff --git a/__tests__/bundlers/esbuild/package.json b/__tests__/bundlers/esbuild/package.json index 5ac55d5f1a..3c7cf4c182 100644 --- a/__tests__/bundlers/esbuild/package.json +++ b/__tests__/bundlers/esbuild/package.json @@ -6,16 +6,11 @@ "license": "MIT", "scripts": { "build": "node build.mjs", + "bump": "vg bump prod && vg bump dev && (npm audit fix || exit 0)", "react16": "npm install --no-save react@16.8.6 react-dom@16.8.6", "react18": "npm install --no-save react@18.3.1 react-dom@18.3.1", "react19": "npm install --no-save react@19.3.1 react-dom@19.3.1" }, - "dependencies": { - "botframework-webchat": "4.19.2", - "botframework-webchat-fluent-theme": "4.19.2", - "react": "18.3.1", - "react-dom": "18.3.1" - }, "devDependencies": { "esbuild": "0.28.2" } diff --git a/__tests__/bundlers/package.json b/__tests__/bundlers/package.json index fb69606df5..8626e666c1 100644 --- a/__tests__/bundlers/package.json +++ b/__tests__/bundlers/package.json @@ -4,6 +4,9 @@ "description": "Checks that the Web Chat apps in the folders here render in Chrome, see README.md", "private": true, "license": "MIT", + "scripts": { + "bump": "vg bump prod && vg bump dev && (npm audit fix || exit 0)" + }, "dependencies": { "chrome-launcher": "1.2.2" } diff --git a/__tests__/bundlers/vite/package.json b/__tests__/bundlers/vite/package.json index da1f989890..fc7b08f531 100644 --- a/__tests__/bundlers/vite/package.json +++ b/__tests__/bundlers/vite/package.json @@ -7,18 +7,13 @@ "type": "module", "scripts": { "build": "vite build", + "bump": "vg bump prod && vg bump dev && (npm audit fix || exit 0)", "dev": "vite", "preview": "vite preview", "react16": "npm install --no-save react@16.8.6 react-dom@16.8.6", "react18": "npm install --no-save react@18.3.1 react-dom@18.3.1", "react19": "npm install --no-save react@19.3.1 react-dom@19.3.1" }, - "dependencies": { - "botframework-webchat": "4.19.2", - "botframework-webchat-fluent-theme": "4.19.2", - "react": "18.3.1", - "react-dom": "18.3.1" - }, "devDependencies": { "vite": "8.3.5" } diff --git a/__tests__/bundlers/webpack4/package.json b/__tests__/bundlers/webpack4/package.json index 5f98229aef..543fe84d7f 100644 --- a/__tests__/bundlers/webpack4/package.json +++ b/__tests__/bundlers/webpack4/package.json @@ -6,15 +6,32 @@ "license": "MIT", "scripts": { "build": "webpack", + "bump": "vg bump prod && vg bump dev && (npm audit fix || exit 0)", "react16": "npm install --no-save react@16.8.6 react-dom@16.8.6", "react18": "npm install --no-save react@18.3.1 react-dom@18.3.1", "react19": "npm install --no-save react@19.3.1 react-dom@19.3.1" }, - "dependencies": { - "botframework-webchat": "4.19.2", - "botframework-webchat-fluent-theme": "4.19.2", - "react": "18.3.1", - "react-dom": "18.3.1" + "pinDependencies": { + "@babel/core": [ + "7", + "babel-loader@8 needs Babel 7" + ], + "@babel/preset-env": [ + "7", + "babel-loader@8 needs Babel 7" + ], + "babel-loader": [ + "8", + "babel-loader@9 needs webpack 5" + ], + "webpack": [ + "4", + "this app tests webpack 4" + ], + "webpack-cli": [ + "3", + "webpack-cli@3 is the one from webpack 4's docs" + ] }, "devDependencies": { "@babel/core": "7.29.7", diff --git a/__tests__/bundlers/webpack5/package.json b/__tests__/bundlers/webpack5/package.json index a54ac8419d..625788e453 100644 --- a/__tests__/bundlers/webpack5/package.json +++ b/__tests__/bundlers/webpack5/package.json @@ -6,16 +6,11 @@ "license": "MIT", "scripts": { "build": "webpack", + "bump": "vg bump prod && vg bump dev && (npm audit fix || exit 0)", "react16": "npm install --no-save react@16.8.6 react-dom@16.8.6", "react18": "npm install --no-save react@18.3.1 react-dom@18.3.1", "react19": "npm install --no-save react@19.3.1 react-dom@19.3.1" }, - "dependencies": { - "botframework-webchat": "4.19.2", - "botframework-webchat-fluent-theme": "4.19.2", - "react": "18.3.1", - "react-dom": "18.3.1" - }, "devDependencies": { "webpack": "5.107.2", "webpack-cli": "7.2.3" From a986de83e23d8f6fe1710cf8d568fdb1f7992e35 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 29 Oct 2026 00:48:20 -0700 Subject: [PATCH 22/25] Bundler test: run webpack 5 with React 19 again, react-film no longer imports render --- .github/workflows/pull-request-validation.yml | 5 ----- docs/HOSTING.md | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 62852c10e6..c2b0ac54d7 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -291,11 +291,6 @@ jobs: matrix: bundler: [esbuild, vite, webpack4, webpack5] # Folders under __tests__/bundlers/. react: [16, 18, 19] - # react-film 4.0.0 imports `render` from react-dom, which React 19 removed, so webpack 5 fails on the missing export. - # Re-enable when react-film is updated. - exclude: - - bundler: webpack5 - react: 19 timeout-minutes: 10 env: NODE_ENV: production # Apps ship production builds; with "test", Vite bundles React's development build. diff --git a/docs/HOSTING.md b/docs/HOSTING.md index 2194e38631..b100dabca3 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -189,7 +189,7 @@ Then, import Web Chat into your code: import { createDirectLine, ReactWebChat, renderWebChat } from 'botframework-webchat'; ``` -webpack 5, esbuild and Vite need no changes to bundle Web Chat; our CI renders Web Chat bundled by each of them in Chrome. The exception is webpack 5 with React 19, which fails on react-film importing `render` from `react-dom` until react-film is removed ([#5552](https://github.com/microsoft/BotFramework-WebChat/issues/5552)); until then, set `module.parser.javascript.exportsPresence: 'warn'` in `webpack.config.js`. +webpack 5, esbuild and Vite need no changes to bundle Web Chat; our CI renders Web Chat bundled by each of them in Chrome. #### Bundling with webpack 4 From 58dc7b898536fd54111ab5cfd723fb14280d0a17 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 29 Oct 2026 01:11:42 -0700 Subject: [PATCH 23/25] Run the bundler apps' bump scripts from the root bump --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a8f2f01eb..a0ec5385a9 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "build:html2-samples": "cd __tests__/html2/samples/ && esbuild --bundle --format=esm --outbase=. --outdir=./dist/ --minify **/*.tsx --splitting", "build:production": "npm run build --if-present --workspaces", "build-browser": "npm run build:run --workspace ./packages/test/harness", - "bump": "vg bump prod && vg bump dev && (npm audit fix || exit 0) && npm run bump:packages && npm run bump:samples && npm dedupe", + "bump": "vg bump prod && vg bump dev && (npm audit fix || exit 0) && npm run bump:packages && npm run bump:samples && npm run bump:bundlers && npm dedupe", + "bump:bundlers": "cd __tests__/bundlers && npm run bump && for app in esbuild vite webpack4 webpack5; do npm run bump --prefix $app || exit 1; done", "bump:dev": "PACKAGES_TO_BUMP=$(cat package.json | jq -r '(.pinDependencies // {}) as $P | (.localPeerDependencies // []) as $L | (.devDependencies // {}) | to_entries | map(select(.key as $K | $L | contains([$K]) | not)) | map(.key + \"@\" + ($P[.key] // [\"latest\"])[0]) | join(\" \")') && [ ! -z \"$PACKAGES_TO_BUMP\" ] && npm install $PACKAGES_TO_BUMP || true", "bump:prod": "PACKAGES_TO_BUMP=$(cat package.json | jq -r '(.pinDependencies // {}) as $P | (.localPeerDependencies // []) as $L | (.dependencies // {}) | to_entries | map(select(.key as $K | $L | contains([$K]) | not)) | map(.key + \"@\" + ($P[.key] // [\"latest\"])[0]) | join(\" \")') && [ ! -z \"$PACKAGES_TO_BUMP\" ] && npm install --save-exact $PACKAGES_TO_BUMP || true", "bump:packages": "npm run bump --workspaces", From 97cb4d4e9ef7d2aa30cb092107ad0a464face79b Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 29 Oct 2026 10:38:18 -0700 Subject: [PATCH 24/25] Bundler test: render in Chrome 141 in Docker over WebDriver instead of chrome-launcher One image per app on selenium/standalone-chrome:141.0 (pinned by digest) serves the app's dist with report.js injected; run.mjs talks bare-HTTP WebDriver to it and removes the container on every path. --- .github/workflows/pull-request-validation.yml | 10 +- __tests__/bundlers/Dockerfile | 19 +++ __tests__/bundlers/README.md | 16 +- __tests__/bundlers/package.json | 13 -- __tests__/bundlers/report.js | 5 +- __tests__/bundlers/run.mjs | 144 ++++++++++++------ package.json | 2 +- 7 files changed, 136 insertions(+), 73 deletions(-) create mode 100644 __tests__/bundlers/Dockerfile delete mode 100644 __tests__/bundlers/package.json diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index c2b0ac54d7..87ad9a7cd7 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -309,9 +309,7 @@ jobs: with: node-version: ${{ env.node-version }} cache: npm - cache-dependency-path: | - __tests__/bundlers/package.json - __tests__/bundlers/${{ matrix.bundler }}/package.json + cache-dependency-path: __tests__/bundlers/${{ matrix.bundler }}/package.json - name: Download tarballs uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -332,11 +330,7 @@ jobs: - run: npm run build - - name: Install the runner - run: npm install - working-directory: __tests__/bundlers - - # Renders in the Google Chrome preinstalled on the runner. + # Renders in the Chrome of the Docker image built from __tests__/bundlers/Dockerfile. - name: Render in Chrome run: node ../run.mjs ${{ matrix.bundler }} diff --git a/__tests__/bundlers/Dockerfile b/__tests__/bundlers/Dockerfile new file mode 100644 index 0000000000..f438762a57 --- /dev/null +++ b/__tests__/bundlers/Dockerfile @@ -0,0 +1,19 @@ +# Chrome 141 with the app in /dist/ served on port 8000, see README.md. Same Chrome as the HTML tests' grid +# (selenium/node-chrome:141.0), in the single-container "standalone" variant: WebDriver on port 4444, noVNC on port 7900. +# +# Usage: docker build --build-arg APP= . + +FROM selenium/standalone-chrome:141.0@sha256:2315640266fa3a4a28c3023fa8edd190ca7f6b827810c2f9bd3b75cb88198cff + +ARG APP + +COPY --chown=seluser ${APP}/dist/ /home/seluser/app/ +COPY report.js /home/seluser/app/__runner/ + +# Injects report.js before the app's scripts. +RUN sed -i 's||&|i' /home/seluser/app/index.html + +COPY < { const errors = []; @@ -31,7 +32,7 @@ if (rendered || errors.length) { clearInterval(interval); - fetch('/__runner/report', { body: JSON.stringify({ errors, ok: rendered && !errors.length }), method: 'POST' }); + window.bundlerTestResult = Object.freeze({ errors: Object.freeze([...errors]), ok: rendered && !errors.length }); } }, 100); })(); diff --git a/__tests__/bundlers/run.mjs b/__tests__/bundlers/run.mjs index e7724eb939..755efcdec3 100644 --- a/__tests__/bundlers/run.mjs +++ b/__tests__/bundlers/run.mjs @@ -1,64 +1,118 @@ -// Serves /dist/ with report.js injected before the app's scripts, and opens it in headless Chrome: passes when -// report.js reports that Web Chat shows the bot message without errors. +// Builds the Dockerfile here for /dist/ (Chrome with the app served on port 8000) and opens the app over +// WebDriver: passes when report.js reports that Web Chat shows the bot message without errors. // // Usage: node run.mjs -import { launch } from 'chrome-launcher'; -import { access, readFile } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import { extname, join, normalize } from 'node:path'; +import { execFile } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; -const CONTENT_TYPES = Object.freeze({ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8' }); const TIMEOUT = 60_000; -const root = fileURLToPath(new URL(`${process.argv[2]}/dist/`, import.meta.url)); -const { promise: reported, resolve: report } = Promise.withResolvers(); +const app = process.argv[2]; +const docker = async (...args) => (await promisify(execFile)('docker', args)).stdout.trim(); -await access(join(root, 'index.html')); // Fails with the path when the app is not built. +// Resolves with the first truthy value of fn(), which is called every 100 ms. +async function poll(fn, message) { + const deadline = Date.now() + TIMEOUT; -setTimeout(() => report({ errors: [`No report from the page in ${TIMEOUT / 1000} seconds.`] }), TIMEOUT).unref(); + for (;;) { + const value = await fn(); -const server = createServer(async (request, response) => { - const { pathname } = new URL(request.url, 'http://localhost'); - - if (pathname === '/__runner/report') { - try { - report(JSON.parse(Buffer.concat(await Array.fromAsync(request)))); - response.end(); - } catch (error) { - report({ errors: [`Invalid report from the page: ${error.message}`] }); - response.writeHead(400).end(); + if (value) { + return value; + } else if (Date.now() > deadline) { + throw new Error(`${message} in ${TIMEOUT / 1000} seconds.`); } - return; + await new Promise(resolve => setTimeout(resolve, 100)); } +} - try { - const path = - pathname === '/__runner/report.js' - ? fileURLToPath(new URL('report.js', import.meta.url)) - : join(root, normalize(pathname).replace(/\/$/u, '/index.html')); - const body = await readFile(path); - - response - .writeHead(200, { 'content-type': CONTENT_TYPES[extname(path)] || 'application/octet-stream' }) - .end( - path.endsWith('.html') ? `${body}`.replace(//iu, '$&') : body - ); - } catch { - response.writeHead(404).end(); +console.time('Built the image'); + +await docker( + 'build', + '--build-arg', + `APP=${app}`, + '--tag', + `webchat-bundler-${app}`, + fileURLToPath(new URL('.', import.meta.url)) +); + +console.timeEnd('Built the image'); + +const container = `webchat-bundler-${app}-${process.pid}`; +let removingContainer; +const removeContainer = () => (removingContainer ||= docker('rm', '--force', container)); + +// Registered before `docker run`, which can be interrupted after it created the container. +['SIGINT', 'SIGTERM'].forEach(signal => process.once(signal, () => removeContainer().finally(() => process.exit(1)))); + +await docker( + 'run', + '--detach', + '--name', + container, + '--publish', + '127.0.0.1::4444', + '--shm-size=2g', + `webchat-bundler-${app}` +); + +try { + const webDriverURL = `http://${await docker('port', container, '4444')}/`; + + async function webDriver(method, path, body) { + const response = await fetch(new URL(path, webDriverURL), { + body: body && JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + method + }); + const { value } = await response.json(); + + if (!response.ok) { + throw new Error(`WebDriver ${method} /${path}: ${value.message}`); + } + + return value; } -}); -await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + console.time('WebDriver ready'); + + await poll( + () => + webDriver('GET', 'status').then( + ({ ready }) => ready, + () => false + ), + 'WebDriver is not ready' + ); -const chrome = await launch({ chromeFlags: ['--headless'], startingUrl: `http://127.0.0.1:${server.address().port}/` }); -const { errors, ok } = await reported; + console.timeEnd('WebDriver ready'); + console.time('Rendered'); -chrome.kill(); -server.close(); + const { sessionId } = await webDriver('POST', 'session', { + capabilities: { alwaysMatch: { browserName: 'chrome' } } + }); -errors.forEach(error => console.error(error)); -console.log(ok ? `Web Chat bundled in ${root} rendered the bot message.` : `Failed to render ${root}.`); -process.exitCode = ok ? 0 : 1; + try { + // "localhost" is the container, where the Dockerfile serves the app. + await webDriver('POST', `session/${sessionId}/url`, { url: 'http://localhost:8000/' }); + + const { errors, ok } = await poll( + () => + webDriver('POST', `session/${sessionId}/execute/sync`, { args: [], script: 'return window.bundlerTestResult' }), + 'No report from the page' + ); + + console.timeEnd('Rendered'); + errors.forEach(error => console.error(error)); + console.log(ok ? `Web Chat bundled by ${app} rendered the bot message.` : `Failed to render the ${app} app.`); + process.exitCode = ok ? 0 : 1; + } finally { + await webDriver('DELETE', `session/${sessionId}`); + } +} finally { + await removeContainer(); +} diff --git a/package.json b/package.json index a0ec5385a9..5f222d4243 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "build:production": "npm run build --if-present --workspaces", "build-browser": "npm run build:run --workspace ./packages/test/harness", "bump": "vg bump prod && vg bump dev && (npm audit fix || exit 0) && npm run bump:packages && npm run bump:samples && npm run bump:bundlers && npm dedupe", - "bump:bundlers": "cd __tests__/bundlers && npm run bump && for app in esbuild vite webpack4 webpack5; do npm run bump --prefix $app || exit 1; done", + "bump:bundlers": "cd __tests__/bundlers && for app in esbuild vite webpack4 webpack5; do npm run bump --prefix $app || exit 1; done", "bump:dev": "PACKAGES_TO_BUMP=$(cat package.json | jq -r '(.pinDependencies // {}) as $P | (.localPeerDependencies // []) as $L | (.devDependencies // {}) | to_entries | map(select(.key as $K | $L | contains([$K]) | not)) | map(.key + \"@\" + ($P[.key] // [\"latest\"])[0]) | join(\" \")') && [ ! -z \"$PACKAGES_TO_BUMP\" ] && npm install $PACKAGES_TO_BUMP || true", "bump:prod": "PACKAGES_TO_BUMP=$(cat package.json | jq -r '(.pinDependencies // {}) as $P | (.localPeerDependencies // []) as $L | (.dependencies // {}) | to_entries | map(select(.key as $K | $L | contains([$K]) | not)) | map(.key + \"@\" + ($P[.key] // [\"latest\"])[0]) | join(\" \")') && [ ! -z \"$PACKAGES_TO_BUMP\" ] && npm install --save-exact $PACKAGES_TO_BUMP || true", "bump:packages": "npm run bump --workspaces", From 0dc701cf3bd3e99828d74cfb3e2d9010d983a347 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 29 Oct 2026 11:19:34 -0700 Subject: [PATCH 25/25] Bundler test: time out WebDriver calls, keep the first error, bind the debug port to localhost --- __tests__/bundlers/README.md | 2 +- __tests__/bundlers/run.mjs | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/__tests__/bundlers/README.md b/__tests__/bundlers/README.md index b42ffbc653..a619eb205e 100644 --- a/__tests__/bundlers/README.md +++ b/__tests__/bundlers/README.md @@ -27,7 +27,7 @@ To look at the app as served to that Chrome, run the image yourself and browse ` ```sh docker build --build-arg APP=vite --tag webchat-bundler-vite .. -docker run --rm --publish 8000:8000 webchat-bundler-vite +docker run --rm --publish 127.0.0.1:8000:8000 webchat-bundler-vite ``` The image has a copy of `dist/`: build it again after `npm run build`. diff --git a/__tests__/bundlers/run.mjs b/__tests__/bundlers/run.mjs index 755efcdec3..3a6463c9a6 100644 --- a/__tests__/bundlers/run.mjs +++ b/__tests__/bundlers/run.mjs @@ -12,12 +12,12 @@ const TIMEOUT = 60_000; const app = process.argv[2]; const docker = async (...args) => (await promisify(execFile)('docker', args)).stdout.trim(); -// Resolves with the first truthy value of fn(), which is called every 100 ms. -async function poll(fn, message) { +// Resolves with the first truthy value of getValue(), which is called every 100 ms. +async function poll(getValue, message) { const deadline = Date.now() + TIMEOUT; for (;;) { - const value = await fn(); + const value = await getValue(); if (value) { return value; @@ -67,12 +67,13 @@ try { const response = await fetch(new URL(path, webDriverURL), { body: body && JSON.stringify(body), headers: { 'content-type': 'application/json' }, - method + method, + signal: AbortSignal.timeout(TIMEOUT) }); const { value } = await response.json(); if (!response.ok) { - throw new Error(`WebDriver ${method} /${path}: ${value.message}`); + throw new Error(`WebDriver ${method} /${path}: ${value?.message ?? response.statusText}`); } return value; @@ -111,7 +112,8 @@ try { console.log(ok ? `Web Chat bundled by ${app} rendered the bot message.` : `Failed to render the ${app} app.`); process.exitCode = ok ? 0 : 1; } finally { - await webDriver('DELETE', `session/${sessionId}`); + // Ignore errors here, so they don't replace the error of a session that already ended. + await webDriver('DELETE', `session/${sessionId}`).catch(() => {}); } } finally { await removeContainer();