diff --git a/.changelog/5977-fix-ci-webpack-consumers.md b/.changelog/5977-fix-ci-webpack-consumers.md new file mode 100644 index 0000000000..cbc80158cb --- /dev/null +++ b/.changelog/5977-fix-ci-webpack-consumers.md @@ -0,0 +1,12 @@ +### Added + +- 👷🏻 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 + +- `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 + +- 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/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 03e63f0b85..87ad9a7cd7 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -281,6 +281,59 @@ jobs: run: ../../node_modules/.bin/tsd -t ./dist/botframework-webchat-core.d.mts working-directory: ./packages/core + bundler-test: + name: Bundler test (${{ matrix.bundler }}, React ${{ matrix.react }}) + needs: + - build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + bundler: [esbuild, vite, webpack4, webpack5] # Folders under __tests__/bundlers/. + react: [16, 18, 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 + with: + 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__/bundlers/${{ matrix.bundler }}/package.json + + - name: Download tarballs + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: tarballs + + - 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 + + - 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 + + # Renders in the Chrome of the Docker image built from __tests__/bundlers/Dockerfile. + - name: Render in Chrome + run: node ../run.mjs ${{ matrix.bundler }} + html-test: name: HTML test (${{ format('{0}/{1}', matrix.shard-index, matrix.shard-count) }}) needs: 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/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 < + + + + 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..3c7cf4c182 --- /dev/null +++ b/__tests__/bundlers/esbuild/package.json @@ -0,0 +1,17 @@ +{ + "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", + "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" + }, + "devDependencies": { + "esbuild": "0.28.2" + } +} 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 new file mode 100644 index 0000000000..12dfd2254b --- /dev/null +++ b/__tests__/bundlers/esbuild/src/index.js @@ -0,0 +1,7 @@ +// The entry for React 18 and later. + +import { createRoot } from 'react-dom/client'; + +import app from './app.js'; + +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/report.js b/__tests__/bundlers/report.js new file mode 100644 index 0000000000..0e1c28e633 --- /dev/null +++ b/__tests__/bundlers/report.js @@ -0,0 +1,38 @@ +// Injected before the app's scripts (see Dockerfile): records errors, and reports in `window.bundlerTestResult` once Web +// Chat shows the bot message, for run.mjs to read over WebDriver. + +(() => { + 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); + 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 new file mode 100644 index 0000000000..3a6463c9a6 --- /dev/null +++ b/__tests__/bundlers/run.mjs @@ -0,0 +1,120 @@ +// 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 { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +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 getValue(), which is called every 100 ms. +async function poll(getValue, message) { + const deadline = Date.now() + TIMEOUT; + + for (;;) { + const value = await getValue(); + + if (value) { + return value; + } else if (Date.now() > deadline) { + throw new Error(`${message} in ${TIMEOUT / 1000} seconds.`); + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + +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, + signal: AbortSignal.timeout(TIMEOUT) + }); + const { value } = await response.json(); + + if (!response.ok) { + throw new Error(`WebDriver ${method} /${path}: ${value?.message ?? response.statusText}`); + } + + return value; + } + + console.time('WebDriver ready'); + + await poll( + () => + webDriver('GET', 'status').then( + ({ ready }) => ready, + () => false + ), + 'WebDriver is not ready' + ); + + console.timeEnd('WebDriver ready'); + console.time('Rendered'); + + const { sessionId } = await webDriver('POST', 'session', { + capabilities: { alwaysMatch: { browserName: 'chrome' } } + }); + + 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 { + // 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(); +} 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..a6c2163c22 --- /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..fc7b08f531 --- /dev/null +++ b/__tests__/bundlers/vite/package.json @@ -0,0 +1,20 @@ +{ + "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", + "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" + }, + "devDependencies": { + "vite": "8.3.5" + } +} 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 new file mode 100644 index 0000000000..12dfd2254b --- /dev/null +++ b/__tests__/bundlers/vite/src/index.js @@ -0,0 +1,7 @@ +// The entry for React 18 and later. + +import { createRoot } from 'react-dom/client'; + +import app from './app.js'; + +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/vite.config.js b/__tests__/bundlers/vite/vite.config.js new file mode 100644 index 0000000000..07f6e78a23 --- /dev/null +++ b/__tests__/bundlers/vite/vite.config.js @@ -0,0 +1,13 @@ +import { createRequire } from 'node:module'; +import { defineConfig } from 'vite'; + +const { version: reactDOMVersion } = createRequire(import.meta.url)('react-dom/package.json'); + +export default defineConfig({ + 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/.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..543fe84d7f --- /dev/null +++ b/__tests__/bundlers/webpack4/package.json @@ -0,0 +1,43 @@ +{ + "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", + "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" + }, + "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", + "@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/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 new file mode 100644 index 0000000000..12dfd2254b --- /dev/null +++ b/__tests__/bundlers/webpack4/src/index.js @@ -0,0 +1,7 @@ +// The entry for React 18 and later. + +import { createRoot } from 'react-dom/client'; + +import app from './app.js'; + +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/webpack.config.js b/__tests__/bundlers/webpack4/webpack.config.js new file mode 100644 index 0000000000..9e89bc9651 --- /dev/null +++ b/__tests__/bundlers/webpack4/webpack.config.js @@ -0,0 +1,54 @@ +// 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 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'); + +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. +const transpilePackages = [ + 'event-as-promise', + 'htmlparser2', + 'iter-fest', + '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 = { + entry: reactDOMVersion.startsWith('16.') ? './src/index.react16.js' : './src/index.js', + mode: 'production', + output: { + filename: 'main.js', + path: path.resolve(__dirname, 'dist') + }, + 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' + } + } + } + ] + } +}; 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..625788e453 --- /dev/null +++ b/__tests__/bundlers/webpack5/package.json @@ -0,0 +1,18 @@ +{ + "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", + "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" + }, + "devDependencies": { + "webpack": "5.107.2", + "webpack-cli": "7.2.3" + } +} 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 new file mode 100644 index 0000000000..12dfd2254b --- /dev/null +++ b/__tests__/bundlers/webpack5/src/index.js @@ -0,0 +1,7 @@ +// The entry for React 18 and later. + +import { createRoot } from 'react-dom/client'; + +import app from './app.js'; + +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/webpack.config.js b/__tests__/bundlers/webpack5/webpack.config.js new file mode 100644 index 0000000000..4358175e01 --- /dev/null +++ b/__tests__/bundlers/webpack5/webpack.config.js @@ -0,0 +1,12 @@ +const path = require('path'); + +const { version: reactDOMVersion } = require('react-dom/package.json'); + +module.exports = { + entry: reactDOMVersion.startsWith('16.') ? './src/index.react16.js' : './src/index.js', + mode: 'production', + output: { + filename: 'main.js', + path: path.resolve(__dirname, 'dist') + } +}; 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/docs/HOSTING.md b/docs/HOSTING.md index 6c533d5054..b100dabca3 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -189,6 +189,64 @@ 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. + +#### 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. + +```js +const { sep } = require('path'); + +// Dependencies of Web Chat that ship syntax newer than ES2019 (e.g. `?.`, `#private`, `1_000`), which webpack 4 cannot parse. +const transpilePackages = [ + 'event-as-promise', + 'htmlparser2', + 'iter-fest', + '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' + } + } + } + ] + } +}; +``` + +- If your app already transpiles `node_modules`, add these packages to its rule instead. +- 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 > 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. diff --git a/package-lock.json b/package-lock.json index 4abc9fbdf8..ce9c6e540e 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/package.json b/package.json index 9a8f2f01eb..5f222d4243 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 && 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", 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'); 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..a93cf8bfc3 --- /dev/null +++ b/packages/core/src/createStore.devTools.spec.ts @@ -0,0 +1,76 @@ +/** @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'; + +// 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([ + ['devTools: true', () => ({ devTools: true })], + ['devTools: false', () => ({ devTools: false })] + ]) + .and( + 'the Redux DevTools browser extension', + options => { + 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); + } + ); + + devToolsWindow.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = composeEnhancers; + + return { composeEnhancers, devToolsCreateStore, options }; + }, + () => { + delete devToolsWindow.__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 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!') + ); + + 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..4b4cfc26a5 100644 --- a/packages/core/src/createStore.ts +++ b/packages/core/src/createStore.ts @@ -1,5 +1,10 @@ -import { composeWithDevTools } from '@redux-devtools/extension'; -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'; @@ -10,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. @@ -123,10 +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 || {}, - options.devTools ? composeWithDevTools(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/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": [