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',