Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/dom-compat.yml
Original file line number Diff line number Diff line change
@@ -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@<v>`
# 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
13 changes: 13 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
143 changes: 143 additions & 0 deletions scripts/install-dom-compat-baseline.mjs
Original file line number Diff line number Diff line change
@@ -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 <pkg> version`.
* - Picks the lower of the two as the baseline (semver compare).
* - Installs `chat-components-baseline@npm:@cognigy/chat-components@<baseline>`
* 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 <pkg> 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);
}
Loading
Loading