htmx/test/lib/helpers.js
Stu Kennedy 7510030e59
Websocket extension for HTMX (#3547)
* feat: add hx-ws WebSocket extension with accompanying tests and debug utilities.

* feat: Refine WebSocket extension initialization and processing logic, preventing re-initialization and ensuring comprehensive element handling.

* refactor: Enhance WebSocket connection handling with improved event triggering and dynamic configuration management

* refactor: Introduce dynamic configuration management for WebSocket connections, consolidating default settings into a dedicated function for improved maintainability and clarity.

* refactor: Update WebSocket extension to improve reconnection logic and dynamic configuration handling, enhancing maintainability and clarity.

* docs: Expand WebSocket extension documentation with detailed architecture, attributes, message formats, and example use cases for improved clarity and usability.

* refactor: Update WebSocket message structure to include default values for channel and format, enhancing clarity in documentation and implementation.

* feat: Add default `channel` and `format` values to WebSocket messages if not provided.
2025-11-23 11:38:02 -07:00

220 lines
6.6 KiB
JavaScript

if (typeof installFetchMock !== 'undefined') {
installFetchMock()
}
//================================================================================
// Code to prevent accidental navigation
//================================================================================
let linkNavPreventer = (e) => {
let anchor = e.target.closest('a');
if (!anchor || !anchor.href) return;
if (e.defaultPrevented) return;
if (e.button !== 0) return; // Not left click
if (e.ctrlKey || e.metaKey || e.shiftKey) return; // Modifier keys
if (anchor.download) return;
if (anchor.href.startsWith('javascript:')) return;
if (anchor.href.startsWith('#')) return; // Hash only
e.preventDefault();
console.warn('Navigation prevented:', anchor.href, new Error().stack);
};
let submitPreventer = (e) => {
if (e.defaultPrevented) return;
e.preventDefault();
console.warn('Form submission prevented:', e, new Error().stack);
};
//================================================================================
// Test life cycle helpers
//================================================================================
// can be enabled for a test by calling debug(this)
let testDebugging = false;
const savedUrl = window.location.href;
function setupTest(test) {
if (test) {
console.log("RUNNING TEST: ", test.title);
}
if (!playground().hasAttribute("data-is-navsafe")) {
// Catch anchor navigations
playground().addEventListener('click', linkNavPreventer);
// Catch form submissions
playground().addEventListener('submit', submitPreventer);
playground().setAttribute("data-is-navsafe", 'true')
}
}
function cleanupTest() {
let pg = playground()
if (pg && !testDebugging) {
pg.innerHTML = ''
}
testDebugging = false;
if (typeof fetchMock !== 'undefined' && fetchMock.reset) {
// Check for pending requests before cleaning up
if (fetchMock.pendingRequests && fetchMock.pendingRequests.length > 0) {
console.warn(`WARNING: Test is leaving ${fetchMock.pendingRequests.length} request(s) in flight. Tests should wait for all requests to complete.`);
}
fetchMock.reset()
}
history.replaceState(null, '', savedUrl);
}
//================================================================================
// Extension backup/restore helpers
//================================================================================
function backupExtensions() {
return {
extMethods: new Map(htmx.__extMethods),
registeredExt: new Set(htmx.__registeredExt),
approvedExt: htmx.__approvedExt
};
}
function restoreExtensions(backup) {
htmx.__extMethods = backup.extMethods;
htmx.__registeredExt = backup.registeredExt;
htmx.__approvedExt = backup.approvedExt;
}
function clearExtensions() {
htmx.__extMethods.clear();
htmx.__registeredExt.clear();
}
function debug(test) {
test.timeout(0);
testDebugging = true;
}
//================================================================================
// HTML creation helpers
//================================================================================
// This function processes the content immediately (rather than waiting for the mutation observer)
// Prefer using this function for testing!
function createProcessedHTML(innerHTML) {
let pg = playground();
if (pg) {
pg.innerHTML = innerHTML
htmx.process(pg)
}
return pg.firstElementChild
}
// This function waits for the mutation observer to process the new content
function createHTMLNoProcessing(html) {
const div = document.createElement('div');
div.innerHTML = html;
const elt = div.firstElementChild;
playground().appendChild(elt);
return elt
}
// This function creates a disconnected node
function createDisconnectedHTML(html) {
const div = document.createElement('div');
div.innerHTML = html;
const elt = div.firstElementChild;
return elt
}
//================================================================================
// Fetch mock response helpers
//================================================================================
function mockResponse(action, pattern, response, options = {}) {
fetchMock.mockResponse(action, pattern, response, options);
}
function mockFailure(action, pattern, message = 'Network failure') {
fetchMock.mockFailure(action, pattern, message);
}
function mockStreamResponse(url) {
const controllers = [];
const enc = new TextEncoder();
// Return a fresh stream each time the URL is fetched
fetchMock.mockResponse('GET', url, () => {
let ctrl;
const body = new ReadableStream({ start(c) { ctrl = c; controllers.push(c); } });
const response = new MockResponse(body, {
headers: { 'Content-Type': 'text/event-stream' }
});
response.body = body;
return response;
});
return {
send(data, event, id) {
// Send to the most recent active controller
const ctrl = controllers[controllers.length - 1];
if (!ctrl) return;
let msg = (event ? `event: ${event}\n` : '') + (id ? `id: ${id}\n` : '') + `data: ${data}\n\n`;
ctrl.enqueue(enc.encode(msg));
},
close: () => {
// Close the most recent controller
const ctrl = controllers[controllers.length - 1];
if (ctrl) ctrl.close();
},
error: (e) => {
const ctrl = controllers[controllers.length - 1];
if (ctrl) ctrl.error(e);
}
};
}
function lastFetch() {
let lastCall = fetchMock.getLastCall();
assert.isNotNull(lastCall, "No fetch call was made!")
return lastCall;
}
//======================================================================
// General test helper utilities
//======================================================================
function waitForEvent(eventName, timeout = 200) {
return htmx.forEvent(eventName, testDebugging ? 0 : timeout);
}
function forRequest(timeout = 200) {
return waitForEvent("htmx:finally:request", timeout);
}
function forRequestWithDelay(timeout = 200) {
return htmx.timeout(50).then(() => forRequest(timeout));
}
function playground() {
return htmx.find("#test-playground");
}
function find(selector) {
return htmx.find(playground(), selector)
}
// ==============================================================================
// Assertion Helpers
// ==============================================================================
function assertPropertyIs(css, property, content) {
let elt = find(css);
if (!elt) {
assert.fail("Could not find element with css '" + css + "' in :\n\n" + playground().innerHTML + "\n\n")
}
assert.equal(elt[property], content)
}
function assertTextContentIs(css, content) {
assertPropertyIs(css, 'textContent', content)
}