From 3d5ba2a4a473e4d720530be9b7e923f733c9d5fd Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich Date: Wed, 22 Apr 2026 13:22:40 +0200 Subject: [PATCH 01/13] test(chat-components): AB#131731 add DOM compatibility check vs latest release Renders every supported variant (core layouts + all demo-page tabs) against the branch's built dist/ and the latest published @cognigy/chat-components, and asserts the normalized DOM matches. Guards against silent regressions in the default webchat layout consumers depend on. Runs as its own GitHub Action ("DOM Compatibility") so a break shows as a distinct PR check. The baseline is resolved at CI time via `npm view` so we never drift against a stale pin. Excluded from the default `npm test` because it requires a dist/ build and the dynamically-installed alias. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/dom-compat.yml | 43 ++++ package.json | 2 + scripts/install-dom-compat-baseline.mjs | 102 ++++++++++ test/layouts/dom-compat.spec.tsx | 250 ++++++++++++++++++++++++ vite.config.ts | 9 +- vitest.dom-compat.config.ts | 41 ++++ 6 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/dom-compat.yml create mode 100644 scripts/install-dom-compat-baseline.mjs create mode 100644 test/layouts/dom-compat.spec.tsx create mode 100644 vitest.dom-compat.config.ts diff --git a/.github/workflows/dom-compat.yml b/.github/workflows/dom-compat.yml new file mode 100644 index 00000000..adc89580 --- /dev/null +++ b/.github/workflows/dom-compat.yml @@ -0,0 +1,43 @@ +name: DOM Compatibility + +# Separate workflow (distinct check on the PR) so a DOM-compatibility +# failure stands out next to regular unit-test failures. The spec renders +# every supported message type on this branch and compares the DOM against +# the same type rendered by the latest published @cognigy/chat-components +# release. A failure means the branch would break backward compatibility +# for consumers of the default webchat layout. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + dom-compat: + name: DOM compatibility vs latest release + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [22.x] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + - run: npm ci + # Dynamically installs the dist-tag `latest` of + # @cognigy/chat-components as the aliased dev-dep + # `chat-components-baseline` used by the dom-compat spec. Must + # run AFTER npm ci (which clears node_modules) and BEFORE the + # build / dom-compat test. See scripts/install-dom-compat-baseline.mjs. + - run: npm run test:dom-compat:install-baseline + # The dom-compat spec imports Message from `dist/chat-components.js`, + # so a production build must exist before the spec runs. Using + # the same build pipeline for both sides of the comparison avoids + # Vitest-only CSS-module class-name artifacts. + - run: npm run build + - run: npm run test:dom-compat diff --git a/package.json b/package.json index a2ecb2ee..682729bf 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "test": "vitest run", "test:web-ui": "vitest --ui", "test:watch": "vitest", + "test:dom-compat:install-baseline": "node scripts/install-dom-compat-baseline.mjs", + "test:dom-compat": "vitest run --config vitest.dom-compat.config.ts", "codeql:scan": "rimraf node_modules && npm ci --omit=dev && codeql database create --overwrite codeql-db --language=typescript-javascript --source-root=. --codescanning-config=codeql-config.yml && codeql database analyze codeql-db codeql/javascript-queries --format=sarifv2.1.0 --output=codeql-results.sarif --threads=0", "codeql:scan:dist": "npm ci && npm run build && rimraf node_modules && codeql database create --overwrite codeql-db --language=javascript --source-root=dist && codeql database analyze codeql-db codeql/javascript-queries --format=sarifv2.1.0 --output=codeql-results-dist.sarif --threads=0" }, diff --git a/scripts/install-dom-compat-baseline.mjs b/scripts/install-dom-compat-baseline.mjs new file mode 100644 index 00000000..e56a14d1 --- /dev/null +++ b/scripts/install-dom-compat-baseline.mjs @@ -0,0 +1,102 @@ +/* eslint-env node */ +/** + * Installs the latest published `@cognigy/chat-components` as the aliased + * dev-dependency `chat-components-baseline`. Consumed by + * `test/layouts/dom-compat.spec.tsx`, which compares the current branch's + * built DOM output against the last public release. + * + * Why a script instead of a pinned devDependency? + * A pinned version would drift as releases happen; we'd have to bump + * package.json on every release or the DOM-compat check would silently + * assert against an older baseline. Resolving "latest" at install time + * keeps the check honest without human bookkeeping. + * + * Behavior: + * - Resolves the latest @cognigy/chat-components from the configured + * registry (`dist-tags.latest`). + * - Installs it as `chat-components-baseline@npm:@cognigy/chat-components@` + * without touching package.json / package-lock.json (`--no-save`). + * - If the working tree's own package.json version equals the latest + * published version, logs a notice — the DOM-compat test will still run + * but will effectively compare a rebuild against itself. + * + * Usage: + * node scripts/install-dom-compat-baseline.mjs + * # or via npm: + * npm run test:dom-compat:install-baseline + * + * Exit codes: + * 0 — baseline installed (or already present at the resolved version) + * 1 — npm view / npm install failed + */ + +import { execSync } from "node:child_process"; +import { readFileSync, existsSync } from "node:fs"; + +const PKG_NAME = "@cognigy/chat-components"; +const ALIAS = "chat-components-baseline"; + +function run(cmd, opts = {}) { + // execSync returns null when stdout is inherited (no captured buffer), so + // we only call .toString() when we know we captured stdout. + const out = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], ...opts }); + return out == null ? "" : out.toString().trim(); +} + +function currentVersion() { + const pkg = JSON.parse(readFileSync("package.json", "utf8")); + return pkg.version; +} + +function latestPublishedVersion() { + // `npm view version` returns the version tagged `latest`. + return run(`npm view ${PKG_NAME} version`); +} + +function installedBaselineVersion() { + const p = `node_modules/${ALIAS}/package.json`; + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, "utf8")).version ?? null; + } catch { + return null; + } +} + +function main() { + const current = currentVersion(); + const latest = latestPublishedVersion(); + + console.log(`[dom-compat] working tree version: ${current}`); + console.log(`[dom-compat] latest published version: ${latest}`); + + if (current === latest) { + console.log( + `[dom-compat] NOTE: working tree is at the latest published version — ` + + `DOM-compat check will compare a rebuild against itself.`, + ); + } + + const installed = installedBaselineVersion(); + if (installed === latest) { + console.log(`[dom-compat] baseline already installed at ${latest} — skipping.`); + return; + } + + console.log( + `[dom-compat] installing ${ALIAS}@npm:${PKG_NAME}@${latest}` + + (installed ? ` (replacing ${installed})` : "") + + "...", + ); + run(`npm install --no-save --no-audit --no-fund ${ALIAS}@npm:${PKG_NAME}@${latest}`, { + stdio: "inherit", + }); + console.log(`[dom-compat] done.`); +} + +try { + main(); +} catch (err) { + console.error(`[dom-compat] failed: ${err?.message ?? err}`); + process.exit(1); +} diff --git a/test/layouts/dom-compat.spec.tsx b/test/layouts/dom-compat.spec.tsx new file mode 100644 index 00000000..e353a534 --- /dev/null +++ b/test/layouts/dom-compat.spec.tsx @@ -0,0 +1,250 @@ +/** + * DOM compatibility: default webchat layout on this branch must render DOM + * identical to the latest published library release (@cognigy/chat-components + * installed dynamically as the `chat-components-baseline` alias by + * scripts/install-dom-compat-baseline.mjs — see that script for the why). + * + * Compares BUILT artifact vs BUILT artifact to avoid false positives caused + * by Vitest's `classNameStrategy: "non-scoped"` CSS-module behavior (see + * vite.config.ts). Under `non-scoped`, every CSS-module key resolves to its + * literal camelCase name, which diverges from the production build in two + * ways: + * - missing keys (e.g. `classes.slideImage` when `.slideImage` isn't in the + * CSS file) resolve to the literal string "slideImage" instead of + * `undefined`, producing a phantom `class="slideImage"` attribute; + * - two distinct CSS-module scopes that reuse the same key (e.g. `.button` + * in both Buttons.module.css and TextWithButtons.module.css) collapse to + * the same string, producing visible duplicates (`class="button button"`). + * Neither divergence exists at runtime for real consumers. Building the + * branch first and importing from `dist/` makes both sides go through the + * same Vite production CSS-module pipeline, so the comparison reflects the + * actual published DOM. + * + * RUN SEPARATION: this spec is excluded from the default `npm test` via + * vite.config.ts (`test.exclude`). It's executed by `npm run test:dom-compat`, + * which uses vitest.dom-compat.config.ts to narrow `include` to just this + * file. On CI, the dedicated .github/workflows/dom-compat.yml invokes the + * script after the baseline install + production build so it shows as its + * own check on the PR. + * + * PRECONDITIONS: + * - `npm run test:dom-compat:install-baseline` has installed the + * `chat-components-baseline` alias (latest published release). + * - `npm run build` has produced `../../dist/chat-components.js`. + * The CI workflow wires both steps before invoking `npm run test:dom-compat`. + * + * The test renders the same fixtures through from both packages + * side by side and performs a strict DOM structure comparison. Indentation, + * inter-tag whitespace, React-generated dynamic ids (useId output like + * `:r7:`, tooltip ids, UUID-based gallery ids, swiper wrapper hashes) and + * CSS-module hash suffixes (`_header_21mid_1` → `header`) are normalized + * away before comparing because they are not part of the structural + * contract. + * + * If this test fails, the default (no layout prop / `layout="webchat"`) + * render path on this branch has diverged from the published release and the + * backward-compatibility claim of PR #242 is broken. + */ +import { render } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; + +// Import from the branch's built dist/, not src/, so CSS-module resolution +// matches the baseline package (which is also a dist/ bundle). See preamble. +import { Message as CurrentMessage } from "../../dist/chat-components.js"; +import { Message as BaselineMessage } from "chat-components-baseline"; +// Read the installed baseline's version so the describe block / failure +// messages show exactly which release we compared against. The baseline's +// package.json is not re-exported through the package's `exports` field, so +// we read the file directly instead of using a bare-specifier import. +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const baselineVersion: string = JSON.parse( + readFileSync( + resolve(__dirname, "../../node_modules/chat-components-baseline/package.json"), + "utf8", + ), +).version; + +import { + botTextMessage, + userTextMessage, + agentTextMessage, + engagementTextMessage, + richBotMessage, + quickRepliesBotMessage, +} from "../fixtures/layout-messages"; +import type { IMessage } from "@cognigy/socket-client"; + +// ---- demo-page fixtures ---- +// Each corresponds to a tab on test/demo.tsx. We cover every message type the +// demo renders via a component (i.e. everything except the +// non-Message tabs: "UI Components" and tabs that require stateful runtime +// setup like collation or streaming animation). Fixtures that don't ship with +// a `source` field are given `"bot"` at rendering time — the published +// baseline and the branch both follow the same rule so the comparison still holds. +import imageFixture from "../fixtures/image.json"; +import imageDownloadableFixture from "../fixtures/image-downloadable.json"; +import imageBrokenFixture from "../fixtures/imageBroken.json"; +import videoFixture from "../fixtures/video.json"; +import videoYoutubeFixture from "../fixtures/videoYoutube.json"; +import videoAltTextFixture from "../fixtures/videoWithAltText.json"; +import audioFixture from "../fixtures/audio.json"; +import fileFixture from "../fixtures/file.json"; +import listFixture from "../fixtures/list.json"; +import galleryFixture from "../fixtures/gallery.json"; +import galleryNullButtonsFixture from "../fixtures/gallery-with-null-buttons.json"; +import actionButtonsFixture from "../fixtures/action-buttons.json"; +import adaptiveCardsFixture from "../fixtures/adaptiveCards.json"; +import webchat3EventFixture from "../fixtures/webchat3Event.json"; +import datepickerSingleDate from "../fixtures/datepicker/singleDate.json"; +import datepickerMinMax from "../fixtures/datepicker/singleDateWithMinMax.json"; +import datepickerMultiple from "../fixtures/datepicker/multiple.json"; +import datepickerRange from "../fixtures/datepicker/range.json"; +import datepickerWeeks from "../fixtures/datepicker/weekNumbers.json"; +import datepickerNoTime from "../fixtures/datepicker/noTime.json"; +import datepickerTimeOnly from "../fixtures/datepicker/timeOnly.json"; +import datepickerDisableWeekends from "../fixtures/datepicker/disableWeekends.json"; + +// Cast + default source helper. Fixtures are stored as plain JSON and some +// omit `source` because the existing per-component specs accept whatever +// shape the matcher needs; for Message-level rendering we must have a source +// so the non-user / non-engagement branches flow as expected. +const asBot = (raw: unknown): IMessage => + ({ source: "bot", ...(raw as object) }) as IMessage; + +// Normalize HTML so that non-structural differences don't cause false +// positives. We strip: +// 1. Whitespace between tags (indentation is explicitly allowed to differ +// per the PR review request). +// 2. React-generated auto ids (useId / react-tooltip). These look like +// `:r0:`, `:R1a:`, `«r0»` in React 18 and are regenerated per render, +// so the same component rendered twice produces two distinct id +// strings. Any attribute value *containing* such a token gets the +// token masked so cross-referenced attrs (aria-describedby, htmlFor, +// for, id) stay equal to themselves after masking. +// 3. CSS-module hashed class names. The published baseline build emits +// classes in the default hashed form (`_header_21mid_1`, `_incoming_21mid_8`, +// `_title2-regular_1ltiv_41`), while the branch-under-test source is +// loaded by Vitest with `classNameStrategy: "non-scoped"` (see +// vite.config.ts test.css.modules), which yields plain class names +// (`header`, `incoming`, `title2-regular`). This is a build-time +// packaging difference, not a DOM-structural one, so we canonicalize +// both shapes to the plain name before comparing. +function normalize(html: string): string { + return ( + html + // collapse whitespace between tags + .replace(/>\s+<") + // trim leading/trailing whitespace + .trim() + // mask React useId tokens like :r0:, :R1a:, :Rab: + .replace(/:[rR][0-9a-z]+:/g, ":__id__:") + // mask react-tooltip / random uuid-ish ids seen in attribute values + .replace(/tooltip-[A-Za-z0-9_-]+/g, "tooltip-__id__") + // mask UUID v4-style ids (used by gallery subtitle/title/content ids) + .replace( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, + "__uuid__", + ) + // mask swiper auto-generated wrapper/container ids: `swiper-wrapper-` + .replace(/swiper-wrapper-[0-9a-f]+/g, "swiper-wrapper-__id__") + // canonicalize hashed CSS-module class names: + // `_header_21mid_1` / `_title2-regular_1ltiv_41` → `header` / `title2-regular` + .replace(/\b_([A-Za-z][\w-]*?)_[A-Za-z0-9]{4,6}_\d+\b/g, "$1") + // collapse any resulting double spaces inside attribute values + .replace(/ +/g, " ") + ); +} + +type Case = { name: string; message: IMessage }; + +// Core layout / source fixtures. These exercise the Message/Header/Body +// structural contract across every MessageSender variant plus the two plugin +// payload shapes that exist in the layout-messages test helper. +const cases: Case[] = [ + { name: "bot text message", message: botTextMessage }, + { name: "user text message", message: userTextMessage }, + { name: "agent text message", message: agentTextMessage }, + { name: "engagement message", message: engagementTextMessage }, + { name: "bot gallery (generic template)", message: richBotMessage }, + { name: "bot quick replies", message: quickRepliesBotMessage }, +]; + +// Demo-page coverage. One case per demo tab where the tab renders via +// and is not inherently time- or animation-dependent. Skipped +// tabs: "UI Components" (renders ActionButtons/Typography/ChatEvent directly, +// not via ), "Message Collation" (depends on prevMessage chaining), +// "Streaming messages with markdown" (animationState changes DOM over time), +// "Default Preview" + "HTML Sanitization" + "xApp Buttons" (require specific +// widgetSettings.config injection that isn't trivially picked up from JSON). +const demoCases: Case[] = [ + // Multimedia + { name: "demo: image", message: asBot(imageFixture) }, + { name: "demo: image downloadable", message: asBot(imageDownloadableFixture) }, + { name: "demo: image broken", message: asBot(imageBrokenFixture) }, + { name: "demo: video", message: asBot(videoFixture) }, + { name: "demo: video (YouTube)", message: asBot(videoYoutubeFixture) }, + { name: "demo: video with alt text", message: asBot(videoAltTextFixture) }, + { name: "demo: audio", message: asBot(audioFixture) }, + { name: "demo: file", message: asBot(fileFixture) }, + // Templates + { name: "demo: list", message: asBot(listFixture) }, + { name: "demo: gallery", message: asBot(galleryFixture) }, + { name: "demo: gallery (null buttons)", message: asBot(galleryNullButtonsFixture) }, + { name: "demo: quick replies / buttons", message: asBot(actionButtonsFixture) }, + // Datepicker variants (closed calendar — open state is non-deterministic) + { name: "demo: datepicker single date", message: asBot(datepickerSingleDate) }, + { name: "demo: datepicker single date w/ min-max", message: asBot(datepickerMinMax) }, + { name: "demo: datepicker multiple", message: asBot(datepickerMultiple) }, + { name: "demo: datepicker range", message: asBot(datepickerRange) }, + { name: "demo: datepicker week numbers", message: asBot(datepickerWeeks) }, + { name: "demo: datepicker no time", message: asBot(datepickerNoTime) }, + { name: "demo: datepicker time only", message: asBot(datepickerTimeOnly) }, + { name: "demo: datepicker disable weekends", message: asBot(datepickerDisableWeekends) }, + // Misc + // adaptiveCards fixture is an array — first entry is representative. + { + name: "demo: adaptive cards (first)", + message: asBot((adaptiveCardsFixture as unknown as object[])[0]), + }, + { name: "demo: webchat3 event", message: asBot(webchat3EventFixture) }, +]; + +// Shared assertion helper: render the same message through both packages, +// normalize the HTML, compare. Extracted so every case reads the same. +function assertSameDom(message: IMessage, props: Partial<{ layout: "webchat" }> = {}) { + const { container: current, unmount: unmountCurrent } = render( + , + ); + const currentHtml = normalize(current.innerHTML); + unmountCurrent(); + + const { container: baseline, unmount: unmountBaseline } = render( + , + ); + const baselineHtml = normalize(baseline.innerHTML); + unmountBaseline(); + + expect(currentHtml).toBe(baselineHtml); +} + +describe(`DOM compatibility: branch vs @cognigy/chat-components@${baselineVersion}`, () => { + describe("core layout fixtures", () => { + it.each(cases)( + "$name — default layout matches published release DOM", + ({ message }) => assertSameDom(message), + ); + + it(' explicit prop also matches baseline', () => + assertSameDom(botTextMessage, { layout: "webchat" })); + }); + + describe("demo-page message tabs", () => { + it.each(demoCases)( + "$name — matches published release DOM", + ({ message }) => assertSameDom(message), + ); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 383ac2d9..dd795930 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig, configDefaults } from "vitest/config"; import react from "@vitejs/plugin-react-swc"; import svgr from "vite-plugin-svgr"; import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; @@ -20,6 +20,13 @@ export default defineConfig({ globals: true, // Removed browser configuration due to unsupported headless preview provider error setupFiles: ["./test/preSetup.js", "./test/setup.js"], + // The dom-compat spec is excluded from the default `npm test` run + // because it has preconditions (a production `dist/` build and the + // dynamically-installed `chat-components-baseline` alias) that only + // the dedicated dom-compat workflow / `npm run test:dom-compat` + // script arrange. vitest.dom-compat.config.ts narrows `include` to + // specifically that file for the dedicated run. + exclude: [...configDefaults.exclude, "test/layouts/dom-compat.spec.tsx"], css: { modules: { classNameStrategy: "non-scoped", diff --git a/vitest.dom-compat.config.ts b/vitest.dom-compat.config.ts new file mode 100644 index 00000000..25ddd3dc --- /dev/null +++ b/vitest.dom-compat.config.ts @@ -0,0 +1,41 @@ +/** + * Dedicated Vitest config for the DOM-compatibility spec. + * + * The main vite.config.ts excludes `test/layouts/dom-compat.spec.tsx` + * from `npm test` because it has preconditions (a dist/ build and the + * dynamically-installed `chat-components-baseline` alias) that only the + * dom-compat workflow arranges. This config narrows `include` to that one + * file and overrides `exclude` back to Vitest's default so the spec runs. + * + * We can't use `mergeConfig` with the base config because mergeConfig + * concatenates arrays — the base config's exclude would keep the dom-compat + * spec excluded. Instead this file restates the few fields Vitest needs + * (plugins are a no-op at test-run time beyond react/svgr, which Vitest + * picks up from vite.config.ts via the shared resolve config below). + */ +import { defineConfig, configDefaults } from "vitest/config"; +import react from "@vitejs/plugin-react-swc"; +import svgr from "vite-plugin-svgr"; + +export default defineConfig({ + plugins: [react(), svgr()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test/preSetup.js", "./test/setup.js"], + include: ["test/layouts/dom-compat.spec.tsx"], + exclude: configDefaults.exclude, // Vitest's default — no dom-compat exclusion + css: { + modules: { + classNameStrategy: "non-scoped", + }, + }, + }, + resolve: { + alias: { + src: "/src", + test: "/test", + "react-player": "/test/__mocks__/react-player.tsx", + }, + }, +}); From 5b3b9385e2a43bf2cd33213903400eb0cee53b1d Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich Date: Wed, 22 Apr 2026 13:27:41 +0200 Subject: [PATCH 02/13] fix(chat-components): AB#131731 address review feedback on DOM compatibility workflow - eslint.config.js: add a `**/*.mjs` block declaring `globals.node`. Flat config ignores legacy `/* eslint-env node */` directives, so without this `console`/`process` in our install script tripped `no-undef` under CodeQL's ESLint pass (local `npm run lint` only scans .ts/.tsx so it missed this). - scripts/install-dom-compat-baseline.mjs: drop the stale `/* eslint-env node */` comment that no longer does anything. - .github/workflows/dom-compat.yml: drop the single-value matrix. With a matrix GitHub appends "(22.x)" to the PR-check title, which reads as if "22.x" were the release being compared against. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/dom-compat.yml | 11 ++++++----- eslint.config.js | 13 +++++++++++++ scripts/install-dom-compat-baseline.mjs | 1 - 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dom-compat.yml b/.github/workflows/dom-compat.yml index adc89580..7501d35e 100644 --- a/.github/workflows/dom-compat.yml +++ b/.github/workflows/dom-compat.yml @@ -18,15 +18,16 @@ jobs: name: DOM compatibility vs latest release runs-on: ubuntu-latest - strategy: - matrix: - node-version: [22.x] - + # No matrix — a single Node version is enough (this only validates + # that the rendered DOM hasn't regressed, which is independent of + # runtime version). Keeping it flat also avoids GitHub appending the + # matrix value (e.g. "(22.x)") to the PR-check title, which reads + # like the library release being compared against. steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: - node-version: ${{ matrix.node-version }} + node-version: 22.x cache: "npm" - run: npm ci # Dynamically installs the dist-tag `latest` of diff --git a/eslint.config.js b/eslint.config.js index 90c01370..9d0a135b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,6 +26,19 @@ export default [ // Base JS recommended rules (apply to all files) js.configs.recommended, + // Node scripts (.mjs). The base recommended config enables `no-undef`, + // which flags `console` / `process` / etc. unless Node globals are + // declared. Legacy `/* eslint-env node */` directives are ignored under + // flat config, so we declare the environment here instead. + { + files: ["**/*.mjs"], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, + // TypeScript + React Hooks + Accessibility + React Refresh rules { files: ["**/*.ts", "**/*.tsx"], diff --git a/scripts/install-dom-compat-baseline.mjs b/scripts/install-dom-compat-baseline.mjs index e56a14d1..f042d11b 100644 --- a/scripts/install-dom-compat-baseline.mjs +++ b/scripts/install-dom-compat-baseline.mjs @@ -1,4 +1,3 @@ -/* eslint-env node */ /** * Installs the latest published `@cognigy/chat-components` as the aliased * dev-dependency `chat-components-baseline`. Consumed by From a3d89bcbd64c9ad279af6ba32a340fd389313afe Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich Date: Wed, 22 Apr 2026 14:49:26 +0200 Subject: [PATCH 03/13] style(chat-components): AB#131731 apply prettier to dom-compat spec `npx prettier --check .` on CI flagged three whitespace issues in test/layouts/dom-compat.spec.tsx (arrow-body / call-args that fit on a single line). Ran `prettier --write` on the file to fix. No semantic changes; 29/29 dom-compat tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/layouts/dom-compat.spec.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/test/layouts/dom-compat.spec.tsx b/test/layouts/dom-compat.spec.tsx index e353a534..a9fb3767 100644 --- a/test/layouts/dom-compat.spec.tsx +++ b/test/layouts/dom-compat.spec.tsx @@ -111,8 +111,7 @@ import datepickerDisableWeekends from "../fixtures/datepicker/disableWeekends.js // omit `source` because the existing per-component specs accept whatever // shape the matcher needs; for Message-level rendering we must have a source // so the non-user / non-engagement branches flow as expected. -const asBot = (raw: unknown): IMessage => - ({ source: "bot", ...(raw as object) }) as IMessage; +const asBot = (raw: unknown): IMessage => ({ source: "bot", ...(raw as object) }) as IMessage; // Normalize HTML so that non-structural differences don't cause false // positives. We strip: @@ -144,10 +143,7 @@ function normalize(html: string): string { // mask react-tooltip / random uuid-ish ids seen in attribute values .replace(/tooltip-[A-Za-z0-9_-]+/g, "tooltip-__id__") // mask UUID v4-style ids (used by gallery subtitle/title/content ids) - .replace( - /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, - "__uuid__", - ) + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "__uuid__") // mask swiper auto-generated wrapper/container ids: `swiper-wrapper-` .replace(/swiper-wrapper-[0-9a-f]+/g, "swiper-wrapper-__id__") // canonicalize hashed CSS-module class names: @@ -242,9 +238,8 @@ describe(`DOM compatibility: branch vs @cognigy/chat-components@${baselineVersio }); describe("demo-page message tabs", () => { - it.each(demoCases)( - "$name — matches published release DOM", - ({ message }) => assertSameDom(message), + it.each(demoCases)("$name — matches published release DOM", ({ message }) => + assertSameDom(message), ); }); }); From 2c1e943a948d4ab64e838c6473487a38e1274d99 Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich Date: Tue, 28 Apr 2026 13:28:47 +0200 Subject: [PATCH 04/13] refactor(chat-components): AB#131731 extract dom-compat to standalone PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple the DOM-compatibility regression spec from the layout-variant work in PR #242 (closed) so it can land independently against main. - Move test/layouts/dom-compat.spec.tsx → test/dom-compat.spec.tsx and remove the now-empty test/layouts/ directory; update path references in vite.config.ts, vitest.dom-compat.config.ts and the spec docstring. - Bring along the small test/fixtures/layout-messages.ts helper (the spec's only layout-PR-derived dependency — pure fixture data, no runtime coupling). - Add tsconfig.json `exclude` for test/dom-compat.spec.tsx since the spec imports `../dist/chat-components.js` and `chat-components-baseline` which only exist after the dom-compat workflow's build + install steps. The previous test/layouts/ location was implicitly skipped by the `test/*.spec.tsx` include glob. - Drop the `` explicit-prop assertion. Without the layout prop on main, only the default render path remains. - Reword the spec preamble + dom-compat workflow comment so neither references PR #242 / "default webchat layout"; the check now stands on its own as a generic Message DOM backward-compatibility guard. Local verification: lint, tsc, `npm run test` (121/121), `npm run test:dom-compat:install-baseline`, `npm run build`, `npm run test:dom-compat` (28/28) all pass. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/dom-compat.yml | 2 +- scripts/install-dom-compat-baseline.mjs | 4 +- test/{layouts => }/dom-compat.spec.tsx | 84 ++++++++++++------------- test/fixtures/layout-messages.ts | 66 +++++++++++++++++++ tsconfig.json | 3 +- vite.config.ts | 2 +- vitest.dom-compat.config.ts | 4 +- 7 files changed, 114 insertions(+), 51 deletions(-) rename test/{layouts => }/dom-compat.spec.tsx (78%) create mode 100644 test/fixtures/layout-messages.ts diff --git a/.github/workflows/dom-compat.yml b/.github/workflows/dom-compat.yml index 7501d35e..aa5e9cc5 100644 --- a/.github/workflows/dom-compat.yml +++ b/.github/workflows/dom-compat.yml @@ -5,7 +5,7 @@ name: DOM Compatibility # every supported message type on this branch and compares the DOM against # the same type rendered by the latest published @cognigy/chat-components # release. A failure means the branch would break backward compatibility -# for consumers of the default webchat layout. +# for consumers of the Message component's DOM contract. on: push: diff --git a/scripts/install-dom-compat-baseline.mjs b/scripts/install-dom-compat-baseline.mjs index f042d11b..4c0bbadb 100644 --- a/scripts/install-dom-compat-baseline.mjs +++ b/scripts/install-dom-compat-baseline.mjs @@ -1,8 +1,8 @@ /** * Installs the latest published `@cognigy/chat-components` as the aliased * dev-dependency `chat-components-baseline`. Consumed by - * `test/layouts/dom-compat.spec.tsx`, which compares the current branch's - * built DOM output against the last public release. + * `test/dom-compat.spec.tsx`, which compares the current branch's built + * DOM output against the last public release. * * Why a script instead of a pinned devDependency? * A pinned version would drift as releases happen; we'd have to bump diff --git a/test/layouts/dom-compat.spec.tsx b/test/dom-compat.spec.tsx similarity index 78% rename from test/layouts/dom-compat.spec.tsx rename to test/dom-compat.spec.tsx index a9fb3767..1703cec2 100644 --- a/test/layouts/dom-compat.spec.tsx +++ b/test/dom-compat.spec.tsx @@ -1,7 +1,7 @@ /** - * DOM compatibility: default webchat layout on this branch must render DOM - * identical to the latest published library release (@cognigy/chat-components - * installed dynamically as the `chat-components-baseline` alias by + * DOM compatibility: this branch's output must render DOM identical + * to the latest published library release (@cognigy/chat-components installed + * dynamically as the `chat-components-baseline` alias by * scripts/install-dom-compat-baseline.mjs — see that script for the why). * * Compares BUILT artifact vs BUILT artifact to avoid false positives caused @@ -30,7 +30,7 @@ * PRECONDITIONS: * - `npm run test:dom-compat:install-baseline` has installed the * `chat-components-baseline` alias (latest published release). - * - `npm run build` has produced `../../dist/chat-components.js`. + * - `npm run build` has produced `../dist/chat-components.js`. * The CI workflow wires both steps before invoking `npm run test:dom-compat`. * * The test renders the same fixtures through from both packages @@ -41,16 +41,16 @@ * away before comparing because they are not part of the structural * contract. * - * If this test fails, the default (no layout prop / `layout="webchat"`) - * render path on this branch has diverged from the published release and the - * backward-compatibility claim of PR #242 is broken. + * If this test fails, the render path on this branch has diverged + * from the published release — backward compatibility for consumers of the + * Message DOM contract is broken. */ import { render } from "@testing-library/react"; import { describe, it, expect } from "vitest"; // Import from the branch's built dist/, not src/, so CSS-module resolution // matches the baseline package (which is also a dist/ bundle). See preamble. -import { Message as CurrentMessage } from "../../dist/chat-components.js"; +import { Message as CurrentMessage } from "../dist/chat-components.js"; import { Message as BaselineMessage } from "chat-components-baseline"; // Read the installed baseline's version so the describe block / failure // messages show exactly which release we compared against. The baseline's @@ -62,7 +62,7 @@ import { dirname, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const baselineVersion: string = JSON.parse( readFileSync( - resolve(__dirname, "../../node_modules/chat-components-baseline/package.json"), + resolve(__dirname, "../node_modules/chat-components-baseline/package.json"), "utf8", ), ).version; @@ -74,7 +74,7 @@ import { engagementTextMessage, richBotMessage, quickRepliesBotMessage, -} from "../fixtures/layout-messages"; +} from "./fixtures/layout-messages"; import type { IMessage } from "@cognigy/socket-client"; // ---- demo-page fixtures ---- @@ -84,28 +84,28 @@ import type { IMessage } from "@cognigy/socket-client"; // setup like collation or streaming animation). Fixtures that don't ship with // a `source` field are given `"bot"` at rendering time — the published // baseline and the branch both follow the same rule so the comparison still holds. -import imageFixture from "../fixtures/image.json"; -import imageDownloadableFixture from "../fixtures/image-downloadable.json"; -import imageBrokenFixture from "../fixtures/imageBroken.json"; -import videoFixture from "../fixtures/video.json"; -import videoYoutubeFixture from "../fixtures/videoYoutube.json"; -import videoAltTextFixture from "../fixtures/videoWithAltText.json"; -import audioFixture from "../fixtures/audio.json"; -import fileFixture from "../fixtures/file.json"; -import listFixture from "../fixtures/list.json"; -import galleryFixture from "../fixtures/gallery.json"; -import galleryNullButtonsFixture from "../fixtures/gallery-with-null-buttons.json"; -import actionButtonsFixture from "../fixtures/action-buttons.json"; -import adaptiveCardsFixture from "../fixtures/adaptiveCards.json"; -import webchat3EventFixture from "../fixtures/webchat3Event.json"; -import datepickerSingleDate from "../fixtures/datepicker/singleDate.json"; -import datepickerMinMax from "../fixtures/datepicker/singleDateWithMinMax.json"; -import datepickerMultiple from "../fixtures/datepicker/multiple.json"; -import datepickerRange from "../fixtures/datepicker/range.json"; -import datepickerWeeks from "../fixtures/datepicker/weekNumbers.json"; -import datepickerNoTime from "../fixtures/datepicker/noTime.json"; -import datepickerTimeOnly from "../fixtures/datepicker/timeOnly.json"; -import datepickerDisableWeekends from "../fixtures/datepicker/disableWeekends.json"; +import imageFixture from "./fixtures/image.json"; +import imageDownloadableFixture from "./fixtures/image-downloadable.json"; +import imageBrokenFixture from "./fixtures/imageBroken.json"; +import videoFixture from "./fixtures/video.json"; +import videoYoutubeFixture from "./fixtures/videoYoutube.json"; +import videoAltTextFixture from "./fixtures/videoWithAltText.json"; +import audioFixture from "./fixtures/audio.json"; +import fileFixture from "./fixtures/file.json"; +import listFixture from "./fixtures/list.json"; +import galleryFixture from "./fixtures/gallery.json"; +import galleryNullButtonsFixture from "./fixtures/gallery-with-null-buttons.json"; +import actionButtonsFixture from "./fixtures/action-buttons.json"; +import adaptiveCardsFixture from "./fixtures/adaptiveCards.json"; +import webchat3EventFixture from "./fixtures/webchat3Event.json"; +import datepickerSingleDate from "./fixtures/datepicker/singleDate.json"; +import datepickerMinMax from "./fixtures/datepicker/singleDateWithMinMax.json"; +import datepickerMultiple from "./fixtures/datepicker/multiple.json"; +import datepickerRange from "./fixtures/datepicker/range.json"; +import datepickerWeeks from "./fixtures/datepicker/weekNumbers.json"; +import datepickerNoTime from "./fixtures/datepicker/noTime.json"; +import datepickerTimeOnly from "./fixtures/datepicker/timeOnly.json"; +import datepickerDisableWeekends from "./fixtures/datepicker/disableWeekends.json"; // Cast + default source helper. Fixtures are stored as plain JSON and some // omit `source` because the existing per-component specs accept whatever @@ -156,9 +156,9 @@ function normalize(html: string): string { type Case = { name: string; message: IMessage }; -// Core layout / source fixtures. These exercise the Message/Header/Body -// structural contract across every MessageSender variant plus the two plugin -// payload shapes that exist in the layout-messages test helper. +// Core source fixtures. These exercise the Message/Header/Body structural +// contract across every MessageSender variant plus the two plugin payload +// shapes that exist in the layout-messages test helper. const cases: Case[] = [ { name: "bot text message", message: botTextMessage }, { name: "user text message", message: userTextMessage }, @@ -210,9 +210,9 @@ const demoCases: Case[] = [ // Shared assertion helper: render the same message through both packages, // normalize the HTML, compare. Extracted so every case reads the same. -function assertSameDom(message: IMessage, props: Partial<{ layout: "webchat" }> = {}) { +function assertSameDom(message: IMessage) { const { container: current, unmount: unmountCurrent } = render( - , + , ); const currentHtml = normalize(current.innerHTML); unmountCurrent(); @@ -227,14 +227,10 @@ function assertSameDom(message: IMessage, props: Partial<{ layout: "webchat" }> } describe(`DOM compatibility: branch vs @cognigy/chat-components@${baselineVersion}`, () => { - describe("core layout fixtures", () => { - it.each(cases)( - "$name — default layout matches published release DOM", - ({ message }) => assertSameDom(message), + describe("core source fixtures", () => { + it.each(cases)("$name — matches published release DOM", ({ message }) => + assertSameDom(message), ); - - it(' explicit prop also matches baseline', () => - assertSameDom(botTextMessage, { layout: "webchat" })); }); describe("demo-page message tabs", () => { diff --git a/test/fixtures/layout-messages.ts b/test/fixtures/layout-messages.ts new file mode 100644 index 00000000..abb8b2f2 --- /dev/null +++ b/test/fixtures/layout-messages.ts @@ -0,0 +1,66 @@ +import { IMessage } from "@cognigy/socket-client"; + +export const botTextMessage: IMessage = { + text: "Hello from bot", + source: "bot", +} as IMessage; + +export const userTextMessage: IMessage = { + text: "Hello from user", + source: "user", +} as IMessage; + +export const agentTextMessage: IMessage = { + text: "Hello from agent", + source: "agent", +} as IMessage; + +export const engagementTextMessage: IMessage = { + text: "Engagement message", + source: "engagement", +} as IMessage; + +// Models a generic / carousel webchat gallery payload. The Gallery matcher +// (src/matcher.ts) reads getChannelPayload(...).message.attachment.payload and +// requires template_type === "generic". `_webchat` works without config; +// `_defaultPreview` would require widgetSettings.enableDefaultPreview. +export const richBotMessage: IMessage = { + source: "bot", + data: { + _cognigy: { + _webchat: { + message: { + attachment: { + type: "template", + payload: { + template_type: "generic", + elements: [ + { title: "Item A", subtitle: "Sub A", image_url: "" }, + { title: "Item B", subtitle: "Sub B", image_url: "" }, + ], + }, + }, + }, + }, + }, + }, +} as unknown as IMessage; + +// Quick-replies webchat payload. The matcher routes this to TextWithButtons +// based on the presence of quick_replies[] on the _webchat message. +export const quickRepliesBotMessage: IMessage = { + source: "bot", + data: { + _cognigy: { + _webchat: { + message: { + text: "Pick one", + quick_replies: [ + { title: "Yes", payload: "yes", content_type: "text" }, + { title: "No", payload: "no", content_type: "text" }, + ], + }, + }, + }, + }, +} as unknown as IMessage; diff --git a/tsconfig.json b/tsconfig.json index 1ccbb873..d5639c2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,6 @@ "test/*": ["./test/*"] } }, - "include": ["src", "test/*.spec.tsx", "test/demo.tsx"] + "include": ["src", "test/*.spec.tsx", "test/demo.tsx"], + "exclude": ["test/dom-compat.spec.tsx"] } diff --git a/vite.config.ts b/vite.config.ts index dd795930..19404ba1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ // the dedicated dom-compat workflow / `npm run test:dom-compat` // script arrange. vitest.dom-compat.config.ts narrows `include` to // specifically that file for the dedicated run. - exclude: [...configDefaults.exclude, "test/layouts/dom-compat.spec.tsx"], + exclude: [...configDefaults.exclude, "test/dom-compat.spec.tsx"], css: { modules: { classNameStrategy: "non-scoped", diff --git a/vitest.dom-compat.config.ts b/vitest.dom-compat.config.ts index 25ddd3dc..0d41db1d 100644 --- a/vitest.dom-compat.config.ts +++ b/vitest.dom-compat.config.ts @@ -1,7 +1,7 @@ /** * Dedicated Vitest config for the DOM-compatibility spec. * - * The main vite.config.ts excludes `test/layouts/dom-compat.spec.tsx` + * The main vite.config.ts excludes `test/dom-compat.spec.tsx` * from `npm test` because it has preconditions (a dist/ build and the * dynamically-installed `chat-components-baseline` alias) that only the * dom-compat workflow arranges. This config narrows `include` to that one @@ -23,7 +23,7 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./test/preSetup.js", "./test/setup.js"], - include: ["test/layouts/dom-compat.spec.tsx"], + include: ["test/dom-compat.spec.tsx"], exclude: configDefaults.exclude, // Vitest's default — no dom-compat exclusion css: { modules: { From 0f2fed46477924772694c08d862ab53cdb1832ed Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich Date: Tue, 28 Apr 2026 13:50:35 +0200 Subject: [PATCH 05/13] fix(chat-components): AB#131731 plug vacuous engagement DOM-compat case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'engagement message' case was passing without exercising any DOM. matcher.ts gates engagement-source messages behind `settings.teaserMessage.showInChat`; without that config flag both the current branch and the baseline render `null`, so the comparison collapses to empty === empty and any DOM regression in the engagement render path would go undetected. Verified by perturbing src/messages/Message.tsx with a sentinel attribute on `
` and confirming the case fires (previously 27/28 caught the perturbation, now 28/28). Two-part fix: - Pass `config={ settings: { teaserMessage: { showInChat: true } } }` for the engagement case so the matcher actually selects a plugin. `assertSameDom` now forwards an optional `config` prop to both branches so they render symmetrically. - Add `expect(currentHtml).not.toBe("")` so any future fixture that silently fails to match a plugin trips a loud assertion instead of passing on emptiness. Cheap belt-and-braces against the same trap. No production code changes. Verification: - `npm run test:dom-compat` — 28/28 pass clean. - Perturbation experiment — 28/28 fail (engagement included). - `npm run test` — 121/121, `tsc --noEmit`, `lint` all clean. Co-Authored-By: Claude Opus 4.7 --- test/dom-compat.spec.tsx | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/test/dom-compat.spec.tsx b/test/dom-compat.spec.tsx index 1703cec2..9e4bca9c 100644 --- a/test/dom-compat.spec.tsx +++ b/test/dom-compat.spec.tsx @@ -154,7 +154,19 @@ function normalize(html: string): string { ); } -type Case = { name: string; message: IMessage }; +// Optional `config` is forwarded as the prop. Used to +// unlock matcher branches that are gated behind widgetSettings — without it, +// the matcher early-returns and renders null, which would make the +// comparison trivially pass (empty === empty). The non-empty-render guard in +// assertSameDom catches such silent no-ops. +type Case = { name: string; message: IMessage; config?: unknown }; + +// Engagement teaser config: matcher.ts gates engagement-source messages +// behind `settings.teaserMessage.showInChat`. Without it, both renders +// resolve to null and the case becomes vacuous coverage. +const engagementConfig = { + settings: { teaserMessage: { showInChat: true } }, +}; // Core source fixtures. These exercise the Message/Header/Body structural // contract across every MessageSender variant plus the two plugin payload @@ -163,7 +175,7 @@ const cases: Case[] = [ { name: "bot text message", message: botTextMessage }, { name: "user text message", message: userTextMessage }, { name: "agent text message", message: agentTextMessage }, - { name: "engagement message", message: engagementTextMessage }, + { name: "engagement message", message: engagementTextMessage, config: engagementConfig }, { name: "bot gallery (generic template)", message: richBotMessage }, { name: "bot quick replies", message: quickRepliesBotMessage }, ]; @@ -209,33 +221,38 @@ const demoCases: Case[] = [ ]; // Shared assertion helper: render the same message through both packages, -// normalize the HTML, compare. Extracted so every case reads the same. -function assertSameDom(message: IMessage) { +// normalize the HTML, compare. Also asserts the rendered HTML is non-empty +// — without this guard, a fixture that silently fails to match any plugin +// would produce empty === empty and pass without exercising any DOM. +function assertSameDom(message: IMessage, config?: unknown) { + const configProp = config as React.ComponentProps["config"]; + const { container: current, unmount: unmountCurrent } = render( - , + , ); const currentHtml = normalize(current.innerHTML); unmountCurrent(); const { container: baseline, unmount: unmountBaseline } = render( - , + , ); const baselineHtml = normalize(baseline.innerHTML); unmountBaseline(); + expect(currentHtml).not.toBe(""); expect(currentHtml).toBe(baselineHtml); } describe(`DOM compatibility: branch vs @cognigy/chat-components@${baselineVersion}`, () => { describe("core source fixtures", () => { - it.each(cases)("$name — matches published release DOM", ({ message }) => - assertSameDom(message), + it.each(cases)("$name — matches published release DOM", ({ message, config }) => + assertSameDom(message, config), ); }); describe("demo-page message tabs", () => { - it.each(demoCases)("$name — matches published release DOM", ({ message }) => - assertSameDom(message), + it.each(demoCases)("$name — matches published release DOM", ({ message, config }) => + assertSameDom(message, config), ); }); }); From 15bdbf76463663c24091fd50e304f80cab76eff4 Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich Date: Tue, 28 Apr 2026 20:56:33 +0200 Subject: [PATCH 06/13] test(chat-components): AB#131731 expand DOM-compat coverage to all message types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls in every demo tab the spec previously skipped behind the "requires widgetSettings.config injection" excuse and renames the fixture helper to drop the layout-PR vestige. Coverage growth: 28 → 40 cases (12 new). Each new case was verified end-to-end by reintroducing the perturbation experiment from commit 0f2fed4 (sentinel attribute on `
`) — all 40/40 cases now fail under that perturbation, confirming none of the additions is a vacuous empty-vs-empty pass. New cases (each cited from test/demo.tsx): - Adaptive Cards [1] and [2] (was just [0]). - Default Preview x2 — exercises the `enableDefaultPreview` branch. Both fixtures encode "RENDER OK" in `_defaultPreview` and "RENDER WRONG" in `_webchat`, so a regression that flipped channel selection fails the comparison. - xApp Buttons x2 — quick-reply pill (`_default._quickReplies` + `_webchat.quick_replies` with `content_type: "openXApp"`) and template button (`attachment.template_type: "button"` with `type: "openXApp"`). - HTML Sanitization x3 — default tags, `customAllowedHtmlTags: ["p", "strong"]`, and `disableHtmlContentSanitization: true`. Default-config case is already covered by `bot text message`. - Markdown text + borderless text — exercises the `renderMarkdown` and `disableBotOutputBorder` branches inside Text.tsx. - Collated bot follow-up with `prevMessage` — header-suppression path through the matcher's collation rules. Mechanical changes: - `Case` type gains optional `prevMessage`; `assertSameDom` forwards it to both renders symmetrically. - Per-tab widget configs (`defaultPreviewConfig`, `customAllowedTagsConfig`, `sanitizationDisabledConfig`, `renderMarkdownConfig`, `disableBorderConfig`) extracted as named constants rather than inlined per case. - Spec docstring updated to drop the now-obsolete skip list — only "UI Components" (not Message-rendered) and "Streaming messages" (animationState changes DOM over time) remain genuinely out of reach. File rename: `test/fixtures/layout-messages.ts` → `test/fixtures/messages.ts`. The "layout-" prefix was a vestige of the closed PR #242 layout-variant work and read oddly here. New fixtures live alongside the existing source-variant + plugin-payload exports in the renamed file. Verification: tsc --noEmit clean, lint clean, `npm run test` 121/121, `npm run test:dom-compat` 40/40, perturbation experiment 40/40 fail. Co-Authored-By: Claude Opus 4.7 --- test/dom-compat.spec.tsx | 141 ++++++++++++++++--- test/fixtures/layout-messages.ts | 66 --------- test/fixtures/messages.ts | 228 +++++++++++++++++++++++++++++++ 3 files changed, 348 insertions(+), 87 deletions(-) delete mode 100644 test/fixtures/layout-messages.ts create mode 100644 test/fixtures/messages.ts diff --git a/test/dom-compat.spec.tsx b/test/dom-compat.spec.tsx index 9e4bca9c..ade7ce94 100644 --- a/test/dom-compat.spec.tsx +++ b/test/dom-compat.spec.tsx @@ -74,7 +74,18 @@ import { engagementTextMessage, richBotMessage, quickRepliesBotMessage, -} from "./fixtures/layout-messages"; + defaultPreviewQuickReplies, + defaultPreviewText, + xAppQuickReply, + xAppButton, + sanitizedHtmlMessage, + sanitizedCustomTagsMessage, + sanitizationDisabledMessage, + markdownText, + borderlessText, + collatedFollowupMessage, + collatedPrevMessage, +} from "./fixtures/messages"; import type { IMessage } from "@cognigy/socket-client"; // ---- demo-page fixtures ---- @@ -159,15 +170,52 @@ function normalize(html: string): string { // the matcher early-returns and renders null, which would make the // comparison trivially pass (empty === empty). The non-empty-render guard in // assertSameDom catches such silent no-ops. -type Case = { name: string; message: IMessage; config?: unknown }; +// +// Optional `prevMessage` participates in collation: matcher / collation rules +// suppress the header on follow-up messages from the same source within a +// short timestamp window. +type Case = { + name: string; + message: IMessage; + config?: unknown; + prevMessage?: IMessage; +}; + +// Per-tab widgetSettings configs. Source: test/demo.tsx — keep in sync if +// the demo's config shape moves. -// Engagement teaser config: matcher.ts gates engagement-source messages -// behind `settings.teaserMessage.showInChat`. Without it, both renders -// resolve to null and the case becomes vacuous coverage. +// Engagement teaser: matcher.ts gates engagement-source messages behind +// `settings.teaserMessage.showInChat`. Without it, both renders resolve to +// null and the case becomes vacuous coverage. const engagementConfig = { settings: { teaserMessage: { showInChat: true } }, }; +// Default Preview: matcher.ts routes messages with `_defaultPreview` payload +// to that channel only when this flag is set; otherwise it falls back to the +// `_webchat` payload. Both demo Default-Preview fixtures encode "RENDER OK" +// in `_defaultPreview` and "RENDER WRONG" in `_webchat` so the assertion +// catches a regression that flipped the channel selection. +const defaultPreviewConfig = { + settings: { widgetSettings: { enableDefaultPreview: true } }, +}; + +// HTML sanitization variants from the demo's `HTML Sanitization` tab. +const customAllowedTagsConfig = { + settings: { widgetSettings: { customAllowedHtmlTags: ["p", "strong"] } }, +}; +const sanitizationDisabledConfig = { + settings: { layout: { disableHtmlContentSanitization: true } }, +}; + +// Markdown / layout-flag text variants from the `Text messages` tab. +const renderMarkdownConfig = { + settings: { behavior: { renderMarkdown: true } }, +}; +const disableBorderConfig = { + settings: { layout: { disableBotOutputBorder: true } }, +}; + // Core source fixtures. These exercise the Message/Header/Body structural // contract across every MessageSender variant plus the two plugin payload // shapes that exist in the layout-messages test helper. @@ -181,12 +229,12 @@ const cases: Case[] = [ ]; // Demo-page coverage. One case per demo tab where the tab renders via -// and is not inherently time- or animation-dependent. Skipped -// tabs: "UI Components" (renders ActionButtons/Typography/ChatEvent directly, -// not via ), "Message Collation" (depends on prevMessage chaining), -// "Streaming messages with markdown" (animationState changes DOM over time), -// "Default Preview" + "HTML Sanitization" + "xApp Buttons" (require specific -// widgetSettings.config injection that isn't trivially picked up from JSON). +// . Skipped: +// - "UI Components" — renders ActionButtons / Typography / ChatEvent +// directly, not through . +// - "Streaming messages with markdown" — animationState transitions +// ("start" / "animating" / "done") change the DOM over time, so a static +// comparison would be flaky. const demoCases: Case[] = [ // Multimedia { name: "demo: image", message: asBot(imageFixture) }, @@ -211,12 +259,61 @@ const demoCases: Case[] = [ { name: "demo: datepicker no time", message: asBot(datepickerNoTime) }, { name: "demo: datepicker time only", message: asBot(datepickerTimeOnly) }, { name: "demo: datepicker disable weekends", message: asBot(datepickerDisableWeekends) }, - // Misc - // adaptiveCards fixture is an array — first entry is representative. + // Adaptive Cards — fixture is an array; cover all three indices since + // they exercise different card payload shapes. { - name: "demo: adaptive cards (first)", + name: "demo: adaptive cards [0]", message: asBot((adaptiveCardsFixture as unknown as object[])[0]), }, + { + name: "demo: adaptive cards [1]", + message: asBot((adaptiveCardsFixture as unknown as object[])[1]), + }, + { + name: "demo: adaptive cards [2]", + message: asBot((adaptiveCardsFixture as unknown as object[])[2]), + }, + // Default Preview — gated by widgetSettings.enableDefaultPreview; both + // fixtures contrast `_defaultPreview` ("RENDER OK") against `_webchat` + // ("RENDER WRONG") so the assertion catches a regression that flipped + // the channel selection. + { + name: "demo: default preview (quick replies)", + message: defaultPreviewQuickReplies, + config: defaultPreviewConfig, + }, + { + name: "demo: default preview (text)", + message: defaultPreviewText, + config: defaultPreviewConfig, + }, + // xApp Buttons — both shapes route through the matcher's openXApp branch. + { name: "demo: xApp button (quick reply)", message: xAppQuickReply }, + { name: "demo: xApp button (template)", message: xAppButton }, + // HTML Sanitization — default config is already covered by `bot text + // message`; these exercise the customAllowedHtmlTags / disableHtmlContent + // Sanitization branches. + { name: "demo: sanitized html (default tags)", message: sanitizedHtmlMessage }, + { + name: "demo: sanitized html (custom allowed tags)", + message: sanitizedCustomTagsMessage, + config: customAllowedTagsConfig, + }, + { + name: "demo: sanitization disabled", + message: sanitizationDisabledMessage, + config: sanitizationDisabledConfig, + }, + // Markdown / layout-flag text variants from the `Text messages` tab. + { name: "demo: markdown text", message: markdownText, config: renderMarkdownConfig }, + { name: "demo: borderless text", message: borderlessText, config: disableBorderConfig }, + // Message Collation — header suppression depends on `prevMessage`. This + // case reproduces the demo's "bot follows bot within window" scenario. + { + name: "demo: collated bot follow-up (no header)", + message: collatedFollowupMessage, + prevMessage: collatedPrevMessage, + }, { name: "demo: webchat3 event", message: asBot(webchat3EventFixture) }, ]; @@ -224,17 +321,17 @@ const demoCases: Case[] = [ // normalize the HTML, compare. Also asserts the rendered HTML is non-empty // — without this guard, a fixture that silently fails to match any plugin // would produce empty === empty and pass without exercising any DOM. -function assertSameDom(message: IMessage, config?: unknown) { +function assertSameDom(message: IMessage, config?: unknown, prevMessage?: IMessage) { const configProp = config as React.ComponentProps["config"]; const { container: current, unmount: unmountCurrent } = render( - , + , ); const currentHtml = normalize(current.innerHTML); unmountCurrent(); const { container: baseline, unmount: unmountBaseline } = render( - , + , ); const baselineHtml = normalize(baseline.innerHTML); unmountBaseline(); @@ -245,14 +342,16 @@ function assertSameDom(message: IMessage, config?: unknown) { describe(`DOM compatibility: branch vs @cognigy/chat-components@${baselineVersion}`, () => { describe("core source fixtures", () => { - it.each(cases)("$name — matches published release DOM", ({ message, config }) => - assertSameDom(message, config), + it.each(cases)( + "$name — matches published release DOM", + ({ message, config, prevMessage }) => assertSameDom(message, config, prevMessage), ); }); describe("demo-page message tabs", () => { - it.each(demoCases)("$name — matches published release DOM", ({ message, config }) => - assertSameDom(message, config), + it.each(demoCases)( + "$name — matches published release DOM", + ({ message, config, prevMessage }) => assertSameDom(message, config, prevMessage), ); }); }); diff --git a/test/fixtures/layout-messages.ts b/test/fixtures/layout-messages.ts deleted file mode 100644 index abb8b2f2..00000000 --- a/test/fixtures/layout-messages.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { IMessage } from "@cognigy/socket-client"; - -export const botTextMessage: IMessage = { - text: "Hello from bot", - source: "bot", -} as IMessage; - -export const userTextMessage: IMessage = { - text: "Hello from user", - source: "user", -} as IMessage; - -export const agentTextMessage: IMessage = { - text: "Hello from agent", - source: "agent", -} as IMessage; - -export const engagementTextMessage: IMessage = { - text: "Engagement message", - source: "engagement", -} as IMessage; - -// Models a generic / carousel webchat gallery payload. The Gallery matcher -// (src/matcher.ts) reads getChannelPayload(...).message.attachment.payload and -// requires template_type === "generic". `_webchat` works without config; -// `_defaultPreview` would require widgetSettings.enableDefaultPreview. -export const richBotMessage: IMessage = { - source: "bot", - data: { - _cognigy: { - _webchat: { - message: { - attachment: { - type: "template", - payload: { - template_type: "generic", - elements: [ - { title: "Item A", subtitle: "Sub A", image_url: "" }, - { title: "Item B", subtitle: "Sub B", image_url: "" }, - ], - }, - }, - }, - }, - }, - }, -} as unknown as IMessage; - -// Quick-replies webchat payload. The matcher routes this to TextWithButtons -// based on the presence of quick_replies[] on the _webchat message. -export const quickRepliesBotMessage: IMessage = { - source: "bot", - data: { - _cognigy: { - _webchat: { - message: { - text: "Pick one", - quick_replies: [ - { title: "Yes", payload: "yes", content_type: "text" }, - { title: "No", payload: "no", content_type: "text" }, - ], - }, - }, - }, - }, -} as unknown as IMessage; diff --git a/test/fixtures/messages.ts b/test/fixtures/messages.ts new file mode 100644 index 00000000..91f7fe55 --- /dev/null +++ b/test/fixtures/messages.ts @@ -0,0 +1,228 @@ +import { IMessage } from "@cognigy/socket-client"; + +// ----- Source-variant text messages ----- + +export const botTextMessage: IMessage = { + text: "Hello from bot", + source: "bot", +} as IMessage; + +export const userTextMessage: IMessage = { + text: "Hello from user", + source: "user", +} as IMessage; + +export const agentTextMessage: IMessage = { + text: "Hello from agent", + source: "agent", +} as IMessage; + +export const engagementTextMessage: IMessage = { + text: "Engagement message", + source: "engagement", +} as IMessage; + +// ----- Plugin payload shapes ----- + +// Models a generic / carousel webchat gallery payload. The Gallery matcher +// (src/matcher.ts) reads getChannelPayload(...).message.attachment.payload and +// requires template_type === "generic". `_webchat` works without config; +// `_defaultPreview` would require widgetSettings.enableDefaultPreview. +export const richBotMessage: IMessage = { + source: "bot", + data: { + _cognigy: { + _webchat: { + message: { + attachment: { + type: "template", + payload: { + template_type: "generic", + elements: [ + { title: "Item A", subtitle: "Sub A", image_url: "" }, + { title: "Item B", subtitle: "Sub B", image_url: "" }, + ], + }, + }, + }, + }, + }, + }, +} as unknown as IMessage; + +// Quick-replies webchat payload. The matcher routes this to TextWithButtons +// based on the presence of quick_replies[] on the _webchat message. +export const quickRepliesBotMessage: IMessage = { + source: "bot", + data: { + _cognigy: { + _webchat: { + message: { + text: "Pick one", + quick_replies: [ + { title: "Yes", payload: "yes", content_type: "text" }, + { title: "No", payload: "no", content_type: "text" }, + ], + }, + }, + }, + }, +} as unknown as IMessage; + +// ----- Default Preview payloads ----- +// Replicate the `_defaultPreview` cases from test/demo.tsx so the matcher's +// `enableDefaultPreview` branch is exercised. Both fixtures ship a contrasting +// `_webchat` payload so a regression that causes the wrong channel to render +// would fail the comparison even after class-name canonicalization. + +export const defaultPreviewQuickReplies: IMessage = { + source: "bot", + data: { + _cognigy: { + _defaultPreview: { + message: { + text: "RENDER OK", + quick_replies: [ + { + id: 0.44535334241574, + content_type: "postback", + payload: "preview-pb-1", + title: "Preview QR 1", + }, + ], + }, + }, + _webchat: { message: { text: "RENDER WRONG" } }, + }, + }, +} as unknown as IMessage; + +export const defaultPreviewText: IMessage = { + source: "bot", + data: { + _cognigy: { + _webchat: { message: { text: "RENDER WRONG" } }, + _defaultPreview: { message: { text: "RENDER OK" } }, + }, + }, +} as unknown as IMessage; + +// ----- xApp payloads ----- +// Replicate the `xApp Buttons` demo tab. Both shapes route through the +// matcher's `openXApp` content-type branch (quick-reply pill vs. button +// template) and share the openXApp payload type. + +export const xAppQuickReply: IMessage = { + source: "bot", + data: { + _cognigy: { + _default: { + _quickReplies: { + type: "quick_replies", + quickReplies: [ + { + id: 0.4782026154264929, + title: "Open xApp", + imageAltText: "", + imageUrl: "", + contentType: "openXApp", + payload: "https://static.test?testParam=TEST", + }, + ], + text: "Tap to open the xApp", + }, + }, + _webchat: { + message: { + text: "QR", + quick_replies: [ + { + content_type: "openXApp", + image_url: "", + image_alt_text: "", + payload: "https://static.test?testParam=TEST", + title: "Open xApp", + }, + ], + }, + }, + }, + }, +} as unknown as IMessage; + +export const xAppButton: IMessage = { + source: "bot", + data: { + _cognigy: { + _webchat: { + message: { + attachment: { + type: "template", + payload: { + text: "Button", + template_type: "button", + buttons: [ + { + title: "Open XApp Button", + type: "openXApp", + payload: "https://static.test?testParam=TEST", + }, + ], + }, + }, + }, + }, + }, + }, +} as unknown as IMessage; + +// ----- HTML sanitization payloads ----- +// One representative case per non-default sanitization config from the +// `HTML Sanitization` demo tab. Default (no config) is already covered by +// the `botTextMessage` baseline; these exercise the branches that consume +// `widgetSettings.customAllowedHtmlTags` / `layout.disableHtmlContentSanitization`. + +export const sanitizedHtmlMessage: IMessage = { + source: "bot", + text: "Default sanitization:

