messenzy-widget/tests/unit/mount.test.ts
Serge RAKOTO HARRY-NAIVO f74ff56fc4 feat(mount): extract mountWidget + applyStyles helpers with shadow support
mountWidget centralizes both classic and shadow mounting behind a single
entry point. The shadow path attaches an open shadow root, injects CSS via
constructable stylesheets (with a deduped <style>-tag fallback for jsdom
and Safari < 16.4), and renders Preact into a stable inner wrapper so the
sibling style node is not clobbered by render() diffs.

applyStyles is exported separately so the fallback branch can be exercised
directly in unit tests without a full mount round trip — the constructable
path runs only in real browsers.

Idempotency on the host element and shadow root means repeated boots
(HMR, accidental double-load) do not duplicate DOM nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:55:02 +02:00

121 lines
4.3 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
// Stub the Widget component so mountWidget does not boot the WebSocket
// transport (jsdom has no WebSocket implementation). We only verify mount
// mechanics here — Widget rendering is a separate concern.
vi.mock('@/ui/widget', () => ({
Widget: () => null,
}));
import { mountWidget, applyStyles } from '@/mount';
import type { WidgetConfig } from '@/config';
const baseConfig: WidgetConfig = {
botId: 'test-bot',
apiKey: 'test-key',
serverUrl: 'https://test.local',
position: 'bottom-right',
shadow: false,
};
const TEST_CSS = '.messenzy-widget { color: tomato; }';
beforeEach(() => {
document.head.innerHTML = '';
document.body.innerHTML = '';
});
describe('mountWidget — classic mode', () => {
it('creates #messenzy-root in document.body when absent', () => {
mountWidget(baseConfig, TEST_CSS, 'classic');
const root = document.getElementById('messenzy-root');
expect(root).not.toBeNull();
expect(root?.parentElement).toBe(document.body);
});
it('reuses an existing #messenzy-root', () => {
const existing = document.createElement('div');
existing.id = 'messenzy-root';
document.body.appendChild(existing);
const handle = mountWidget(baseConfig, TEST_CSS, 'classic');
expect(handle.root).toBe(existing);
});
it('returns a handle without shadowRoot', () => {
const handle = mountWidget(baseConfig, TEST_CSS, 'classic');
expect(handle.shadowRoot).toBeUndefined();
});
it('does not attach a shadow root to the host', () => {
mountWidget(baseConfig, TEST_CSS, 'classic');
const root = document.getElementById('messenzy-root');
expect(root?.shadowRoot).toBeNull();
});
});
describe('mountWidget — shadow mode', () => {
it('creates the host and attaches an open shadow root', () => {
const handle = mountWidget(baseConfig, TEST_CSS, 'shadow');
const root = document.getElementById('messenzy-root');
expect(root).not.toBeNull();
expect(root?.shadowRoot).not.toBeNull();
expect(handle.shadowRoot).toBe(root?.shadowRoot);
});
it('reuses an already-attached shadow root (HMR/idempotency)', () => {
const existing = document.createElement('div');
existing.id = 'messenzy-root';
const preAttached = existing.attachShadow({ mode: 'open' });
document.body.appendChild(existing);
const handle = mountWidget(baseConfig, TEST_CSS, 'shadow');
expect(handle.shadowRoot).toBe(preAttached);
});
it('injects styles into the shadow root, not document.head', () => {
mountWidget(baseConfig, TEST_CSS, 'shadow');
expect(document.getElementById('messenzy-styles')).toBeNull();
const shadow = document.getElementById('messenzy-root')?.shadowRoot;
const styleNode = shadow?.querySelector('style');
expect(styleNode).not.toBeNull();
expect(styleNode?.textContent).toContain('tomato');
});
it('keeps the <style> intact when Preact renders (uses inner wrapper)', () => {
mountWidget(baseConfig, TEST_CSS, 'shadow');
const shadow = document.getElementById('messenzy-root')?.shadowRoot;
// The shadow root must contain both the style node AND a separate
// wrapper for the Preact render target — otherwise render() would
// clobber the <style>.
expect(shadow?.querySelector('style')).not.toBeNull();
expect(shadow?.children.length).toBeGreaterThanOrEqual(2);
});
});
describe('applyStyles', () => {
let shadow: ShadowRoot;
beforeEach(() => {
const host = document.createElement('div');
document.body.appendChild(host);
shadow = host.attachShadow({ mode: 'open' });
});
it('appends a <style> node into the shadow root (jsdom fallback path)', () => {
applyStyles(shadow, TEST_CSS);
const style = shadow.querySelector('style');
expect(style).not.toBeNull();
expect(style?.textContent).toBe(TEST_CSS);
});
it('is idempotent — repeated calls keep one <style>', () => {
applyStyles(shadow, TEST_CSS);
applyStyles(shadow, TEST_CSS);
expect(shadow.querySelectorAll('style')).toHaveLength(1);
});
// The constructable-stylesheets path (`adoptedStyleSheets`) is exercised
// in real browsers with full CSSOM support. jsdom does not implement it,
// so the unit suite verifies only the <style>-fallback branch. Manual
// smoke-test in a real browser confirms the modern path.
});