From 645707ffe3cc013c25e5e4294ecf0ea427a6023c Mon Sep 17 00:00:00 2001 From: Bharath Balan <62698609+bhabalan@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:12:39 +0530 Subject: [PATCH] fix(cc-widgets): restore bundler compatibility for wellness media Generate lazy JavaScript media modules, preserve the approved raw assets, and let production UI logging use normal exports. Add package regression tests and update the sample and module specs. BREAKING CHANGE: Default wellness audio is a data URL. CSP must permit media-src data: or the host must supply an allowed wellnessAudioUrl. --- .../features/agent-wellness-break-intake.md | 65 ++++++++--- .../ai-assistant/ai-docs/ai-assistant-spec.md | 30 ++++- .../ai-assistant/src/ai-assistant.types.ts | 2 +- .../wellness/assets/emit-wellness-asset.cjs | 9 -- .../src/wellness/assets/media.cjs | 31 ++---- .../tests/wellness.orchestrator.ts | 2 + .../ai-assistant/webpack.config.js | 22 ++-- .../ui-logging/ai-docs/ui-logging-spec.md | 8 +- .../contact-center/ui-logging/package.json | 2 +- tooling/tests/wellness-media.js | 104 ++++++++++++++++++ .../cc/samples-cc-react-app/webpack.config.js | 8 +- 11 files changed, 217 insertions(+), 66 deletions(-) delete mode 100644 packages/contact-center/ai-assistant/src/wellness/assets/emit-wellness-asset.cjs create mode 100644 tooling/tests/wellness-media.js diff --git a/ai-docs/features/agent-wellness-break-intake.md b/ai-docs/features/agent-wellness-break-intake.md index 2737ed252..042005e19 100644 --- a/ai-docs/features/agent-wellness-break-intake.md +++ b/ai-docs/features/agent-wellness-break-intake.md @@ -110,23 +110,54 @@ and `✍🏻 Smart summaries`, with their approved Desktop descriptions. | Dark Lottie | `5e9a5eec6f7e370e6c386a79c0bc12b43d4b397e9a9ac94dedbe58f1a93c2f46` | `193052c64943fd94d993916936ef6cb2a2c9fc642c9856892c6ddda983ec92ac` | Removed the missing `audio_0` asset and its type-6 layer. | | Desktop MP3 | `19e4474587759c98638ade310b8873ad957e03686c5a8af04c9a5d272c81699b` | same | Unmodified 65.097-second source. | -The renderer, theme JSON, and audio URL module are dynamic imports. The MP3 is emitted as -`assets/wellness/WellnessBreakSound.mp3`; `wellnessAudioUrl` overrides it for CDN/subpath deployments. - -A production build followed by `npm pack --dry-run --json` shows the media boundary: - -| Package | Lottie renderer chunk | Wellness MP3 | -| --- | --- | --- | -| `@webex/cc-components` | absent | absent | -| `@webex/cc-ai-assistant` | present (305,431 B) | present (2,603,884 B) | -| `@webex/cc-widgets` | present for its WC entry (305,431 B) | present for its WC entry (2,603,884 B) | - -The React entry of `@webex/cc-widgets` does not import AI Assistant or reference its wellness media. -The WC entry preserves the existing AI Assistant registration and copies its lazy media into the -aggregate package. The assistant loads the renderer and audio for imminent playback. Hosts that consume -the prebuilt WC entry must serve its lazy chunks and MP3 from the expected public path; the React sample -builds the assistant from source and emits those files itself. The Web Component sample does not copy -the aggregate package's media into its output automatically. +The package build generates `.mjs` exports for the audio data URL and both animation objects. +The renderer, theme module, and audio URL module use standard dynamic imports. The media loader +contains no inline loader requests, `require.ensure()`, or Webpack public-path globals. Default media +does not require consumer MP3/JSON loaders or raw asset copying. JavaScript chunk paths and development +dependency prebundling remain the consumer's responsibility. + +The original MP3 and theme JSON remain under `assets/wellness` with the checksums above. +`wellnessAudioUrl` overrides the data URL and bypasses the packaged audio import. CSP must permit +`data:` in `media-src` for default playback; hosts with a stricter policy can use an allowed hosted URL. +The 2,603,884-byte MP3 produces about 3.5 MB of encoded JavaScript before compression. + +Both React import paths retain AI Assistant. The React and WC entries of `@webex/cc-widgets` +externalize `@webex/cc-ai-assistant`, which owns these media files. `@webex/cc-components` receives +an injected animation loader and includes no wellness media. Verify packed packages locally with +Webpack, Vite, Rollup, esbuild, and Parcel, with wellness unused and active, both themes, audio +decoding, and a URL subpath. Also verify the custom audio override under a hosted-media CSP. +The React sample compiles widgets from source and routes only the wellness media import to the +assistant's built loader. Build packages before starting that sample. The assistant watch build +tracks its raw media files and regenerates the JavaScript modules after a change. + +### Local media compatibility checks + +The packed release packages passed the following checks on 2026-10-12. A fresh +production build and package archive comparison on 2026-10-13 reproduced the same +SHA-256 hashes for all nine Contact Center packages. + +| Consumer | Version | Production imports and playback | Development | +| --- | --- | --- | --- | +| Webpack | 5.111.1 | Passed | Passed | +| Vite | 8.3.4 | Passed | Passed | +| Rollup | 4.64.5 | Passed | Passed | +| esbuild | 0.28.2 | Passed | Passed | +| Parcel | 2.16.4 | Passed | Passed | + +The checks covered React imports, Web Component registration, both animation +themes, MP3 decoding and playback, delayed sound loading, and deployment below +`/app/nested/`. Consumers used the required UI peers and normal CSS/SCSS support; +Parcel package exports were enabled. They used no MP3 loader, wellness JSON +loader, or raw media copy step. Browser checks used Chromium 156.0.8078.4 through +Playwright 1.64.0 and the existing host globals without an authenticated SDK session. + +`media-src 'self'` blocked the default data audio and permitted the original MP3 +served from the same origin. Hosts with this policy must supply an allowed +`wellnessAudioUrl`. The sound module contains about 3.5 MB before compression. +`tooling/tests/wellness-media.js` checks emission, raw bytes, import requests, +watch dependencies, and regeneration. The AI Assistant unit test also checks that +the host URL bypasses the default sound import. A live agent session and Amplify +validation remain release checks. ## Release gates diff --git a/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md b/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md index 17beac66b..582666515 100644 --- a/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md +++ b/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md @@ -89,11 +89,31 @@ Import `AIAssistant` from `@webex/cc-widgets` or directly from `@webex/cc-ai-ass separate AI Assistant WC entry. This package owns `lottie-web` and the wellness MP3; `cc-components` receives an animation loader as a prop and has no media dependency. -The CommonJS package includes the MP3, animation data, and its media loader under -`dist/assets/wellness`. Webpack emits the MP3 and lazy Lottie/animation chunks into -the consuming app without an asset rule or copy step. The browser fetches them -only when the wellness UI uses them. The React sample also supports this path -when it builds the assistant source. +The CommonJS package includes its media loader and generated `.mjs` modules under +`dist/assets/wellness`. The package build converts the MP3 to an audio data URL +and each theme's JSON to a JavaScript export. It retains the approved MP3 and JSON +files for hosts that serve them directly. The media loader uses standard dynamic +imports for the generated modules and `lottie-web`; it contains no inline asset +loader requests, `require.ensure()`, or Webpack public-path globals. Consumers do +not need an MP3/JSON loader or a copy step for the default media. Consumers that +support code splitting can fetch these JavaScript modules when the wellness UI +uses them. Development dependency prebundling can combine lazy modules. + +The React sample compiles the widget TypeScript from source. Its Webpack config +routes the wellness media import to this package's built media loader. Run +`yarn build:dev` before building or serving the sample. The package build tracks +the original media files as dependencies, so its watch build regenerates modules +when these files change. + +The default audio requires CSP `media-src` to permit `data:` URLs. Hosts that +permit only hosted audio must set the existing `wellnessAudioUrl` prop to an +allowed URL. The override bypasses the packaged audio import. Test the packed +package with Webpack, Vite, Rollup, esbuild, and Parcel, including delayed loading, +audio decoding, both animation themes, and an app deployed below a URL subpath. +The Lottie import accepts both a module default export and a direct CommonJS +export; Parcel and the other consumer bundlers can return different forms. +`tooling/tests/wellness-media.js` checks the emitted modules, unchanged raw media, +standard import requests, source dependencies, and regeneration after a source change. The orchestration uses only the published wellness contract and legacy `setAgentState()` path from the store. It does not consume an AI Assistant RTD status event. diff --git a/packages/contact-center/ai-assistant/src/ai-assistant.types.ts b/packages/contact-center/ai-assistant/src/ai-assistant.types.ts index 17deb274f..97eae9476 100644 --- a/packages/contact-center/ai-assistant/src/ai-assistant.types.ts +++ b/packages/contact-center/ai-assistant/src/ai-assistant.types.ts @@ -40,7 +40,7 @@ export interface IAIAssistantProps { onWellnessBreakEnded?: () => void; /** Fired with a stable, non-PII lifecycle error shape. */ onWellnessBreakError?: (error: WellnessBreakError) => void; - /** Optional host/CDN audio URL. Defaults to the packaged wellness audio asset. */ + /** Optional host/CDN audio URL. The packaged default is a data URL; use this override if CSP blocks data media. */ wellnessAudioUrl?: string; /** Controls whether the break covers the viewport, assistant root, or a host element. */ wellnessBreakOverlayTarget?: WellnessBreakOverlayTarget; diff --git a/packages/contact-center/ai-assistant/src/wellness/assets/emit-wellness-asset.cjs b/packages/contact-center/ai-assistant/src/wellness/assets/emit-wellness-asset.cjs deleted file mode 100644 index 5a0dca421..000000000 --- a/packages/contact-center/ai-assistant/src/wellness/assets/emit-wellness-asset.cjs +++ /dev/null @@ -1,9 +0,0 @@ -const path = require('path'); - -module.exports = function emitWellnessAsset(source) { - const filename = `assets/wellness/${path.basename(this.resourcePath)}`; - this.emitFile(filename, source); - return `module.exports = __webpack_public_path__ + ${JSON.stringify(filename)};`; -}; - -module.exports.raw = true; diff --git a/packages/contact-center/ai-assistant/src/wellness/assets/media.cjs b/packages/contact-center/ai-assistant/src/wellness/assets/media.cjs index 5f0f7a1d0..de014a80c 100644 --- a/packages/contact-center/ai-assistant/src/wellness/assets/media.cjs +++ b/packages/contact-center/ai-assistant/src/wellness/assets/media.cjs @@ -1,27 +1,10 @@ -// Keep these CommonJS requests visible to the consuming Webpack build. The -// assistant's package build leaves this file alongside its published assets. -exports.loadWellnessSoundUrl = () => Promise.resolve(require('!!./emit-wellness-asset.cjs!./WellnessBreakSound.mp3')); +// The package build generates JavaScript modules for the checked-in media. +// Consumers can load them without an MP3 or JSON loader. +exports.loadWellnessSoundUrl = () => import('./WellnessBreakSound.mjs').then((module) => module.default); exports.loadWellnessAnimationData = (theme) => - new Promise((resolve, reject) => { - if (theme === 'dark') { - require.ensure( - [], - (require) => resolve(require('./WellnessBreakAnimationDark.json')), - reject, - 'wellness-animation-dark' - ); - } else { - require.ensure( - [], - (require) => resolve(require('./WellnessBreakAnimationLight.json')), - reject, - 'wellness-animation-light' - ); - } - }); + (theme === 'dark' ? import('./WellnessBreakAnimationDark.mjs') : import('./WellnessBreakAnimationLight.mjs')).then( + (module) => module.default + ); -exports.loadLottie = () => - new Promise((resolve, reject) => { - require.ensure([], (require) => resolve(require('lottie-web')), reject, 'lottie-web'); - }); +exports.loadLottie = () => import('lottie-web').then((module) => module.default ?? module); diff --git a/packages/contact-center/ai-assistant/tests/wellness.orchestrator.ts b/packages/contact-center/ai-assistant/tests/wellness.orchestrator.ts index e18613c52..1635d7f26 100644 --- a/packages/contact-center/ai-assistant/tests/wellness.orchestrator.ts +++ b/packages/contact-center/ai-assistant/tests/wellness.orchestrator.ts @@ -2,6 +2,7 @@ import {act, renderHook, waitFor} from '@testing-library/react'; import type {UseWellnessBreakInput} from '../src/ai-assistant.types'; import {useWellnessBreak, WELLNESS_OFFER_TIMEOUT_MS, WELLNESS_RECOVERY_KEY} from '../src/wellness/useWellnessBreak'; import store from '@webex/cc-store'; +import {loadWellnessSoundUrl} from '../src/wellness/assets/media.cjs'; jest.mock('../src/wellness/assets/media.cjs', () => ({ loadWellnessSoundUrl: jest.fn().mockResolvedValue('/wellness.mp3'), @@ -753,6 +754,7 @@ describe('useWellnessBreak', () => { await Promise.resolve(); }); expect(audioConstructor).toHaveBeenCalledWith('/wellness.mp3'); + expect(loadWellnessSoundUrl).not.toHaveBeenCalled(); expect(audio.load).toHaveBeenCalledTimes(1); expect(audio.play).not.toHaveBeenCalled(); diff --git a/packages/contact-center/ai-assistant/webpack.config.js b/packages/contact-center/ai-assistant/webpack.config.js index c4a6e3a98..89c57f3e6 100644 --- a/packages/contact-center/ai-assistant/webpack.config.js +++ b/packages/contact-center/ai-assistant/webpack.config.js @@ -6,7 +6,6 @@ const baseConfig = require('../../../webpack.config'); const wellnessAssets = [ 'media.cjs', - 'emit-wellness-asset.cjs', 'WellnessBreakSound.mp3', 'WellnessBreakAnimationDark.json', 'WellnessBreakAnimationLight.json', @@ -23,12 +22,21 @@ class EmitWellnessAssetsPlugin { () => { for (const filename of wellnessAssets) { const sourcePath = path.resolve(__dirname, 'src/wellness/assets', filename); - compilation.emitAsset( - `assets/wellness/${filename}`, - new compiler.webpack.sources.RawSource(fs.readFileSync(sourcePath)), - // A consuming Webpack build must still recognize require.ensure's require callback. - {minimized: filename.endsWith('.cjs')} - ); + compilation.fileDependencies.add(sourcePath); + const source = fs.readFileSync(sourcePath); + compilation.emitAsset(`assets/wellness/${filename}`, new compiler.webpack.sources.RawSource(source), { + minimized: filename.endsWith('.cjs'), + }); + if (filename.endsWith('.mp3') || filename.endsWith('.json')) { + const data = filename.endsWith('.mp3') + ? `data:audio/mpeg;base64,${source.toString('base64')}` + : JSON.parse(source.toString('utf8')); + compilation.emitAsset( + `assets/wellness/${filename.replace(/\.(mp3|json)$/, '.mjs')}`, + new compiler.webpack.sources.RawSource(`export default ${JSON.stringify(data)};\n`), + {minimized: true} + ); + } } } ); diff --git a/packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md b/packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md index 8eeba953a..3b1621c16 100644 --- a/packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md +++ b/packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md @@ -54,9 +54,15 @@ mount and `WIDGET_UNMOUNTED` on unmount via the store logger. Does NOT own the l log persistence, or prop sanitization. ## Stack + +The bundle uses the root Webpack mode from `NODE_ENV`. Production builds must +publish normal JavaScript exports rather than development `eval()` modules, so +consumer bundlers can read `withMetrics` and its dependencies. The default mode +remains development when `NODE_ENV` is not set. + TypeScript ^5.6.3, React (peer `>=18.3.1`, `react-dom >=18.3.1`) HOC + hooks. Tests: Jest 29.7.0 + React Testing Library 16.0.1 + `@testing-library/jest-dom`, jsdom environment. Build: Webpack 5 -(`webpack --mode=development`), output to `dist/`. Source of truth: `package.json`. +(`webpack`), output to `dist/`. Source of truth: `package.json`. ## Folder / Package Structure ``` diff --git a/packages/contact-center/ui-logging/package.json b/packages/contact-center/ui-logging/package.json index 4dbdb194d..99a84db89 100644 --- a/packages/contact-center/ui-logging/package.json +++ b/packages/contact-center/ui-logging/package.json @@ -13,7 +13,7 @@ "package.json" ], "scripts": { - "build:src": "webpack --mode=development", + "build:src": "webpack", "clean": "rm -rf dist", "clean:dist": "rm -rf dist", "test:unit": "NODE_ENV=test jest --coverage", diff --git a/tooling/tests/wellness-media.js b/tooling/tests/wellness-media.js new file mode 100644 index 000000000..d11df8f3e --- /dev/null +++ b/tooling/tests/wellness-media.js @@ -0,0 +1,104 @@ +const fs = require('fs'); +const path = require('path'); +const webpack = require('webpack'); +const config = require('../../packages/contact-center/ai-assistant/webpack.config'); + +const sourceRoot = path.resolve(__dirname, '../../packages/contact-center/ai-assistant/src/wellness/assets'); +const sourceFiles = [ + 'media.cjs', + 'WellnessBreakSound.mp3', + 'WellnessBreakAnimationDark.json', + 'WellnessBreakAnimationLight.json', +]; + +const emitMedia = () => { + const assets = new Map(); + const fileDependencies = new Set(); + const compilation = { + fileDependencies, + emitAsset: (filename, source) => assets.set(filename, source.buffer()), + hooks: {processAssets: {tap: (_options, callback) => callback()}}, + }; + const compiler = { + webpack: {sources: webpack.sources, Compilation: {PROCESS_ASSETS_STAGE_ADDITIONS: 0}}, + hooks: {thisCompilation: {tap: (_name, callback) => callback(compilation)}}, + }; + config.plugins.find((plugin) => plugin.constructor.name === 'EmitWellnessAssetsPlugin').apply(compiler); + return {assets, fileDependencies}; +}; + +const readExport = (assets, filename) => { + const source = assets.get(`assets/wellness/${filename}`).toString('utf8'); + expect(source.startsWith('export default ')).toBe(true); + return JSON.parse(source.slice('export default '.length, -2)); +}; + +describe('wellness media packaging', () => { + let emitted; + beforeAll(() => { + emitted = emitMedia(); + }); + + it('emits all portable modules and removes the inline Webpack loader', () => { + expect([...emitted.assets.keys()].sort()).toEqual( + [...sourceFiles, 'WellnessBreakSound.mjs', 'WellnessBreakAnimationDark.mjs', 'WellnessBreakAnimationLight.mjs'] + .map((filename) => `assets/wellness/${filename}`) + .sort() + ); + expect(emitted.assets.has('assets/wellness/emit-wellness-asset.cjs')).toBe(false); + }); + + it.each(sourceFiles)('retains the original bytes of %s', (filename) => { + expect(emitted.assets.get(`assets/wellness/${filename}`)).toEqual(fs.readFileSync(path.join(sourceRoot, filename))); + }); + + it('exports an audio data URL containing the original MP3', () => { + const url = readExport(emitted.assets, 'WellnessBreakSound.mjs'); + expect(url.startsWith('data:audio/mpeg;base64,')).toBe(true); + expect(Buffer.from(url.slice(url.indexOf(',') + 1), 'base64')).toEqual( + fs.readFileSync(path.join(sourceRoot, 'WellnessBreakSound.mp3')) + ); + }); + + it.each(['Dark', 'Light'])('exports the original %s animation as JavaScript data', (theme) => { + expect(readExport(emitted.assets, `WellnessBreakAnimation${theme}.mjs`)).toEqual( + JSON.parse(fs.readFileSync(path.join(sourceRoot, `WellnessBreakAnimation${theme}.json`), 'utf8')) + ); + }); + + it('uses standard imports without a consumer MP3 or JSON loader', () => { + const loader = emitted.assets.get('assets/wellness/media.cjs').toString('utf8'); + expect(loader).not.toMatch(/require\.ensure|__webpack_public_path__|emit-wellness-asset|!!\./); + expect(loader).not.toMatch(/(?:import|require)\([^)]*\.(?:mp3|json)/); + const imports = [...loader.matchAll(/import\('\.\/([^']+)'\)/g)].map((match) => match[1]); + expect(imports.sort()).toEqual( + ['WellnessBreakSound.mjs', 'WellnessBreakAnimationDark.mjs', 'WellnessBreakAnimationLight.mjs'].sort() + ); + imports.forEach((filename) => expect(emitted.assets.has(`assets/wellness/${filename}`)).toBe(true)); + }); + + it('tracks the original media files for watch builds', () => { + expect([...emitted.fileDependencies].sort()).toEqual( + sourceFiles.map((filename) => path.join(sourceRoot, filename)).sort() + ); + }); + + it('regenerates the audio module when the source bytes change', () => { + const readFile = fs.readFileSync; + const changedAudio = Buffer.from('changed audio bytes'); + const readSpy = jest + .spyOn(fs, 'readFileSync') + .mockImplementation((filename, ...options) => + filename === path.join(sourceRoot, 'WellnessBreakSound.mp3') ? changedAudio : readFile(filename, ...options) + ); + try { + const updated = emitMedia(); + expect(readExport(updated.assets, 'WellnessBreakSound.mjs')).toBe( + `data:audio/mpeg;base64,${changedAudio.toString('base64')}` + ); + expect(updated.assets.get('assets/wellness/WellnessBreakSound.mp3')).toEqual(changedAudio); + } finally { + readSpy.mockRestore(); + } + }); +}); diff --git a/widgets-samples/cc/samples-cc-react-app/webpack.config.js b/widgets-samples/cc/samples-cc-react-app/webpack.config.js index ac724b137..361b5d4aa 100644 --- a/widgets-samples/cc/samples-cc-react-app/webpack.config.js +++ b/widgets-samples/cc/samples-cc-react-app/webpack.config.js @@ -1,7 +1,7 @@ const fs = require('fs'); const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -const {HotModuleReplacementPlugin, ProvidePlugin} = require('webpack'); +const {HotModuleReplacementPlugin, NormalModuleReplacementPlugin, ProvidePlugin} = require('webpack'); // Helper function to resolve paths relative to the monorepo root const resolveMonorepoRoot = (...segments) => path.resolve(__dirname, '../../../', ...segments); @@ -143,6 +143,12 @@ module.exports = { chunkFilename: 'assets/[name].[contenthash:8].js', }, plugins: [ + // The source sample uses media modules generated by the assistant package build. + new NormalModuleReplacementPlugin(/^\.\/assets\/media\.cjs$/, (resource) => { + if (resource.context === resolveMonorepoRoot('packages/contact-center/ai-assistant/src/wellness')) { + resource.request = resolveMonorepoRoot('packages/contact-center/ai-assistant/dist/assets/wellness/media.cjs'); + } + }), new HtmlWebpackPlugin({ template: './public/index.html', // Template HTML file filename: 'index.html',