Paragraph

Bold Italic Link ", +} as IMessage; + +export const sanitizedCustomTagsMessage: IMessage = { + source: "bot", + text: "Custom allowed tags (only p, strong):

Paragraph

Bold Italic Link", +} as IMessage; + +export const sanitizationDisabledMessage: IMessage = { + source: "bot", + text: "Sanitization disabled:

Paragraph

Bold Italic Link", +} as IMessage; + +// ----- Markdown / layout-flag text payloads ----- +// Pulled from the `Text messages` tab so the renderMarkdown / layout flag +// branches inside Text.tsx are exercised at the DOM-compat layer too. + +export const markdownText: IMessage = { + source: "bot", + text: "## Heading\n\nA **bold** word and a [link](https://example.com).", +} as IMessage; + +export const borderlessText: IMessage = { + source: "bot", + text: "This message has the bot output border disabled.", +} as IMessage; + +// ----- Collation ----- +// `prevMessage` participates in the matcher's collation rules; this fixture +// pair (current + prev) reproduces the `Message Collation` demo's "bot +// follows bot" case where the second message renders without a header. + +export const collatedFollowupMessage: IMessage = { + text: "This message does not have a header (collated)", + source: "bot", + timestamp: "1701163319138", +} as IMessage; + +export const collatedPrevMessage: IMessage = { + source: "bot", + timestamp: "1701163314138", +} as IMessage; From 198bc249367f0f9a5ae3b1aec36fc970242140f1 Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich <90881+kwinto@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:03:12 +0200 Subject: [PATCH 07/13] Update test/dom-compat.spec.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/dom-compat.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/dom-compat.spec.tsx b/test/dom-compat.spec.tsx index ade7ce94..a3a084bb 100644 --- a/test/dom-compat.spec.tsx +++ b/test/dom-compat.spec.tsx @@ -218,7 +218,7 @@ const disableBorderConfig = { // Core source fixtures. These exercise the Message/Header/Body structural // contract across every MessageSender variant plus the two plugin payload -// shapes that exist in the layout-messages test helper. +// shapes defined in test/fixtures/messages.ts. const cases: Case[] = [ { name: "bot text message", message: botTextMessage }, { name: "user text message", message: userTextMessage }, From 6d3a2853e67b1e219da826c9f6e3e47cbeaa1c5d Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich <90881+kwinto@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:03:25 +0200 Subject: [PATCH 08/13] Update .github/workflows/dom-compat.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/dom-compat.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dom-compat.yml b/.github/workflows/dom-compat.yml index aa5e9cc5..4aa4c31b 100644 --- a/.github/workflows/dom-compat.yml +++ b/.github/workflows/dom-compat.yml @@ -25,7 +25,7 @@ jobs: # like the library release being compared against. steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 22.x cache: "npm" From 585ee278eec219856854336a6b86767826adc176 Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich <90881+kwinto@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:03:41 +0200 Subject: [PATCH 09/13] Update test/dom-compat.spec.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/dom-compat.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/dom-compat.spec.tsx b/test/dom-compat.spec.tsx index a3a084bb..ded4cba4 100644 --- a/test/dom-compat.spec.tsx +++ b/test/dom-compat.spec.tsx @@ -149,8 +149,8 @@ function normalize(html: string): string { .replace(/>\s+<") // trim leading/trailing whitespace .trim() - // mask React useId tokens like :r0:, :R1a:, :Rab: - .replace(/:[rR][0-9a-z]+:/g, ":__id__:") + // mask React useId tokens like :r0:, :R1a:, :Rab:, «r0», «R1a» + .replace(/(?::[rR][0-9a-z]+:|«[rR][0-9a-z]+»)/g, "__id__") // mask react-tooltip / random uuid-ish ids seen in attribute values .replace(/tooltip-[A-Za-z0-9_-]+/g, "tooltip-__id__") // mask UUID v4-style ids (used by gallery subtitle/title/content ids) From 8fcac321ab09590eafb90e897e54543ade6fec99 Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich <90881+kwinto@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:04:03 +0200 Subject: [PATCH 10/13] Update scripts/install-dom-compat-baseline.mjs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/install-dom-compat-baseline.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install-dom-compat-baseline.mjs b/scripts/install-dom-compat-baseline.mjs index 4c0bbadb..2c02b5de 100644 --- a/scripts/install-dom-compat-baseline.mjs +++ b/scripts/install-dom-compat-baseline.mjs @@ -87,7 +87,7 @@ function main() { (installed ? ` (replacing ${installed})` : "") + "...", ); - run(`npm install --no-save --no-audit --no-fund ${ALIAS}@npm:${PKG_NAME}@${latest}`, { + run(`npm install --no-save --no-package-lock --no-audit --no-fund ${ALIAS}@npm:${PKG_NAME}@${latest}`, { stdio: "inherit", }); console.log(`[dom-compat] done.`); From f654b01edb20ac3d446222e2414a8df69b383e6f Mon Sep 17 00:00:00 2001 From: Dmitrii Ostasevich Date: Thu, 30 Apr 2026 14:50:36 +0200 Subject: [PATCH 11/13] fix(chat-components): AB#131731 address remaining Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five small follow-ups after the four web-UI suggestion accepts. All local checks clean (40/40 dom-compat including post-fix perturbation sweep, 121/121 unit tests, tsc, lint, prettier). scripts/install-dom-compat-baseline.mjs - Re-wrap the long `npm install` command across multiple lines so prettier-check on CI passes (the `Format` workflow was failing on this single line after the `--no-package-lock` accept). vitest.dom-compat.config.ts - Reword the header comment that claimed Vitest "picks up plugins from vite.config.ts via the resolve config." That's not how `vitest --config` works — this file must restate every field Vitest needs (plugins, environment, setup files, css modules, resolve aliases). Comment now reflects that. test/dom-compat.spec.tsx - normalize(): tighten the inter-tag whitespace strip from `>\s+<` to `>\s*[\r\n]\s*<` so it only collapses INDENTATION (whitespace runs containing a newline). Single intentional spaces between inline elements like ` ` are preserved, so a regression that drops or adds them is caught instead of normalized away. - normalize(): scope the trailing double-space collapse from a global ` +/g` to attribute-value contents only, via `="([^"]*)"` → callback. The intent had always been to clean up duplicate spaces left in `class="foo bar"` after CSS-module canonicalization; running the rule on the whole HTML also collapsed meaningful spaces in text content (e.g. inside `
`).
- Reword the stale "demo-page fixtures" header comment that listed
  collation as skipped — collation became a covered case in 15bdbf7.
  Also add a parallel one-line heading on the per-source fixture
  imports so the two import blocks read as parallel sections.

Co-Authored-By: Claude Opus 4.7 
---
 scripts/install-dom-compat-baseline.mjs |  9 ++++--
 test/dom-compat.spec.tsx                | 41 +++++++++++++++----------
 vitest.dom-compat.config.ts             |  8 +++--
 3 files changed, 35 insertions(+), 23 deletions(-)

diff --git a/scripts/install-dom-compat-baseline.mjs b/scripts/install-dom-compat-baseline.mjs
index 2c02b5de..9a43f135 100644
--- a/scripts/install-dom-compat-baseline.mjs
+++ b/scripts/install-dom-compat-baseline.mjs
@@ -87,9 +87,12 @@ function main() {
 			(installed ? ` (replacing ${installed})` : "") +
 			"...",
 	);
-	run(`npm install --no-save --no-package-lock --no-audit --no-fund ${ALIAS}@npm:${PKG_NAME}@${latest}`, {
-		stdio: "inherit",
-	});
+	run(
+		`npm install --no-save --no-package-lock --no-audit --no-fund ${ALIAS}@npm:${PKG_NAME}@${latest}`,
+		{
+			stdio: "inherit",
+		},
+	);
 	console.log(`[dom-compat] done.`);
 }
 
diff --git a/test/dom-compat.spec.tsx b/test/dom-compat.spec.tsx
index ded4cba4..3fc916ff 100644
--- a/test/dom-compat.spec.tsx
+++ b/test/dom-compat.spec.tsx
@@ -67,6 +67,7 @@ const baselineVersion: string = JSON.parse(
 	),
 ).version;
 
+// Per-source / per-payload-shape fixtures hand-rolled for this spec.
 import {
 	botTextMessage,
 	userTextMessage,
@@ -86,15 +87,11 @@ import {
 	collatedFollowupMessage,
 	collatedPrevMessage,
 } from "./fixtures/messages";
-import type { IMessage } from "@cognigy/socket-client";
 
-// ---- demo-page fixtures ----
-// Each corresponds to a tab on test/demo.tsx. We cover every message type the
-// demo renders via a  component (i.e. everything except the
-// non-Message tabs: "UI Components" and tabs that require stateful runtime
-// setup like collation or streaming animation). Fixtures that don't ship with
-// a `source` field are given `"bot"` at rendering time — the published
-// baseline and the branch both follow the same rule so the comparison still holds.
+// Demo-page fixtures. Each maps to a tab on test/demo.tsx; we cover every
+// message type that demo renders via . Fixtures that omit `source`
+// are given "bot" at render time via `asBot` — the baseline and the branch
+// both apply the same default, so the comparison still holds.
 import imageFixture from "./fixtures/image.json";
 import imageDownloadableFixture from "./fixtures/image-downloadable.json";
 import imageBrokenFixture from "./fixtures/imageBroken.json";
@@ -118,10 +115,12 @@ import datepickerNoTime from "./fixtures/datepicker/noTime.json";
 import datepickerTimeOnly from "./fixtures/datepicker/timeOnly.json";
 import datepickerDisableWeekends from "./fixtures/datepicker/disableWeekends.json";
 
-// Cast + default source helper. Fixtures are stored as plain JSON and some
-// omit `source` because the existing per-component specs accept whatever
-// shape the matcher needs; for Message-level rendering we must have a source
-// so the non-user / non-engagement branches flow as expected.
+import type { IMessage } from "@cognigy/socket-client";
+
+// Cast + default source helper. JSON fixtures sometimes omit `source`; the
+// existing per-component specs accept whatever shape the matcher needs, but
+// at the Message level a source is required so the non-user / non-engagement
+// branches flow as expected.
 const asBot = (raw: unknown): IMessage => ({ source: "bot", ...(raw as object) }) as IMessage;
 
 // Normalize HTML so that non-structural differences don't cause false
@@ -145,8 +144,11 @@ const asBot = (raw: unknown): IMessage => ({ source: "bot", ...(raw as object) }
 function normalize(html: string): string {
 	return (
 		html
-			// collapse whitespace between tags
-			.replace(/>\s+<")
+			// Collapse INDENTATION between tags only — whitespace runs that
+			// contain a newline. Single intentional spaces between inline
+			// elements (e.g. ` `) are preserved so a regression
+			// that drops or adds them is still caught.
+			.replace(/>\s*[\r\n]\s*<")
 			// trim leading/trailing whitespace
 			.trim()
 			// mask React useId tokens like :r0:, :R1a:, :Rab:, «r0», «R1a»
@@ -160,8 +162,13 @@ function normalize(html: string): string {
 			// canonicalize hashed CSS-module class names:
 			//   `_header_21mid_1` / `_title2-regular_1ltiv_41` → `header` / `title2-regular`
 			.replace(/\b_([A-Za-z][\w-]*?)_[A-Za-z0-9]{4,6}_\d+\b/g, "$1")
-			// collapse any resulting double spaces inside attribute values
-			.replace(/  +/g, " ")
+			// Collapse double spaces ONLY inside HTML attribute values. The
+			// CSS-module canonicalization above can leave `class="foo  bar"`
+			// when one of the originals was a hashed token; class values are
+			// space-separated so the extras are non-structural. Scoping this
+			// to attribute values preserves intentional double spaces in text
+			// content (e.g. `
` blocks).
+			.replace(/="([^"]*)"/g, (_match, value: string) => `="${value.replace(/  +/g, " ")}"`)
 	);
 }
 
@@ -218,7 +225,7 @@ const disableBorderConfig = {
 
 // Core source fixtures. These exercise the Message/Header/Body structural
 // contract across every MessageSender variant plus the two plugin payload
-// shapes defined in test/fixtures/messages.ts.
+// shapes (gallery, quick replies) defined in test/fixtures/messages.ts.
 const cases: Case[] = [
 	{ name: "bot text message", message: botTextMessage },
 	{ name: "user text message", message: userTextMessage },
diff --git a/vitest.dom-compat.config.ts b/vitest.dom-compat.config.ts
index 0d41db1d..436c51e5 100644
--- a/vitest.dom-compat.config.ts
+++ b/vitest.dom-compat.config.ts
@@ -9,9 +9,11 @@
  *
  * We can't use `mergeConfig` with the base config because mergeConfig
  * concatenates arrays — the base config's exclude would keep the dom-compat
- * spec excluded. Instead this file restates the few fields Vitest needs
- * (plugins are a no-op at test-run time beyond react/svgr, which Vitest
- * picks up from vite.config.ts via the shared resolve config below).
+ * spec excluded. And `vitest --config vitest.dom-compat.config.ts` does not
+ * automatically merge in vite.config.ts, so this file must restate every
+ * field Vitest needs to run the spec: the react / svgr plugins, the
+ * jsdom-environment + setup files, the CSS-module non-scoped strategy, and
+ * the resolve aliases used by the spec and its fixtures.
  */
 import { defineConfig, configDefaults } from "vitest/config";
 import react from "@vitejs/plugin-react-swc";

From f6834bd059e234acb89d4263534a4421be9a7d5a Mon Sep 17 00:00:00 2001
From: Dmitrii Ostasevich 
Date: Thu, 30 Apr 2026 16:33:05 +0200
Subject: [PATCH 12/13] fix(chat-components): AB#131731 robust dom-compat
 against published-vs-main drift
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Two related fixes after the first real-world failure on PR #244 (caught
a published-but-not-merged sibling release): a published 0.71.0 (from
the c26 theme PR) shipped a gallery aria-controls accessibility fix
that main's source doesn't have yet, so the dom-compat check naturally
reported the drift. The check is doing its job, but the divergence isn't
something this PR's branch can fix — and we shouldn't block landing on
it.

scripts/install-dom-compat-baseline.mjs
- Change baseline selection from "always npm latest" to
  `min(npm latest, working tree version)` via numeric MAJOR.MINOR.PATCH
  compare. When the working tree advances past npm latest (the normal
  case during dev), npm latest is still the baseline — drift detection
  is preserved. When the working tree is behind npm latest (anomaly),
  the script pins to the working tree's own version, degrading to
  rebuild-vs-itself with a clear notice rather than reporting a
  divergence the branch can't fix.
- Reword the file preamble to document this policy explicitly.

.github/workflows/dom-compat.yml
- Reorder steps: `npm ci` → `npm run build` → install-baseline → test.
  Previously the build ran AFTER install-baseline, but
  `npm install --no-save chat-components-baseline@npm:...` still
  resolves and writes to node_modules (only the lockfile is left
  alone) — locally we saw it touch ~204 packages. Building first means
  our `dist/` is produced with the exact lockfile-pinned dependency
  tree, not the alias-shifted tree that exists post-install-baseline.
  Without this fix the comparison wasn't honest: branch source was
  bundled with potentially-different transitive versions than the
  baseline npm package was bundled with at publish time.

Verification (npm ci → build → install-baseline → test):
- 40/40 dom-compat pass.
- Negative test (perturb DOM in Message.tsx) — 40/40 fail. Safety net
  intact.

Co-Authored-By: Claude Opus 4.7 
---
 .github/workflows/dom-compat.yml        | 25 ++++----
 scripts/install-dom-compat-baseline.mjs | 79 ++++++++++++++++++-------
 2 files changed, 74 insertions(+), 30 deletions(-)

diff --git a/.github/workflows/dom-compat.yml b/.github/workflows/dom-compat.yml
index 4aa4c31b..f1e59a61 100644
--- a/.github/workflows/dom-compat.yml
+++ b/.github/workflows/dom-compat.yml
@@ -30,15 +30,20 @@ jobs:
                   node-version: 22.x
                   cache: "npm"
             - run: npm ci
-            # Dynamically installs the dist-tag `latest` of
-            # @cognigy/chat-components as the aliased dev-dep
-            # `chat-components-baseline` used by the dom-compat spec. Must
-            # run AFTER npm ci (which clears node_modules) and BEFORE the
-            # build / dom-compat test. See scripts/install-dom-compat-baseline.mjs.
-            - run: npm run test:dom-compat:install-baseline
-            # The dom-compat spec imports Message from `dist/chat-components.js`,
-            # so a production build must exist before the spec runs. Using
-            # the same build pipeline for both sides of the comparison avoids
-            # Vitest-only CSS-module class-name artifacts.
+            # Build the branch's dist/ FIRST, with the clean deps installed
+            # by `npm ci`. The dom-compat spec imports `Message` from
+            # `dist/chat-components.js`, so the production bundle must exist
+            # before the spec runs. Using the same build pipeline for both
+            # sides of the comparison avoids Vitest-only CSS-module
+            # class-name artifacts.
             - run: npm run build
+            # Install the published baseline AFTER the build. `npm install
+            # --no-save chat-components-baseline@npm:@cognigy/chat-components@`
+            # still resolves and writes to node_modules (only the lockfile
+            # is left alone), so building first ensures our `dist/` was
+            # produced with the exact dependency tree pinned by the lockfile
+            # — not the alias-shifted tree that exists after install-baseline.
+            # See scripts/install-dom-compat-baseline.mjs for baseline-version
+            # selection.
+            - run: npm run test:dom-compat:install-baseline
             - run: npm run test:dom-compat
