-
Notifications
You must be signed in to change notification settings - Fork 0
Testing a Chat Surface
The package's own suite is the built-in Node test runner plus jsdom and
fake-indexeddb. No build step, no test framework, no linter.
npm test
npm run test:watch
node --test --import ./test/setup.js test/components.test.js
node --test --import ./test/setup.js "test/*.test.js" --test-name-pattern "persist"--import ./test/setup.js is required. The component modules self-register at
import time, so the DOM has to exist before any of them load — without it, the first
import throws ReferenceError: HTMLElement is not defined before a single test
runs.
Node 18+ ships its own global Event and CustomEvent. Handing one of those to a
jsdom element's dispatchEvent throws:
TypeError: Failed to execute 'dispatchEvent' on 'EventTarget':
parameter 1 is not of type 'Event'.
The realms do not recognise each other's objects. A setup file that assigns only the missing globals leaves Node's versions in place, and then every component that dispatches an event fails — which reads as a component bug, not a harness one.
So force-assign them:
const FORCE = ['Event', 'CustomEvent', 'EventTarget',
'KeyboardEvent', 'MouseEvent', 'PointerEvent'];
for (const key of FORCE) {
if (window[key] !== undefined) globalThis[key] = window[key];
}The cache probes globalThis.indexedDB and degrades silently to memory — by design,
so a private window loses history rather than breaking. In a test, that same
degradation means your persistence test passes while exercising no persistence at
all.
import 'fake-indexeddb/auto'; // before anything elseimport { JSDOM, VirtualConsole } from 'jsdom';
import 'fake-indexeddb/auto';
// Forward jsdom's console, minus one known limitation: exporting history clicks
// an <a download>, which jsdom reports as "navigation to another Document" every
// run. A suite that prints a harmless warning every time is a suite where the
// next real warning goes unread.
const virtualConsole = new VirtualConsole();
virtualConsole.forwardTo(console, { jsdomErrors: 'none' }); // jsdom 29 renamed sendTo
virtualConsole.on('jsdomError', (error) => {
if (/navigation to another Document/.test(error.message)) return;
console.error(error);
});
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'https://example.test/',
pretendToBeVisual: true,
virtualConsole
});
const FORCE = ['Event', 'CustomEvent', 'EventTarget', 'KeyboardEvent', 'MouseEvent', 'PointerEvent'];
const IF_MISSING = ['window', 'document', 'navigator', 'HTMLElement', 'customElements',
'Node', 'ShadowRoot', 'getComputedStyle', 'requestAnimationFrame'];
for (const key of FORCE) if (dom.window[key] !== undefined) globalThis[key] = dom.window[key];
for (const key of IF_MISSING) {
if (globalThis[key] === undefined && dom.window[key] !== undefined) globalThis[key] = dom.window[key];
}Streaming is async throughout, so a fixed sleep is how a suite becomes flaky on a loaded machine — the test that passes at 50ms fails at 51.
async function until(predicate, { timeoutMs = 2000, everyMs = 5 } = {}) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const value = await predicate();
if (value) return value;
if (Date.now() > deadline) throw new Error(`condition not met in ${timeoutMs}ms`);
await new Promise((r) => setTimeout(r, everyMs));
}
}
await until(() => canvas.shadowRoot.querySelectorAll('li').length === 3);Polling a predicate also states the actual requirement — "by now this should be true" — rather than encoding a guess about timing.
test.beforeEach(() => document.body.replaceChildren());Without this, a test that throws before its cleanup line leaves a services element in the document — and the next test that expects to find none discovers it through the document-wide fallback. The failure then points at the wrong component, which is worse than no isolation at all. This actually happened while writing the suite.
import test from 'node:test';
import assert from 'node:assert/strict';
import '@machfivetechchicago/machvive-chat-syncopation-ai';
test('a reply streams into the transcript', async () => {
document.body.innerHTML = `
<machvive-chat-syncopation-services transport="echo">
<machvive-chat-syncopation-canvas></machvive-chat-syncopation-canvas>
</machvive-chat-syncopation-services>
`;
const services = document.querySelector('machvive-chat-syncopation-services');
const canvas = document.querySelector('machvive-chat-syncopation-canvas');
await services.send('hello');
await until(() => canvas.shadowRoot.querySelectorAll('li').length === 2);
const rows = [...canvas.shadowRoot.querySelectorAll('li')];
assert.deepEqual(rows.map((r) => r.dataset.role), ['user', 'assistant']);
assert.equal(rows[1].dataset.status, 'complete');
});A green suite proves nothing until you have watched it go red. Break the thing a new test covers and confirm it fails.
This is not hypothetical rigour: in the sibling package three of the first five deliberate mutations survived. Here, 26 mutations are currently all caught — removing the role check, dropping the trim, making a nudge send instead of fill, letting Enter send with Shift held, rendering record text as markup, ignoring the inspector's buffer cap, moving the explicit dark theme before the media query, and so on.
The cheapest form is a script that applies one string replacement, runs the relevant test file, and reverts — asserting that the pattern was actually found, because a mutation that silently does not apply looks exactly like a killed one.
No layout engine and no computed style. The suite is structurally blind to:
- contrast (a white-on-white button is invisible to it),
- overflow and clipping (a long tool name overflowing a fixed column),
- stacking (two floating widgets landing on top of each other),
- scroll position, and therefore autoscroll behaviour itself.
All four of those shipped past a green suite in the sibling package. Anything visual needs a real browser and measured values — see Theming.
--experimental-test-coverage attributes lifecycle callbacks invoked through
jsdom's custom element registry to the registry rather than the source file, so a
fully tested component can report near-zero function coverage. Do not chase the
percentage or add tests to "fix" it — mutation testing tells you what the suite
actually catches, and coverage does not.