diff --git a/package.json b/package.json index 39f3c3330..be598632e 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "build": "npm run build:iife && npm run build:esm && npm run build:minify && npm run build:ext && npm run build:htmax && npm run build:editors && npm run build:skills && npm run build:scripts && npm run build:types && npm run build:compress", "build:htmax": "EXTS='hx-sse hx-ws hx-preload hx-browser-indicator hx-download hx-optimistic hx-targets hx-live hx-history-cache hx-upsert hx-alpine-compat'; { cat dist/htmx.js; printf '\\nhtmx.version += \"-htmax\";\\nhtmx.config.historyCache ??= { disable: true };\\n'; for e in $EXTS; do cat dist/ext/$e.js; done; } > dist/htmax.js && terser --compress --mangle --source-map -o dist/htmax.min.js dist/htmax.js", "build:skills": "mkdir -p dist/skills && cp src/skills/*.md dist/skills/", - "build:iife": "sed -e 's/__proto__/@@PROTOKEY@@/g' -e 's/__/#/g' -e 's/@@PROTOKEY@@/__proto__/g' src/htmx.js > dist/htmx.js", - "build:esm": "sed -e 's/__proto__/@@PROTOKEY@@/g' -e 's/__/#/g' -e 's/@@PROTOKEY@@/__proto__/g' src/htmx.js > dist/htmx.esm.js && printf '\\nif (typeof window !== \"undefined\") window.htmx = htmx;\\nexport default htmx;\\n' >> dist/htmx.esm.js", + "build:iife": "sed -e 's/__proto__/@@PROTOKEY@@/g' -e 's/__/#/g' -e 's/@@PROTOKEY@@/__proto__/g' -e 's/this\\.#location/location/g' -e '/#location = window\\.location/d' src/htmx.js > dist/htmx.js", + "build:esm": "sed -e 's/__proto__/@@PROTOKEY@@/g' -e 's/__/#/g' -e 's/@@PROTOKEY@@/__proto__/g' -e 's/this\\.#location/location/g' -e '/#location = window\\.location/d' src/htmx.js > dist/htmx.esm.js && printf '\\nif (typeof window !== \"undefined\") window.htmx = htmx;\\nexport default htmx;\\n' >> dist/htmx.esm.js", "build:minify": "terser --compress --mangle --source-map -o dist/htmx.min.js dist/htmx.js && terser --compress --mangle --source-map -o dist/htmx.esm.min.js dist/htmx.esm.js", "build:ext": "mkdir -p dist/ext && for file in src/ext/*.js; do name=$(basename \"$file\" .js); cp \"$file\" \"dist/ext/$name.js\" && terser --compress --mangle --source-map -o \"dist/ext/$name.min.js\" \"dist/ext/$name.js\"; done", "build:editors": "mkdir -p dist/editors && cp -r src/editors/* dist/editors/", diff --git a/src/htmx.js b/src/htmx.js index ce6f83bfa..4b8bbe5bc 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -148,6 +148,7 @@ var htmx = (() => { __extMethods = new Map(); __approvedExt = ''; __registeredExt = new Set(); + __location = window.location; // mockable for testing #internalAPI; #Function = Function; #AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; @@ -676,11 +677,11 @@ var htmx = (() => { // Returns true if the response was fully handled by a header. __handleHeadersAndMaybeReturnEarly(ctx) { if (ctx.hx.refresh === 'true') { // HX-Refresh - location.reload(); + this.__location.reload(); return true } if (ctx.hx.redirect) { // HX-Redirect - location.href = ctx.hx.redirect; + this.__location.href = ctx.hx.redirect; return true } if (ctx.hx.location) { // HX-Location @@ -1654,7 +1655,7 @@ var htmx = (() => { let historyElt = document.querySelector(this.__prefixSelector('[hx-history-elt]')) || document.body; if (this.__trigger(document, "htmx:before:history:restore", {path, cacheMiss: true})) { if (this.config.history === "reload") { - location.reload(); + this.__location.reload(); } else { this.#historyAbort = new AbortController(); return this.ajax('GET', path, { diff --git a/test/tests/attributes/hx-on.js b/test/tests/attributes/hx-on.js index d81e55306..352bf68a2 100644 --- a/test/tests/attributes/hx-on.js +++ b/test/tests/attributes/hx-on.js @@ -640,4 +640,25 @@ describe('hx-on="eventSpec -> code" syntax', function() { window.fooCount.should.equal(1); delete window.fooCount; }); + + // Gap #16: hx-on error trigger + it('triggers htmx:error when hx-on handler throws', function(done) { + createProcessedHTML(''); + document.addEventListener('htmx:error', function(evt) { + assert.isNotNull(evt.detail.error); + assert.equal(evt.detail.error.message, 'test error'); + done(); + }, { once: true }); + find('#btn').click(); + }); + + // Gap: hx-on with invalid separator (hyphen instead of colon) + it('skips hx-on attributes with wrong separator', async function() { + let div = createProcessedHTML('
test
'); + let clicked = false; + div.addEventListener('click', () => { clicked = true; }); + div.click(); + await htmx.timeout(10); + assert.isTrue(clicked); + }); }) diff --git a/test/tests/unit/__collectFormData.js b/test/tests/unit/__collectFormData.js index c852ceeec..64fb10c39 100644 --- a/test/tests/unit/__collectFormData.js +++ b/test/tests/unit/__collectFormData.js @@ -245,4 +245,45 @@ describe('__collectFormData unit tests', function() { let formData = htmx.__collectFormData(elt, null, null, false, false); assert.equal(formData.get('tenantId'), 'acme'); }); + + it('includes file input via hx-include', async function () { + mockResponse('POST', '/upload', 'OK'); + createProcessedHTML( + '' + + '' + ); + let fileInput = find('#file-input'); + let file = new File(['test content'], 'test.txt', { type: 'text/plain' }); + let dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + fileInput.files = dataTransfer.files; + find('#btn').click(); + await forRequest(); + let lastCall = fetchMock.calls[fetchMock.calls.length - 1]; + let files = lastCall.request.body.getAll('myfile'); + assert.equal(files.length, 1); + assert.equal(files[0].name, 'test.txt'); + }); + + it('includes multiple files via hx-include', async function () { + mockResponse('POST', '/upload', 'OK'); + createProcessedHTML( + '' + + '' + ); + let fileInput = find('#multi-file'); + let file1 = new File(['content1'], 'file1.txt', { type: 'text/plain' }); + let file2 = new File(['content2'], 'file2.txt', { type: 'text/plain' }); + let dataTransfer = new DataTransfer(); + dataTransfer.items.add(file1); + dataTransfer.items.add(file2); + fileInput.files = dataTransfer.files; + find('#btn').click(); + await forRequest(); + let lastCall = fetchMock.calls[fetchMock.calls.length - 1]; + let files = lastCall.request.body.getAll('files'); + assert.equal(files.length, 2); + assert.equal(files[0].name, 'file1.txt'); + assert.equal(files[1].name, 'file2.txt'); + }); }); \ No newline at end of file diff --git a/test/tests/unit/__executeJavaScript.js b/test/tests/unit/__executeJavaScript.js index 2a8545243..41c62d634 100644 --- a/test/tests/unit/__executeJavaScript.js +++ b/test/tests/unit/__executeJavaScript.js @@ -13,4 +13,19 @@ describe('__executeJavaScript', function() { run().should.equal(42); }); + it('applies nonce to inline scripts when config.inlineScriptNonce is set', async function () { + mockResponse('GET', '/test', '
'); + htmx.config.inlineScriptNonce = 'test-nonce-123'; + try { + createProcessedHTML('
'); + find('#btn').click(); + await forRequest(); + await htmx.timeout(10); + let script = find('#test-script'); + assert.equal(script.nonce, 'test-nonce-123'); + } finally { + htmx.config.inlineScriptNonce = null; + } + }); + }); diff --git a/test/tests/unit/__findAllExt.js b/test/tests/unit/__findAllExt.js index 2af636a12..746cc2ae3 100644 --- a/test/tests/unit/__findAllExt.js +++ b/test/tests/unit/__findAllExt.js @@ -230,4 +230,39 @@ describe('__findAllExt unit tests', function() { assert.include(results[0].className, 'outer') }) + // Gap #19: 'this' selector without thisAttr + it('handles "this" selector returning the element itself', function() { + let div = createProcessedHTML('
'); + let results = htmx.__findAllExt(div, 'this'); + assert.equal(results.length, 1); + assert.equal(results[0], div); + }) + + // Gap #20: getRootNode document fallback for disconnected element + it('falls back to document for disconnected element', function() { + let detached = document.createElement('div'); + createProcessedHTML(''); + let results = htmx.__findAllExt(detached, '.doc-span'); + assert.equal(results.length, 1); + }) + + // Gap #18: Shadow DOM host resolution + it('handles "host" selector returning shadow host', function() { + let host = createProcessedHTML('
'); + let shadow = host.attachShadow({ mode: 'open' }); + let inner = document.createElement('div'); + shadow.appendChild(inner); + + let results = htmx.__findAllExt(inner, 'host'); + assert.equal(results.length, 1); + assert.equal(results[0], host); + }) + + // Public findAll method + it('htmx.findAll delegates to __findAllExt', function() { + createProcessedHTML('
'); + let results = htmx.findAll(document, '.foo'); + assert.equal(results.length, 2); + }) + }); \ No newline at end of file diff --git a/test/tests/unit/__handleHistoryUpdate.js b/test/tests/unit/__handleHistoryUpdate.js index ddc024b2c..800c9353e 100644 --- a/test/tests/unit/__handleHistoryUpdate.js +++ b/test/tests/unit/__handleHistoryUpdate.js @@ -90,4 +90,54 @@ describe('__handleHistoryUpdate unit tests', function() { assert.include(window.location.href, '/redirected-path?foo=bar') }) + it('restores history via popstate event when Navigation API unavailable', async function() { + let savedNavigation = window.navigation; + delete window.navigation; + htmx.__initHistoryHandling(); + try { + playground().innerHTML = '

current

'; + htmx.process(playground()); + history.replaceState({ htmx: true }, '', '/popstate-test-a'); + htmx.__pushUrlIntoHistory('/popstate-test-b'); + playground().innerHTML = '

page B

'; + mockResponse('GET', '/popstate-test-a', '

restored via popstate

'); + history.back(); + await forRequest(); + playground().textContent.should.include('restored via popstate'); + } finally { + if (savedNavigation) window.navigation = savedNavigation; + } + }) + + it('returns early when config.history is false', function() { + let savedHistory = htmx.config.history; + htmx.config.history = false; + try { + htmx.__initHistoryHandling(); + assert.equal(htmx.config.history, false); + } finally { + htmx.config.history = savedHistory; + } + }) + + it('reloads page on history restore when config.history is "reload"', async function() { + let originalLocation = htmx.__location; + let originalHistoryConfig = htmx.config.history; + let reloadCalled = false; + htmx.__location = { + reload: function() { reloadCalled = true; }, + pathname: '/test', search: '', href: 'http://localhost/test' + }; + htmx.config.history = 'reload'; + try { + history.replaceState({ htmx: true }, '', '/test'); + htmx.__restoreHistory({ htmx: true }); + await htmx.timeout(10); + assert.isTrue(reloadCalled); + } finally { + htmx.__location = originalLocation; + htmx.config.history = originalHistoryConfig; + } + }) + }); diff --git a/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js b/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js index 2914ae1b1..60323571c 100644 --- a/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js +++ b/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js @@ -1,10 +1,16 @@ describe('__handleHxHeadersAndMaybeReturnEarly unit tests', function() { + let originalLocation; + beforeEach(function() { setupTest(); + // Save original location reference + originalLocation = htmx.__location; }); afterEach(function() { + // Restore original location + htmx.__location = originalLocation; cleanupTest(); }); @@ -34,4 +40,158 @@ describe('__handleHxHeadersAndMaybeReturnEarly unit tests', function() { assert.isNotOk(result) }) + // HX-Refresh header tests + it('calls location.reload() on HX-Refresh: true', function () { + let reloadCalled = false; + htmx.__location = { reload: function() { reloadCalled = true; } }; + + let ctx = { + hx: { refresh: 'true' }, + sourceElement: createProcessedHTML('
') + }; + + let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx); + + assert.isTrue(result); + assert.isTrue(reloadCalled); + }) + + it('does not reload on HX-Refresh: false', function () { + let reloadCalled = false; + htmx.__location = { reload: function() { reloadCalled = true; } }; + + let ctx = { + hx: { refresh: 'false' }, + sourceElement: createProcessedHTML('
') + }; + + let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx); + + assert.isNotOk(result); + assert.isFalse(reloadCalled); + }) + + // HX-Redirect header tests + it('sets location.href on HX-Redirect', function () { + let redirectUrl = null; + htmx.__location = { + get href() { return window.location.href; }, + set href(val) { redirectUrl = val; } + }; + + let ctx = { + hx: { redirect: 'https://example.com/new-page' }, + sourceElement: createProcessedHTML('
') + }; + + let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx); + + assert.isTrue(result); + assert.equal(redirectUrl, 'https://example.com/new-page'); + }) + + // HX-Location header tests + it('calls ajax on HX-Location with simple path', function () { + let ajaxCalled = false; + let ajaxArgs = null; + const originalAjax = htmx.ajax; + htmx.ajax = function(method, path, opts) { + ajaxCalled = true; + ajaxArgs = { method, path, opts }; + }; + + try { + let ctx = { + hx: { location: '/new-path' }, + sourceElement: createProcessedHTML('
') + }; + + let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx); + + assert.isTrue(result); + assert.isTrue(ajaxCalled); + assert.equal(ajaxArgs.method, 'GET'); + assert.equal(ajaxArgs.path, '/new-path'); + assert.equal(ajaxArgs.opts.push, 'true'); + } finally { + htmx.ajax = originalAjax; + } + }) + + it('calls ajax on HX-Location with JSON config', function () { + let ajaxCalled = false; + let ajaxArgs = null; + const originalAjax = htmx.ajax; + htmx.ajax = function(method, path, opts) { + ajaxCalled = true; + ajaxArgs = { method, path, opts }; + }; + + try { + let ctx = { + hx: { location: '{"path":"/test", "target":"#result"}' }, + sourceElement: createProcessedHTML('
') + }; + + let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx); + + assert.isTrue(result); + assert.isTrue(ajaxCalled); + assert.equal(ajaxArgs.method, 'GET'); + assert.equal(ajaxArgs.path, '/test'); + assert.equal(ajaxArgs.opts.target, '#result'); + assert.equal(ajaxArgs.opts.push, 'true'); + } finally { + htmx.ajax = originalAjax; + } + }) + + it('calls ajax on HX-Location with HCON config', function () { + let ajaxCalled = false; + let ajaxArgs = null; + const originalAjax = htmx.ajax; + htmx.ajax = function(method, path, opts) { + ajaxCalled = true; + ajaxArgs = { method, path, opts }; + }; + + try { + let ctx = { + hx: { location: 'path:/test target:#result' }, + sourceElement: createProcessedHTML('
') + }; + + let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx); + + assert.isTrue(result); + assert.isTrue(ajaxCalled); + assert.equal(ajaxArgs.method, 'GET'); + assert.equal(ajaxArgs.path, '/test'); + assert.equal(ajaxArgs.opts.target, '#result'); + } finally { + htmx.ajax = originalAjax; + } + }) + + it('respects push:false in HX-Location', function () { + let ajaxArgs = null; + const originalAjax = htmx.ajax; + htmx.ajax = function(method, path, opts) { + ajaxArgs = { method, path, opts }; + }; + + try { + let ctx = { + hx: { location: '{"path":"/test", "push":"false"}' }, + sourceElement: createProcessedHTML('
') + }; + + htmx.__handleHeadersAndMaybeReturnEarly(ctx); + + assert.equal(ajaxArgs.opts.push, 'false'); + } finally { + htmx.ajax = originalAjax; + } + }) + }); diff --git a/test/tests/unit/__shouldBoost.js b/test/tests/unit/__shouldBoost.js index 8b522650c..3ba4d8083 100644 --- a/test/tests/unit/__shouldBoost.js +++ b/test/tests/unit/__shouldBoost.js @@ -171,5 +171,11 @@ describe('__shouldBoost() unit tests', function() { const result = htmx.__shouldBoost(input); assert.isUndefined(result); }); + + it('returns false for malformed URLs that cause URL constructor to throw', function() { + createProcessedHTML('
Link
'); + let link = find('#link'); + assert.isUndefined(link._htmx?.boosted); + }); }); }); diff --git a/test/tests/unit/__showHideIndicators.js b/test/tests/unit/__showHideIndicators.js index 4b0d08487..8e1b95e94 100644 --- a/test/tests/unit/__showHideIndicators.js +++ b/test/tests/unit/__showHideIndicators.js @@ -200,4 +200,27 @@ describe('__showIndicators / __hideIndicators unit tests', function() { assert.equal(indicators.length, 2); }) + it('keeps indicators visible when HX-Redirect is received', async function() { + let originalLocation = htmx.__location; + let redirectUrl = null; + htmx.__location = { + get href() { return window.location.href; }, + set href(val) { redirectUrl = val; } + }; + try { + mockResponse('GET', '/test', '', { headers: { 'HX-Redirect': '/other-page' } }); + createProcessedHTML( + '
Loading...
' + + '' + ); + find('#btn').click(); + await forRequest(); + await htmx.timeout(10); + assert.equal(redirectUrl, '/other-page'); + assert.isTrue(find('#indicator').classList.contains('htmx-request')); + } finally { + htmx.__location = originalLocation; + } + }) + }); diff --git a/test/tests/unit/ajax.js b/test/tests/unit/ajax.js index cb3ab30c7..5b57ef4f3 100644 --- a/test/tests/unit/ajax.js +++ b/test/tests/unit/ajax.js @@ -309,4 +309,38 @@ describe('ajax() unit Tests', function() { assert.include(div.innerHTML, '

old

'); assert.include(div.innerHTML, 'new'); }); + + it('sets full URL for cross-origin GET requests with query params', async function() { + let capturedAction; + document.addEventListener('htmx:before:request', function handler(evt) { + capturedAction = evt.detail.ctx.request.action; + evt.preventDefault(); + document.removeEventListener('htmx:before:request', handler); + }); + createProcessedHTML(''); + find('#btn').click(); + await htmx.timeout(50); + assert.isTrue(capturedAction.startsWith('https://other-domain.com/api')); + assert.include(capturedAction, 'foo=bar'); + }); + + it('triggers htmx:error when __createRequestContext throws', async function() { + let errorTriggered = false; + let caughtError = null; + let btn = createProcessedHTML(''); + btn.addEventListener('htmx:error', (e) => { + errorTriggered = true; + caughtError = e.detail.error; + }, { once: true }); + let original = htmx.__createRequestContext; + htmx.__createRequestContext = function() { throw new Error('context creation failed'); }; + try { + btn.click(); + await htmx.timeout(50); + assert.isTrue(errorTriggered, 'htmx:error should have been triggered'); + assert.equal(caughtError.message, 'context creation failed'); + } finally { + htmx.__createRequestContext = original; + } + }); }); diff --git a/test/tests/unit/bootstrap.js b/test/tests/unit/bootstrap.js index e9ec5e892..956dcc281 100644 --- a/test/tests/unit/bootstrap.js +++ b/test/tests/unit/bootstrap.js @@ -226,4 +226,25 @@ describe('bootstrap unit tests', function() { 'Public properties have changed. Expected: ' + JSON.stringify(expectedPublicProperties) + ', Got: ' + JSON.stringify(ownProperties)); }) + + it('constructor uses setTimeout when document is already loaded', async function() { + assert.notEqual(document.readyState, 'loading', 'document should already be loaded') + let HtmxClass = Object.getPrototypeOf(htmx).constructor + let setTimeoutCalled = false + let originalSetTimeout = window.setTimeout + window.setTimeout = function(fn, ...args) { + if (fn.name === 'init' || (fn.toString && fn.toString().includes('__initHistoryHandling'))) { + setTimeoutCalled = true + } + return originalSetTimeout.call(this, fn, ...args) + } + try { + let newInstance = new HtmxClass() + await new Promise(r => originalSetTimeout(r, 100)) + assert.isTrue(setTimeoutCalled, 'setTimeout should have been called for init') + assert.isDefined(newInstance.config) + } finally { + window.setTimeout = originalSetTimeout + } + }) }) diff --git a/test/tests/unit/morph.js b/test/tests/unit/morph.js index 98c9a555b..3c9defe79 100644 --- a/test/tests/unit/morph.js +++ b/test/tests/unit/morph.js @@ -990,4 +990,65 @@ describe('Morph Swap Styles Tests', function() { }); }); + describe('future matches', function() { + // Gap #21: __matchesUpcomingSibling returns true when old element matches future sibling + it('preserves element that matches upcoming identical sibling during morph reorder', async function() { + mockResponse('GET', '/test', '
Y
X
X
'); + const div = createProcessedHTML('
X
Y
X
'); + + await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'}); + + assert.equal(div.children.length, 3); + assert.equal(div.children[0].className, 'b'); + assert.equal(div.children[1].className, 'a'); + assert.equal(div.children[2].className, 'a'); + }); + }); + + describe('pantry move', function() { + // Gap #22: ID element moves between DOM branches via pantry + it('handles deeply nested ID that moves to different branch', async function() { + mockResponse('GET', '/test', '
Deep moved
'); + const div = createProcessedHTML('
Deep original
'); + const deep = div.querySelector('#deep'); + const branch1 = div.querySelector('#branch1'); + const branch2 = div.querySelector('#branch2'); + + await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'}); + + assert.equal(div.querySelector('#deep'), deep); + assert.equal(deep.textContent, 'Deep moved'); + assert.equal(deep.parentElement, branch2); + assert.equal(branch1.children.length, 0); + }); + }); + + describe('moveBefore fallback', function() { + // Gap #23: moveBefore throws, falls back to insertBefore + it('falls back to insertBefore when moveBefore throws', async function() { + let originalMoveBefore = Element.prototype.moveBefore; + Element.prototype.moveBefore = function() { + throw new Error('moveBefore not supported'); + }; + + try { + mockResponse('GET', '/morph-test', '
Item 2
Item 1
'); + createProcessedHTML('
Item 1
Item 2
'); + + find('#container').click(); + await forRequest(); + + let container = find('#container'); + assert.isNotNull(container); + assert.equal(container.children.length, 2); + } finally { + if (originalMoveBefore) { + Element.prototype.moveBefore = originalMoveBefore; + } else { + delete Element.prototype.moveBefore; + } + } + }); + }); + }); \ No newline at end of file diff --git a/test/tests/unit/swap.js b/test/tests/unit/swap.js index c509e9930..fdb9f7f8e 100644 --- a/test/tests/unit/swap.js +++ b/test/tests/unit/swap.js @@ -749,4 +749,107 @@ describe('swap() unit tests', function() { document.activeElement.selectionEnd.should.equal(11) }) + // Gap #11: textContent swap child cleanup + it('cleans up htmx-powered descendants on textContent swap', async function() { + mockResponse('GET', '/test', 'Plain text content'); + + createProcessedHTML( + '
' + + '' + + '' + + '
' + + '' + ); + + assert.isTrue(find('#child1').hasAttribute('data-htmx-powered')); + assert.isTrue(find('#child2').hasAttribute('data-htmx-powered')); + + find('#trigger').click(); + await forRequest(); + await htmx.timeout(10); + + assert.isUndefined(find('#child1')); + assert.isUndefined(find('#child2')); + assert.equal(find('#target').textContent, 'Plain text content'); + }) + + // Gap #9: __setFocus catch block when focus() throws + it('handles web component that throws on focus during restoration', async function() { + if (!customElements.get('broken-focus')) { + customElements.define('broken-focus', class extends HTMLElement { + focus() { throw new Error('focus not supported'); } + }); + } + + mockResponse('GET', '/test', 'Updated'); + createProcessedHTML('
'); + + find('#field').focus(); + await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerHTML'}); + + let newElt = find('#field'); + assert.isNotNull(newElt); + assert.equal(newElt.tagName, 'BROKEN-FOCUS'); + }) + + // Gap #24: Transition queue task execution when startViewTransition unavailable + it('swap works with transition:true when startViewTransition is unavailable', async function() { + mockResponse('GET', '/test', '
Swapped!
'); + const originalStartViewTransition = document.startViewTransition; + document.startViewTransition = undefined; + + try { + createProcessedHTML('
Original
'); + find('#btn').click(); + await forRequest(); + await htmx.timeout(50); + assert.include(find('#target').innerHTML, 'Swapped!'); + } finally { + document.startViewTransition = originalStartViewTransition; + } + }) + + // Gap #25: view transition catch block when task throws + it('catches error when view transition is aborted', async function() { + let savedTransitions = htmx.config.transitions; + htmx.config.transitions = true; + mockResponse('GET', '/test', '
response
'); + let btn = createProcessedHTML(''); + + document.addEventListener('htmx:before:viewTransition', (e) => { + e.detail.task = async () => { throw new Error('transition aborted'); }; + }, { once: true }); + + btn.click(); + await forRequest(); + await htmx.timeout(50); + htmx.config.transitions = savedTransitions; + assert.isTrue(true); + }) + + // Gap #12: Extension content transformation result + it('uses array result from extension handle_swap', async function() { + mockResponse('GET', '/test', '
Content
'); + htmx.registerExtension('test-swap-ext', { + handle_swap: function(swapStyle, target, fragment, swapSpec) { + if (swapStyle === 'custom-swap') { + let newEl = document.createElement('span'); + newEl.id = 'ext-result'; + newEl.textContent = 'Extension swapped'; + target.appendChild(newEl); + return [newEl]; + } + } + }); + createProcessedHTML( + '
' + + '' + ); + find('#btn').click(); + await forRequest(); + await htmx.timeout(10); + assert.isNotNull(find('#ext-result')); + assert.equal(find('#ext-result').textContent, 'Extension swapped'); + }) + })