diff --git a/scripts/install-dom-compat-baseline.mjs b/scripts/install-dom-compat-baseline.mjs
index 9a43f135..86d119eb 100644
--- a/scripts/install-dom-compat-baseline.mjs
+++ b/scripts/install-dom-compat-baseline.mjs
@@ -1,23 +1,32 @@
 /**
- * Installs the latest published `@cognigy/chat-components` as the aliased
- * dev-dependency `chat-components-baseline`. Consumed by
+ * Installs the right baseline build of `@cognigy/chat-components` as the
+ * aliased dev-dependency `chat-components-baseline`. Consumed by
  * `test/dom-compat.spec.tsx`, which compares the current branch's built
- * DOM output against the last public release.
+ * DOM output against that baseline.
  *
- * Why a script instead of a pinned devDependency?
- *   A pinned version would drift as releases happen; we'd have to bump
- *   package.json on every release or the DOM-compat check would silently
- *   assert against an older baseline. Resolving "latest" at install time
- *   keeps the check honest without human bookkeeping.
+ * Baseline selection:
+ *   We default to the dist-tag `latest` so the check stays honest as
+ *   releases happen — no human has to bump a pinned devDependency and
+ *   risk silently asserting against a stale version.
+ *
+ *   But when the working tree's own version is *behind* npm latest (which
+ *   happens whenever a release ships from a sibling branch before main has
+ *   merged it — e.g. a hotfix or an out-of-order feature release), comparing
+ *   `working tree source` vs `npm latest` reports the divergence the
+ *   sibling-branch release introduced, not anything this branch did. To
+ *   keep the check actionable we degrade to rebuild-vs-itself in that case
+ *   by installing the working tree's own version as the baseline.
+ *
+ *   Resulting policy: baseline = min(npm `latest`, working tree version).
  *
  * Behavior:
- *   - Resolves the latest @cognigy/chat-components from the configured
- *     registry (`dist-tags.latest`).
- *   - Installs it as `chat-components-baseline@npm:@cognigy/chat-components@`
- *     without touching package.json / package-lock.json (`--no-save`).
- *   - If the working tree's own package.json version equals the latest
- *     published version, logs a notice — the DOM-compat test will still run
- *     but will effectively compare a rebuild against itself.
+ *   - Reads the working tree's version from package.json.
+ *   - Reads npm latest via `npm view  version`.
+ *   - Picks the lower of the two as the baseline (semver compare).
+ *   - Installs `chat-components-baseline@npm:@cognigy/chat-components@`
+ *     with `--no-save --no-package-lock` so the lockfile isn't touched.
+ *   - Logs a clear notice when the comparison degrades to rebuild-vs-itself
+ *     (either current === latest, or current < latest).
  *
  * Usage:
  *   node scripts/install-dom-compat-baseline.mjs
@@ -62,6 +71,21 @@ function installedBaselineVersion() {
 	}
 }
 
+// Numeric semver compare for plain `MAJOR.MINOR.PATCH` strings.
+// Returns negative if a < b, 0 if equal, positive if a > b. Pre-release
+// suffixes are ignored — package.json/npm release versions are always
+// plain triplets in this repo, so we don't need full semver semantics.
+function compareSemver(a, b) {
+	const parse = v =>
+		v
+			.split("-")[0]
+			.split(".")
+			.map(n => parseInt(n, 10) || 0);
+	const [a1, a2, a3] = parse(a);
+	const [b1, b2, b3] = parse(b);
+	return a1 - b1 || a2 - b2 || a3 - b3;
+}
+
 function main() {
 	const current = currentVersion();
 	const latest = latestPublishedVersion();
@@ -69,26 +93,41 @@ function main() {
 	console.log(`[dom-compat] working tree version:     ${current}`);
 	console.log(`[dom-compat] latest published version: ${latest}`);
 
-	if (current === latest) {
+	// Pick the lower of the two as the baseline. Rationale in the file
+	// preamble: when the working tree is behind npm latest (an anomaly that
+	// happens when a release shipped from a sibling branch before main
+	// merged it), comparing branch vs npm latest reports the sibling
+	// release's diff, not anything this branch did.
+	const cmp = compareSemver(current, latest);
+	const baseline = cmp < 0 ? current : latest;
+
+	if (cmp === 0) {
 		console.log(
 			`[dom-compat] NOTE: working tree is at the latest published version — ` +
 				`DOM-compat check will compare a rebuild against itself.`,
 		);
+	} else if (cmp < 0) {
+		console.log(
+			`[dom-compat] NOTE: working tree (${current}) is behind npm latest ` +
+				`(${latest}); pinning baseline to ${current} so the check ` +
+				`degrades to rebuild-vs-itself instead of reporting drift this ` +
+				`branch can't fix.`,
+		);
 	}
 
 	const installed = installedBaselineVersion();
-	if (installed === latest) {
-		console.log(`[dom-compat] baseline already installed at ${latest} — skipping.`);
+	if (installed === baseline) {
+		console.log(`[dom-compat] baseline already installed at ${baseline} — skipping.`);
 		return;
 	}
 
 	console.log(
-		`[dom-compat] installing ${ALIAS}@npm:${PKG_NAME}@${latest}` +
+		`[dom-compat] installing ${ALIAS}@npm:${PKG_NAME}@${baseline}` +
 			(installed ? ` (replacing ${installed})` : "") +
 			"...",
 	);
 	run(
-		`npm install --no-save --no-package-lock --no-audit --no-fund ${ALIAS}@npm:${PKG_NAME}@${latest}`,
+		`npm install --no-save --no-package-lock --no-audit --no-fund ${ALIAS}@npm:${PKG_NAME}@${baseline}`,
 		{
 			stdio: "inherit",
 		},

From 87833e645195e7c7723fd47cf4a4a642b50eb1a3 Mon Sep 17 00:00:00 2001
From: Dmitrii Ostasevich 
Date: Thu, 30 Apr 2026 16:53:19 +0200
Subject: [PATCH 13/13] docs(chat-components): AB#131731 fix CSS-module
 normalization comment

The "non-scoped vs hashed" framing in normalize()'s comment was
left over from when only one side imported from src/ via Vitest.
Now that both `CurrentMessage` and `BaselineMessage` come from built
dist bundles, the actual reason for canonicalization is different:
the CSS-module hash suffix is content-derived per build, so the same
logical class can carry a different hash between releases (or even
between two rebuilds of the same source after a node_modules
shuffle) without any DOM-structural change.

Reword the comment to describe what the regex actually does. The
plain-name fallback note for `classNameStrategy: "non-scoped"` is
preserved as a "convenient if anyone ever runs the spec against
non-dist source" aside.

Co-Authored-By: Claude Opus 4.7 
---
 test/dom-compat.spec.tsx | 23 +++++++++++++++--------
 1 file changed, 15 insertions(+), 8 deletions(-)

diff --git a/test/dom-compat.spec.tsx b/test/dom-compat.spec.tsx
index 3fc916ff..d481a838 100644
--- a/test/dom-compat.spec.tsx
+++ b/test/dom-compat.spec.tsx
@@ -133,14 +133,21 @@ const asBot = (raw: unknown): IMessage => ({ source: "bot", ...(raw as object) }
 //      strings. Any attribute value *containing* such a token gets the
 //      token masked so cross-referenced attrs (aria-describedby, htmlFor,
 //      for, id) stay equal to themselves after masking.
-//   3. CSS-module hashed class names. The published baseline build emits
-//      classes in the default hashed form (`_header_21mid_1`, `_incoming_21mid_8`,
-//      `_title2-regular_1ltiv_41`), while the branch-under-test source is
-//      loaded by Vitest with `classNameStrategy: "non-scoped"` (see
-//      vite.config.ts test.css.modules), which yields plain class names
-//      (`header`, `incoming`, `title2-regular`). This is a build-time
-//      packaging difference, not a DOM-structural one, so we canonicalize
-//      both shapes to the plain name before comparing.
+//   3. CSS-module hashed class names. Both `CurrentMessage` and
+//      `BaselineMessage` are imported from built dist bundles, so both
+//      sides emit hashed class tokens (`_header_21mid_1`, `_incoming_21mid_8`,
+//      `_title2-regular_1ltiv_41`). The hash suffix is content-derived per
+//      build, so the same logical class can carry a different suffix
+//      between releases (or between two rebuilds of the same source after
+//      a node_modules shuffle) even when the underlying DOM structure and
+//      logical class identity are unchanged. That's a build-artifact
+//      difference, not a DOM-structural one, so we canonicalize both
+//      sides' tokens to their plain local names before comparing. The
+//      plain-name shape (`header`, `incoming`) is also what
+//      vite.config.ts uses for the regular Vitest run via
+//      `classNameStrategy: "non-scoped"` — the canonicalization is a
+//      no-op on that shape, which is convenient if anyone ever runs the
+//      spec against a non-dist source build.
 function normalize(html: string): string {
 	return (
 		html