htmx/test/tests/ext/hx-ws.js
Stu Kennedy 3a72a1d2a3
feat(ws): handle non-JSON messages as raw HTML with cancelable event (#3641)
* feat(ws): handle non-JSON messages as raw HTML with cancelable event

Non-JSON WebSocket messages now swap as raw HTML instead of being
dropped. Fires a cancelable htmx:ws:rawMessage event before swapping,
allowing custom handling when needed.

- If hx-target is set: swaps into target using element's swap style
- If no hx-target: uses swap:none to protect connection element,
  but hx-partial tags in payload still reach their own targets
- preventDefault() on the event skips default swap for custom handling

Replaces the old htmx:wsUnknownMessage event which only notified
but took no action on non-JSON data.

* test(ws): add tests for raw HTML message handling and cancelable event

Tests cover:
- Non-JSON messages swap as raw HTML into hx-target
- swap:none used when no hx-target (protects connection element)
- hx-partial tags in raw messages still reach their targets
- htmx:ws:rawMessage event fires with message data
- preventDefault() on rawMessage cancels default swap

---------

Co-authored-by: Stu Kennedy <stu@stukennedy.com>
2026-01-29 13:44:24 -07:00

1356 lines
51 KiB
JavaScript

describe('hx-ws WebSocket extension', function() {
let extBackup;
let mockWebSocket;
let mockWebSocketInstances = [];
before(async () => {
extBackup = backupExtensions();
clearExtensions();
// Mock WebSocket
mockWebSocket = class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
constructor(url) {
this.url = url;
this.readyState = MockWebSocket.CONNECTING;
this.listeners = {};
mockWebSocketInstances.push(this);
// Simulate connection after a short delay
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
this.triggerEvent('open', {});
}, 10);
}
addEventListener(event, handler) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(handler);
}
removeEventListener(event, handler) {
if (!this.listeners[event]) return;
this.listeners[event] = this.listeners[event].filter(h => h !== handler);
}
send(data) {
if (this.readyState !== MockWebSocket.OPEN) {
throw new Error('WebSocket is not open');
}
this.lastSent = data;
}
close(code = 1000, reason = '') {
this.readyState = MockWebSocket.CLOSED;
this.triggerEvent('close', { code, reason });
}
triggerEvent(event, data) {
if (this.listeners[event]) {
// Add target property to event object for proper event handling
const eventObj = { ...data, target: this };
this.listeners[event].forEach(handler => handler(eventObj));
}
}
// Helper to simulate receiving a message (JSON)
simulateMessage(data) {
this.triggerEvent('message', { data: JSON.stringify(data) });
}
// Helper to simulate receiving raw (non-JSON) message
simulateRawMessage(data) {
this.triggerEvent('message', { data: data });
}
};
window.WebSocket = mockWebSocket;
// CRITICAL: Approve extension BEFORE loading it
// Extension registration silently fails if not approved
htmx.config.extensions = 'ws';
htmx.__approvedExt = 'ws';
let script = document.createElement('script');
script.src = '../src/ext/hx-ws.js';
await new Promise(resolve => {
script.onload = resolve;
document.head.appendChild(script);
});
// Verify extension loaded and registered
if (!htmx.ext || !htmx.ext.ws) {
throw new Error('WebSocket extension failed to load');
}
if (!htmx.__registeredExt.has('ws')) {
throw new Error('WebSocket extension failed to register - check approval');
}
});
after(() => {
restoreExtensions(extBackup);
});
beforeEach(() => {
setupTest(this.currentTest);
mockWebSocketInstances = [];
if (htmx.ext && htmx.ext.ws && htmx.ext.ws.getRegistry) {
htmx.ext.ws.getRegistry().clear();
}
});
afterEach(() => {
cleanupTest(this.currentTest);
// Close all mock WebSocket connections
mockWebSocketInstances.forEach(ws => {
if (ws.readyState === mockWebSocket.OPEN) {
ws.close();
}
});
});
// Helper to check if URL ends with expected path (accounts for URL normalization)
function urlEndsWith(url, expectedPath) {
return url.endsWith(expectedPath);
}
// Helper to get normalized URL for registry lookups
function getNormalizedUrl(path) {
// The extension normalizes /path to ws://host/path or wss://host/path
let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return protocol + '//' + window.location.host + path;
}
// ========================================
// 1. CONNECTION LIFECYCLE TESTS
// ========================================
describe('Connection Lifecycle', function() {
it('creates connection on hx-ws:connect with load trigger', async function() {
let div = createProcessedHTML('<div hx-ext="ws" hx-ws:connect="/ws/test" hx-trigger="load"></div>');
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 1);
assert.isTrue(urlEndsWith(mockWebSocketInstances[0].url, '/ws/test'), 'URL should end with /ws/test');
});
it('auto-connects by default without explicit trigger', async function() {
let div = createProcessedHTML('<div hx-ws:connect="/ws/test"></div>');
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 1, 'Should auto-connect when no trigger is specified');
});
it('connects on custom trigger event', async function() {
let div = createProcessedHTML('<div hx-ws:connect="/ws/test" hx-trigger="click"></div>');
await htmx.timeout(20);
assert.equal(mockWebSocketInstances.length, 0);
div.click();
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 1);
});
it('reuses connection for same URL', async function() {
let container = createProcessedHTML(`
<div>
<div id="div1" hx-ws:connect="/ws/shared" hx-trigger="load"></div>
<div id="div2" hx-ws:connect="/ws/shared" hx-trigger="load"></div>
</div>
`);
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 1, 'Should only create one WebSocket for shared URL');
});
it('creates separate connections for different URLs', async function() {
let container = createProcessedHTML(`
<div>
<div id="div1" hx-ws:connect="/ws/channel1" hx-trigger="load"></div>
<div id="div2" hx-ws:connect="/ws/channel2" hx-trigger="load"></div>
</div>
`);
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 2);
});
it('increments refCount for shared connections', async function() {
let container = createProcessedHTML(`
<div>
<div id="div1" hx-ws:connect="/ws/shared" hx-trigger="load"></div>
<div id="div2" hx-ws:connect="/ws/shared" hx-trigger="load"></div>
</div>
`);
await htmx.timeout(50);
// Access internal registry (this assumes the extension exposes it for testing)
// Registry now uses normalized URLs, so we can pass relative path (it normalizes internally)
let registry = htmx.ext.ws.getRegistry?.();
if (registry) {
let entry = registry.get('/ws/shared');
assert.isNotNull(entry, 'Should find entry for /ws/shared');
assert.equal(entry.refCount, 2);
}
});
it('closes connection when last element is removed', async function() {
let container = createProcessedHTML(`
<div id="container">
<div id="div1" hx-ws:connect="/ws/test" hx-trigger="load"></div>
</div>
`);
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 1);
let ws = mockWebSocketInstances[0];
await htmx.swap({
text: '',
target: document.getElementById('container'),
swap: 'innerHTML'
});
await htmx.timeout(50);
assert.equal(ws.readyState, mockWebSocket.CLOSED);
});
it('keeps connection open when one of multiple elements is removed', async function() {
let container = createProcessedHTML(`
<div id="container">
<div id="div1" hx-ws:connect="/ws/shared" hx-trigger="load"></div>
<div id="div2" hx-ws:connect="/ws/shared" hx-trigger="load"></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
await htmx.swap({
text: '',
target: document.getElementById('div1'),
swap: 'delete'
});
await htmx.timeout(50);
assert.equal(ws.readyState, mockWebSocket.OPEN);
});
});
// ========================================
// 2. MESSAGE SENDING TESTS
// ========================================
describe('Message Sending', function() {
it('sends message with hx-ws:send on form submit', async function() {
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/chat" hx-trigger="load">
<form hx-ws:send hx-trigger="submit">
<input name="message" value="hello">
<button type="submit">Send</button>
</form>
</div>
`);
await htmx.timeout(50);
let form = div.querySelector('form');
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await htmx.timeout(20);
let ws = mockWebSocketInstances[0];
assert.isDefined(ws.lastSent);
let sent = JSON.parse(ws.lastSent);
assert.equal(sent.type, 'request');
assert.isDefined(sent.request_id);
assert.equal(sent.values.message, 'hello');
});
it('includes hx-vals in sent message', async function() {
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<button hx-ws:send hx-vals='{"extra": "data"}' hx-trigger="click">Send</button>
</div>
`);
await htmx.timeout(50);
let button = div.querySelector('button');
button.click();
await htmx.timeout(20);
let ws = mockWebSocketInstances[0];
let sent = JSON.parse(ws.lastSent);
assert.equal(sent.values.extra, 'data');
});
it('finds connection from nearest ancestor', async function() {
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/outer" hx-trigger="load">
<div>
<button id="btn" hx-ws:send hx-trigger="click">Send</button>
</div>
</div>
`);
await htmx.timeout(50);
document.getElementById('btn').click();
await htmx.timeout(20);
let ws = mockWebSocketInstances[0];
assert.isDefined(ws.lastSent);
});
it('creates own connection if hx-ws:send has path', async function() {
let button = createProcessedHTML('<button hx-ws:send="/ws/direct" hx-trigger="click">Send</button>');
button.click();
await htmx.timeout(50);
assert.equal(mockWebSocketInstances.length, 1);
assert.isTrue(urlEndsWith(mockWebSocketInstances[0].url, '/ws/direct'), 'URL should end with /ws/direct');
});
it('includes element id in message context', async function() {
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<button id="my-button" hx-ws:send hx-trigger="click">Send</button>
</div>
`);
await htmx.timeout(50);
document.getElementById('my-button').click();
await htmx.timeout(20);
let ws = mockWebSocketInstances[0];
let sent = JSON.parse(ws.lastSent);
assert.equal(sent.id, 'my-button');
});
it('generates unique request_id for each message', async function() {
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<button hx-ws:send hx-trigger="click">Send</button>
</div>
`);
await htmx.timeout(50);
let button = div.querySelector('button');
button.click();
await htmx.timeout(20);
let firstId = JSON.parse(mockWebSocketInstances[0].lastSent).request_id;
button.click();
await htmx.timeout(20);
let secondId = JSON.parse(mockWebSocketInstances[0].lastSent).request_id;
assert.notEqual(firstId, secondId);
});
it('includes async hx-vals (js:) in sent message', async function() {
window.testAsyncValue = () => new Promise(resolve => setTimeout(() => resolve('asyncValue'), 10));
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<button hx-ws:send hx-vals='js:{asyncField: await testAsyncValue()}' hx-trigger="click">Send</button>
</div>
`);
await htmx.timeout(50);
let button = div.querySelector('button');
button.click();
await htmx.timeout(20);
let ws = mockWebSocketInstances[0];
let sent = JSON.parse(ws.lastSent);
assert.equal(sent.values.asyncField, 'asyncValue');
delete window.testAsyncValue;
});
});
// ========================================
// 3. MESSAGE RECEIVING & HTML HANDLING
// ========================================
describe('Message Receiving and HTML Handling', function() {
it('swaps HTML partial into target element', async function() {
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#messages">
<div id="messages"></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="messages"><p>New message</p></hx-partial>'
});
await htmx.timeout(20);
let messages = document.getElementById('messages');
assert.include(messages.innerHTML, 'New message');
});
it('uses default swap strategy (innerHTML)', async function() {
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content">
<div id="content">Old</div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="content">New</hx-partial>'
});
await htmx.timeout(20);
assert.equal(document.getElementById('content').textContent, 'New');
});
it('respects hx-swap attribute on partial', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#list">
<div id="list"><p>Item 1</p></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="list" hx-swap="beforeend"><p>Item 2</p></hx-partial>'
});
await htmx.timeout(20);
let list = document.getElementById('list');
assert.include(list.innerHTML, 'Item 1');
assert.include(list.innerHTML, 'Item 2');
});
it('handles multiple partials in one message', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<div id="header"></div>
<div id="content"></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: `
<hx-partial id="header"><h1>Title</h1></hx-partial>
<hx-partial id="content"><p>Body</p></hx-partial>
`
});
await htmx.timeout(20);
assert.include(document.getElementById('header').innerHTML, 'Title');
assert.include(document.getElementById('content').innerHTML, 'Body');
});
it('executes script tags in swapped content', async function() {
// Clean up any existing test variable
delete window.wsScriptTestValue;
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content">
<div id="content"></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="content"><div>Content</div><script>window.wsScriptTestValue = "executed";</script></hx-partial>'
});
await htmx.timeout(20);
assert.equal(window.wsScriptTestValue, 'executed', 'Script tag should have been executed');
// Clean up
delete window.wsScriptTestValue;
});
it('executes multiple script tags in swapped content', async function() {
// Clean up any existing test variables
delete window.wsScriptTest1;
delete window.wsScriptTest2;
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content">
<div id="content"></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="content"><script>window.wsScriptTest1 = 1;</script><div>Content</div><script>window.wsScriptTest2 = 2;</script></hx-partial>'
});
await htmx.timeout(20);
assert.equal(window.wsScriptTest1, 1, 'First script tag should have been executed');
assert.equal(window.wsScriptTest2, 2, 'Second script tag should have been executed');
// Clean up
delete window.wsScriptTest1;
delete window.wsScriptTest2;
});
it('preserves script tag attributes when executing', async function() {
// Clean up any existing test variable
delete window.wsScriptAttrTest;
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content">
<div id="content"></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="content"><script data-testattr="testvalue">window.wsScriptAttrTest = document.currentScript.getAttribute("data-testattr");</script></hx-partial>'
});
await htmx.timeout(20);
assert.equal(window.wsScriptAttrTest, 'testvalue', 'Script should access its own attributes');
// Clean up
delete window.wsScriptAttrTest;
});
it('matches request_id for request/response pattern', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<button id="btn" hx-ws:send hx-trigger="click" hx-target="#result">Send</button>
<div id="result"></div>
</div>
`);
await htmx.timeout(50);
let button = document.getElementById('btn');
button.click();
await htmx.timeout(20);
let ws = mockWebSocketInstances[0];
let sent = JSON.parse(ws.lastSent);
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="result">Response</hx-partial>',
request_id: sent.request_id
});
await htmx.timeout(20);
assert.include(document.getElementById('result').innerHTML, 'Response');
});
});
// ========================================
// 4. CUSTOM CHANNEL TESTS
// ========================================
describe('Custom Channels', function() {
it('emits event for non-ui channel messages', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
await htmx.timeout(50);
let eventFired = false;
let eventDetail = null;
container.addEventListener('htmx:wsMessage', (e) => {
eventFired = true;
eventDetail = e.detail;
});
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'audio',
format: 'binary',
payload: 'base64data'
});
await htmx.timeout(20);
assert.isTrue(eventFired);
assert.equal(eventDetail.channel, 'audio');
});
it('does not auto-swap JSON channel messages', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#data">
<div id="data"></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'json',
format: 'json',
payload: { foo: 'bar' }
});
await htmx.timeout(20);
assert.equal(document.getElementById('data').innerHTML, '');
});
it('fires htmx:before:ws:message for all messages', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
await htmx.timeout(50);
let beforeFired = false;
container.addEventListener('htmx:before:ws:message', () => {
beforeFired = true;
});
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="test">Test</hx-partial>'
});
await htmx.timeout(20);
assert.isTrue(beforeFired);
});
it('allows canceling message processing via event', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content">
<div id="content">Original</div>
</div>
`);
await htmx.timeout(50);
container.addEventListener('htmx:before:ws:message', (e) => {
e.preventDefault();
});
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="content">Changed</hx-partial>'
});
await htmx.timeout(20);
assert.equal(document.getElementById('content').textContent, 'Original');
});
});
// ========================================
// 5. ERROR HANDLING & RECONNECTION
// ========================================
describe('Error Handling and Reconnection', function() {
it('emits htmx:ws:error on connection error', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
let errorFired = false;
container.addEventListener('htmx:ws:error', () => {
errorFired = true;
});
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.triggerEvent('error', { message: 'Connection failed' });
await htmx.timeout(20);
assert.isTrue(errorFired);
});
it('emits htmx:ws:close on connection close', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
let closeFired = false;
container.addEventListener('htmx:ws:close', () => {
closeFired = true;
});
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.close();
await htmx.timeout(20);
assert.isTrue(closeFired);
});
it('attempts reconnection on close when config.reconnect is true', async function() {
htmx.config.websockets = { reconnect: true, reconnectDelay: 50 };
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
await htmx.timeout(50);
let firstWs = mockWebSocketInstances[0];
firstWs.close();
await htmx.timeout(100);
assert.isTrue(mockWebSocketInstances.length > 1, 'Should create new WebSocket for reconnection');
});
it('does not reconnect when config.reconnect is false', async function() {
htmx.config.websockets = { reconnect: false };
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
await htmx.timeout(50);
let firstWs = mockWebSocketInstances[0];
firstWs.close();
await htmx.timeout(100);
assert.equal(mockWebSocketInstances.length, 1);
});
it('emits htmx:ws:reconnect on reconnection attempt', async function() {
htmx.config.websockets = { reconnect: true, reconnectDelay: 50 };
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
let reconnectFired = false;
container.addEventListener('htmx:ws:reconnect', () => {
reconnectFired = true;
});
await htmx.timeout(50);
let firstWs = mockWebSocketInstances[0];
firstWs.close();
await htmx.timeout(100);
assert.isTrue(reconnectFired);
});
it('uses exponential backoff for reconnection', async function() {
htmx.config.websockets = {
reconnect: true,
reconnectDelay: 100,
reconnectMaxDelay: 1000
};
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
await htmx.timeout(50);
let reconnectTimes = [];
container.addEventListener('htmx:ws:reconnect', () => {
reconnectTimes.push(Date.now());
});
// First close
let ws = mockWebSocketInstances[mockWebSocketInstances.length - 1];
ws.close();
await htmx.timeout(200);
// Second close
ws = mockWebSocketInstances[mockWebSocketInstances.length - 1];
ws.close();
await htmx.timeout(300);
// Third close
ws = mockWebSocketInstances[mockWebSocketInstances.length - 1];
ws.close();
await htmx.timeout(500);
// Verify delays are increasing
assert.isTrue(reconnectTimes.length >= 3, 'Should have at least 3 reconnect attempts');
let firstDelay = reconnectTimes[1] - reconnectTimes[0];
let secondDelay = reconnectTimes[2] - reconnectTimes[1];
assert.isTrue(secondDelay >= firstDelay, 'Second delay should be >= first delay');
});
it('emits htmx:wsSendError when send fails', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<button hx-ws:send hx-trigger="click">Send</button>
</div>
`);
await htmx.timeout(50);
let errorFired = false;
container.addEventListener('htmx:wsSendError', () => {
errorFired = true;
});
// Close the connection
let ws = mockWebSocketInstances[0];
ws.close();
await htmx.timeout(20);
// Try to send
container.querySelector('button').click();
await htmx.timeout(20);
assert.isTrue(errorFired);
});
it('swaps non-JSON messages as raw HTML into hx-target', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content">
<div id="content">Original</div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateRawMessage('<hx-partial id="content"><p>Raw HTML update</p></hx-partial>');
await htmx.timeout(20);
assert.include(document.getElementById('content').innerHTML, 'Raw HTML update');
});
it('uses swap:none for non-JSON messages without hx-target', async function() {
let container = createProcessedHTML(`
<div id="ws-conn" hx-ws:connect="/ws/test" hx-trigger="load">
<div id="content">Original</div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
// Send raw HTML without hx-partial targeting — should not wipe connection element
ws.simulateRawMessage('<p>Should not appear</p>');
await htmx.timeout(20);
// Connection element content should be preserved
assert.include(document.getElementById('ws-conn').innerHTML, 'Original');
});
it('processes hx-partial in non-JSON messages even without hx-target', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<div id="widget">Old</div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateRawMessage('<hx-partial id="widget"><p>Updated via partial</p></hx-partial>');
await htmx.timeout(20);
assert.include(document.getElementById('widget').innerHTML, 'Updated via partial');
});
it('fires cancelable htmx:ws:rawMessage for non-JSON data', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content">
<div id="content">Original</div>
</div>
`);
await htmx.timeout(50);
let eventFired = false;
let receivedData = null;
container.addEventListener('htmx:ws:rawMessage', (e) => {
eventFired = true;
receivedData = e.detail.data;
});
let ws = mockWebSocketInstances[0];
ws.simulateRawMessage('<p>Raw content</p>');
await htmx.timeout(20);
assert.isTrue(eventFired);
assert.equal(receivedData, '<p>Raw content</p>');
});
it('prevents default swap when htmx:ws:rawMessage is cancelled', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content">
<div id="content">Original</div>
</div>
`);
await htmx.timeout(50);
container.addEventListener('htmx:ws:rawMessage', (e) => {
e.preventDefault();
});
let ws = mockWebSocketInstances[0];
ws.simulateRawMessage('<hx-partial id="content"><p>Should not appear</p></hx-partial>');
await htmx.timeout(20);
assert.equal(document.getElementById('content').textContent, 'Original');
});
});
// ========================================
// 6. CONFIGURATION TESTS
// ========================================
describe('Configuration', function() {
it('defers connection when explicit trigger is specified', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="click"></div>
`);
await htmx.timeout(50);
// Should not connect immediately when explicit trigger is set
assert.equal(mockWebSocketInstances.length, 0, 'Should not connect until trigger fires');
});
it('uses custom reconnectDelay from config', async function() {
htmx.config.websockets = {
reconnect: true,
reconnectDelay: 200
};
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
let closeTime = Date.now();
ws.close();
await htmx.timeout(100);
assert.equal(mockWebSocketInstances.length, 1, 'Should not reconnect yet');
await htmx.timeout(150);
assert.isTrue(mockWebSocketInstances.length > 1, 'Should reconnect after delay');
});
it('applies reconnectJitter when enabled', async function() {
htmx.config.websockets = {
reconnect: true,
reconnectDelay: 100,
reconnectJitter: true
};
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load"></div>
`);
await htmx.timeout(50);
// This test just ensures jitter doesn't break reconnection
let ws = mockWebSocketInstances[0];
ws.close();
await htmx.timeout(200);
assert.isTrue(mockWebSocketInstances.length > 1);
});
});
// ========================================
// 7. EVENT EMISSION TESTS
// ========================================
describe('Event Emission', function() {
it('emits htmx:before:ws:connect before connection', async function() {
let beforeFired = false;
let container = document.createElement('div');
container.innerHTML = '<div hx-ws:connect="/ws/test" hx-trigger="load"></div>';
container.addEventListener('htmx:before:ws:connect', () => {
beforeFired = true;
});
document.body.appendChild(container);
htmx.process(container);
await htmx.timeout(20);
assert.isTrue(beforeFired);
container.remove();
});
it('emits htmx:after:ws:connect after connection', async function() {
let afterFired = false;
let container = document.createElement('div');
container.innerHTML = '<div hx-ws:connect="/ws/test" hx-trigger="load"></div>';
container.addEventListener('htmx:after:ws:connect', () => {
afterFired = true;
});
document.body.appendChild(container);
htmx.process(container);
await htmx.timeout(50);
assert.isTrue(afterFired);
container.remove();
});
it('emits htmx:before:ws:send before sending', async function() {
let beforeFired = false;
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<button hx-ws:send hx-trigger="click">Send</button>
</div>
`);
div.addEventListener('htmx:before:ws:send', () => {
beforeFired = true;
});
await htmx.timeout(50);
div.querySelector('button').click();
await htmx.timeout(20);
assert.isTrue(beforeFired);
});
it('emits htmx:after:ws:send after sending', async function() {
let afterFired = false;
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<button hx-ws:send hx-trigger="click">Send</button>
</div>
`);
div.addEventListener('htmx:after:ws:send', () => {
afterFired = true;
});
await htmx.timeout(50);
div.querySelector('button').click();
await htmx.timeout(20);
assert.isTrue(afterFired);
});
it('allows modifying message via htmx:before:ws:send', async function() {
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<button hx-ws:send hx-trigger="click">Send</button>
</div>
`);
div.addEventListener('htmx:before:ws:send', (e) => {
e.detail.data.custom = 'added';
});
await htmx.timeout(50);
div.querySelector('button').click();
await htmx.timeout(20);
let ws = mockWebSocketInstances[0];
let sent = JSON.parse(ws.lastSent);
assert.equal(sent.custom, 'added');
});
it('can cancel send via htmx:before:ws:send', async function() {
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load">
<button hx-ws:send hx-trigger="click">Send</button>
</div>
`);
div.addEventListener('htmx:before:ws:send', (e) => {
e.preventDefault();
});
await htmx.timeout(50);
div.querySelector('button').click();
await htmx.timeout(20);
let ws = mockWebSocketInstances[0];
assert.isUndefined(ws.lastSent);
});
});
// ========================================
// 8. BACKWARD COMPATIBILITY TESTS
// ========================================
describe('Backward Compatibility', function() {
it('supports legacy ws-connect attribute with deprecation warning', async function() {
let warnCalled = false;
let originalWarn = console.warn;
console.warn = () => { warnCalled = true; };
let container = createProcessedHTML(`
<div hx-ext="ws" ws-connect="/ws/test" hx-trigger="load"></div>
`);
await htmx.timeout(50);
console.warn = originalWarn;
// Should still create connection
assert.equal(mockWebSocketInstances.length, 1);
// Should warn about deprecation
assert.isTrue(warnCalled);
});
it('supports legacy ws-send attribute', async function() {
let div = createProcessedHTML(`
<div hx-ext="ws" ws-connect="/ws/test" hx-trigger="load">
<button ws-send hx-trigger="click">Send</button>
</div>
`);
await htmx.timeout(50);
div.querySelector('button').click();
await htmx.timeout(20);
let ws = mockWebSocketInstances[0];
assert.isDefined(ws.lastSent);
});
});
// ========================================
// 9. INTEGRATION TESTS
// ========================================
describe('Integration Scenarios', function() {
it('handles chat application pattern', async function() {
let div = createProcessedHTML(`
<div hx-ws:connect="/ws/chat" hx-trigger="load" hx-target="#messages" hx-swap="beforeend">
<div id="messages"></div>
<form hx-ws:send hx-trigger="submit">
<input name="message" value="Hello">
<button type="submit">Send</button>
</form>
</div>
`);
await htmx.timeout(50);
// Send a message
let form = div.querySelector('form');
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await htmx.timeout(20);
// Simulate server response
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="messages"><p>Hello</p></hx-partial>'
});
await htmx.timeout(20);
assert.include(document.getElementById('messages').innerHTML, 'Hello');
});
it('handles live notifications pattern', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/notifications" hx-trigger="load" hx-target="#notifications">
<div id="notifications"></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
// Receive multiple notifications
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="notifications" hx-swap="afterbegin"><div class="notif">Notification 1</div></hx-partial>'
});
await htmx.timeout(20);
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<hx-partial id="notifications" hx-swap="afterbegin"><div class="notif">Notification 2</div></hx-partial>'
});
await htmx.timeout(20);
let notifications = document.getElementById('notifications');
assert.include(notifications.innerHTML, 'Notification 1');
assert.include(notifications.innerHTML, 'Notification 2');
});
it('handles real-time dashboard with multiple widgets', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/dashboard" hx-trigger="load">
<div id="widget1"></div>
<div id="widget2"></div>
<div id="widget3"></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: `
<hx-partial id="widget1"><span>Data 1</span></hx-partial>
<hx-partial id="widget2"><span>Data 2</span></hx-partial>
<hx-partial id="widget3"><span>Data 3</span></hx-partial>
`
});
await htmx.timeout(20);
assert.include(document.getElementById('widget1').innerHTML, 'Data 1');
assert.include(document.getElementById('widget2').innerHTML, 'Data 2');
assert.include(document.getElementById('widget3').innerHTML, 'Data 3');
});
});
// ========================================
// 10. TARGET AND SWAP OVERRIDE TESTS
// ========================================
describe('Target and Swap Overrides', function() {
it('respects target override from message envelope', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#default-target">
<div id="default-target">Default</div>
<div id="override-target">Override</div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<div>New Content</div>',
target: '#override-target'
});
await htmx.timeout(20);
assert.equal(document.getElementById('default-target').textContent, 'Default');
assert.include(document.getElementById('override-target').innerHTML, 'New Content');
});
it('respects swap override from message envelope', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content" hx-swap="innerHTML">
<div id="content"><p>Item 1</p></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<p>Item 2</p>',
swap: 'beforeend'
});
await htmx.timeout(20);
let content = document.getElementById('content');
assert.include(content.innerHTML, 'Item 1');
assert.include(content.innerHTML, 'Item 2');
});
it('uses element hx-target when message has no target', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#element-target">
<div id="element-target"></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<div>Content</div>'
});
await htmx.timeout(20);
assert.include(document.getElementById('element-target').innerHTML, 'Content');
});
it('uses element hx-swap when message has no swap', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content" hx-swap="beforeend">
<div id="content"><p>Item 1</p></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<p>Item 2</p>'
});
await htmx.timeout(20);
let content = document.getElementById('content');
assert.include(content.innerHTML, 'Item 1');
assert.include(content.innerHTML, 'Item 2');
});
it('message target overrides element hx-target', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#element-target">
<div id="element-target">Element Target</div>
<div id="message-target">Message Target</div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<div>New</div>',
target: '#message-target'
});
await htmx.timeout(20);
assert.equal(document.getElementById('element-target').textContent, 'Element Target');
assert.include(document.getElementById('message-target').innerHTML, 'New');
});
it('message swap overrides element hx-swap', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-trigger="load" hx-target="#content" hx-swap="innerHTML">
<div id="content"><p>Original</p></div>
</div>
`);
await htmx.timeout(50);
let ws = mockWebSocketInstances[0];
ws.simulateMessage({
channel: 'ui',
format: 'html',
payload: '<p>Appended</p>',
swap: 'beforeend'
});
await htmx.timeout(20);
let content = document.getElementById('content');
assert.include(content.innerHTML, 'Original');
assert.include(content.innerHTML, 'Appended');
});
});
});