diff --git a/.github/workflows/dom-compat.yml b/.github/workflows/dom-compat.yml new file mode 100644 index 00000000..f1e59a61 --- /dev/null +++ b/.github/workflows/dom-compat.yml @@ -0,0 +1,49 @@ +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 Message component's DOM contract. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + dom-compat: + name: DOM compatibility vs latest release + runs-on: ubuntu-latest + + # 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@v4 + with: + node-version: 22.x + cache: "npm" + - run: npm ci + # 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/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/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..86d119eb --- /dev/null +++ b/scripts/install-dom-compat-baseline.mjs @@ -0,0 +1,143 @@ +/** + * 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 that baseline. + * + * 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: + * - 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 + * # 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; + } +} + +// 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(); + + console.log(`[dom-compat] working tree version: ${current}`); + console.log(`[dom-compat] latest published version: ${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 === baseline) { + console.log(`[dom-compat] baseline already installed at ${baseline} — skipping.`); + return; + } + + console.log( + `[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}@${baseline}`, + { + 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/dom-compat.spec.tsx b/test/dom-compat.spec.tsx new file mode 100644 index 00000000..d481a838 --- /dev/null +++ b/test/dom-compat.spec.tsx @@ -0,0 +1,371 @@ +/** + * 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 + * 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 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 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; + +// Per-source / per-payload-shape fixtures hand-rolled for this spec. +import { + botTextMessage, + userTextMessage, + agentTextMessage, + engagementTextMessage, + richBotMessage, + quickRepliesBotMessage, + defaultPreviewQuickReplies, + defaultPreviewText, + xAppQuickReply, + xAppButton, + sanitizedHtmlMessage, + sanitizedCustomTagsMessage, + sanitizationDisabledMessage, + markdownText, + borderlessText, + collatedFollowupMessage, + collatedPrevMessage, +} from "./fixtures/messages"; + +// 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"; +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 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 +// 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. 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 + // 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» + .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) + .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 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, " ")}"`)
+	);
+}
+
+// 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.
+//
+// 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: 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 (gallery, quick replies) defined in test/fixtures/messages.ts.
+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, config: engagementConfig },
+	{ 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
+// . 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) },
+	{ 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) },
+	// Adaptive Cards — fixture is an array; cover all three indices since
+	// they exercise different card payload shapes.
+	{
+		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) },
+];
+
+// Shared assertion helper: render the same message through both packages,
+// 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, 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();
+
+	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, config, prevMessage }) => assertSameDom(message, config, prevMessage),
+		);
+	});
+
+	describe("demo-page message tabs", () => {
+		it.each(demoCases)(
+			"$name — matches published release DOM",
+			({ message, config, prevMessage }) => assertSameDom(message, config, prevMessage),
+		);
+	});
+});
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; 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 383ac2d9..19404ba1 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/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..436c51e5 --- /dev/null +++ b/vitest.dom-compat.config.ts @@ -0,0 +1,43 @@ +/** + * Dedicated Vitest config for the DOM-compatibility spec. + * + * 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 + * 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. 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"; +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/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", + }, + }, +});