diff --git a/src/pat/inject/documentation.md b/src/pat/inject/documentation.md index d439ac896..8d93e1f85 100644 --- a/src/pat/inject/documentation.md +++ b/src/pat/inject/documentation.md @@ -387,5 +387,6 @@ pat-inject fires several JavaScript events which bubble up the DOM tree: | `pat-inject-content-loaded` | jQuery | images within injected content | true | Triggered on images within the injected content when those images are loaded. | | `pat-inject-missingSource` | jQuery | trigger which caused the injection | true | Triggered when no to-be-injected source could be found. | | `pat-inject-missingTarget` | jQuery | trigger which caused the injection | true | Triggered when no target could be found. | +| `pat-inject-before-history-update` | JavaScript | document | true | Trigger just before the history is update, if `history: record` is set. | Please note: `jQuery.trigger` events can be catched with jQuery only while JavaScript `dispatchEvent` events can be catched with bare JavaScript `addEventListener` and `jQuery.on`. diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 0e75b6873..91af8ed3a 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -463,7 +463,7 @@ const inject = { return $target; }, - _performInjection(target, $el, $sources, cfg, trigger, $title) { + _performInjection(target, $el, $sources, cfg, trigger, ev) { /* Called after the XHR has succeeded and we have a new $sources * element to inject. */ @@ -472,7 +472,9 @@ const inject = { const method = cfg.sourceMod === "content" ? "innerHTML" : "outerHTML"; // There might be multiple sources, so we need to loop over them. // Access them with "innerHTML" or "outerHTML" depending on the sourceMod. - const sources_string = [...$sources].map(source => source[method]).join("\n"); + const sources_string = [...$sources] + .map((source) => source[method]) + .join("\n"); wrapper.innerHTML = sources_string; for (const img of wrapper.content.querySelectorAll("img")) { @@ -494,32 +496,92 @@ const inject = { // Now the injection actually happens. if (this._inject(trigger, source_nodes, target, cfg)) { // Update history - this._update_history(cfg, trigger, $title); + this._update_history(cfg, ev); // Post-injection this._afterInjection($el, cfg.$created_target || $(source_nodes), cfg); } }, - _update_history(cfg, trigger, $title) { + _update_history(cfg, ev) { // History support. if subform is submitted, append form params if (cfg.history !== "record" || !history?.pushState) { return; } + + // We have a URL changing injection. We also need to update other data: + // - title + // - canonical link + // - base url + + const html = ev?.jqxhr?.responseText; + + // Update the document's title. + const source_title = html?.match(/]*>(.*?)<\/title>/i)?.[0]; + const target_title = document.querySelector("head title"); + // Title: update/add but never remove + this._update_head(source_title, target_title, false); + + // Update the element. + const source_canonical = html?.match( + /]*\brel\s*=\s*["']canonical["'][^>]*>/i + )?.[0]; + const target_canonical = document.querySelector("head link[rel=canonical]"); + // Canonical: update/add/remove as needed + this._update_head(source_canonical, target_canonical, true); + + // Update the base tag. + const source_base = html?.match(/]*>/i)?.[0]; + const target_base = document.querySelector("head base"); + // Base: update/add/remove as needed + this._update_head(source_base, target_base, true); + + document.dispatchEvent( + new Event("pat-inject-before-history-update", { detail: { ajax_event: ev } }) + ); + + // At last position - other patterns can react on already changed title, + // canonical or base. let url = cfg.url; if (cfg.params) { const glue = url.indexOf("?") > -1 ? "&" : "?"; url = `${url}${glue}${cfg.params}`; } history.pushState({ url: url }, "", url); - // Also inject title element if we have one - if ($title?.length) { - const title_el = document.querySelector("title"); - if (title_el) { - this._inject(trigger, $title, title_el, { - action: "element", - }); + }, + + _update_head(source_string, target_el, delete_when_empty = true) { + if (!source_string) { + // No source element found in the HTML + if (target_el && delete_when_empty) { + // Source doesn't have the target element equivalent. Remove the + // target. This prevents incorrect canonical and base URLs after a + // navigation URL change. + target_el.remove(); + } + // (If delete_when_empty is false, keep the target - used for title) + // (If no target_el exists, nothing to do) + return; + } + + const parser = new DOMParser(); + const parsed = parser.parseFromString(source_string, "text/html"); + // Head elements (title, link, base) are placed in parsed.head. + const source_el = parsed.head.children[0]; + + if (source_el) { + // Source is present - update or add + if (target_el) { + // Replace existing target element + target_el.replaceWith(source_el); + } else { + // Add new element to head + document.head.prepend(source_el); } + } else if (target_el && delete_when_empty) { + // Source string exists but doesn't parse to an element. Remove target. + target_el.remove(); } + // (If source doesn't parse to element AND no target, do nothing) }, _afterInjection($el, $injected, cfg) { @@ -592,17 +654,6 @@ const inject = { data, ev, ]); - /* pick the title source for dedicated handling later - Title - if present - is always appended at the end. */ - let $title; - if ( - sources$ && - sources$[sources$.length - 1] && - sources$[sources$.length - 1][0] && - sources$[sources$.length - 1][0].nodeName === "TITLE" - ) { - $title = sources$[sources$.length - 1]; - } for (const [idx1, cfg] of cfgs.entries()) { const perform_inject = () => { @@ -614,7 +665,7 @@ const inject = { sources$[idx1], cfg, ev.target, - $title + ev ); } } @@ -834,19 +885,20 @@ const inject = { _sourcesFromHtml(html, url, sources) { const $html = this._parseRawHtml(html, url); - return sources.map((source) => { + const $sources = sources.map((source) => { + // Special case for body if (source === "body") { source = "#__original_body"; } + + // Special case for "none"; if (source === "none") { return $(""); } const $source = $html.find(source); if ($source.length === 0) { - if (source != "title") { - log.warn("No source elements for selector:", source, $html); - } + log.warn("No source elements for selector:", source, $html); } $source.find('a[href^="#"]').each((idx, el_) => { @@ -868,6 +920,7 @@ const inject = { }); return $source; }); + return $sources; }, _rebaseAttrs: { @@ -973,7 +1026,6 @@ const inject = { _parseRawHtml(html, url = "") { // remove script tags and head and replace body by a div - const title = html.match(/\(.*)\<\/title\>/); let clean_html = html .replace(/)<[^<]*)*<\/script>/gi, "") .replace(/)<[^<]*)*<\/head>/gi, "") @@ -982,14 +1034,12 @@ const inject = { .replace(/]*?)>/gi, '
') .replace(/<\/body([^>]*?)>/gi, "
"); - if (title && title.length == 2) { - clean_html = title[0] + clean_html; - } try { clean_html = this._rebaseHTML(url, clean_html); } catch (e) { log.error("Error rebasing urls", e); } + const $html = $("
").html(clean_html); if ($html.children().length === 0) { log.warn("Parsing html resulted in empty jquery object:", clean_html); @@ -1121,7 +1171,6 @@ const inject = { html: { sources(cfgs, data) { const sources = cfgs.map((cfg) => cfg.source); - sources.push("title"); const result = this._sourcesFromHtml(data, cfgs[0].url, sources); return result; }, diff --git a/src/pat/inject/inject.test.js b/src/pat/inject/inject.test.js index 83382a343..167028ba5 100644 --- a/src/pat/inject/inject.test.js +++ b/src/pat/inject/inject.test.js @@ -1731,7 +1731,7 @@ describe("pat-inject", function () { }); }); - describe("9.4 - injecton of the title element.", function () { + describe("9.4 - history:record DOM updates.", function () { let spy_ajax; beforeEach(function () { @@ -1742,90 +1742,997 @@ describe("pat-inject", function () { spy_ajax.mockRestore(); }); - it("9.4.1 - Injects a title element with history:record", async function () { - document.head.innerHTML = ` - test - `; - document.body.innerHTML = ` - link - `; + describe("9.4.1 - title element updates", function () { + it("9.4.1.1 - Injects a title element with history:record", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; - answer(` - - - hello - - - OK - - - `); + answer(` + + + hello + + + OK + + + `); - const inject = document.querySelector(".pat-inject"); + const inject = document.querySelector(".pat-inject"); - pattern.init($(inject)); - await utils.timeout(1); // wait a tick for async to settle. + pattern.init($(inject)); + await utils.timeout(1); // wait a tick for async to settle. - inject.click(); + inject.click(); - await utils.timeout(1); // wait a tick for async to settle. + await utils.timeout(1); // wait a tick for async to settle. - expect(document.body.textContent.trim()).toBe("OK"); + expect(document.body.textContent.trim()).toBe("OK"); - const title = document.head.querySelector("title"); - expect(title).toBeTruthy(); - expect(title.textContent.trim()).toBe("hello"); + const title = document.head.querySelector("title"); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("hello"); + }); + + it("9.4.1.2 - Does not inject a title element without history:record", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + hello + + + OK + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); // wait a tick for async to settle. + + inject.click(); + + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.body.textContent.trim()).toBe("OK"); + + const title = document.head.querySelector("title"); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("test"); // Old title + }); + + it("9.4.1.3 - Does not break, if no title is found in source", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + OK + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); // wait a tick for async to settle. + + inject.click(); + + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.body.textContent.trim()).toBe("OK"); + + // Title in head target is not modified. + const title = document.head.querySelector("title"); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("test"); // Old title + }); + + it("9.4.1.4 - Adds title when not present in target but present in source", async function () { + document.head.innerHTML = ""; + document.body.innerHTML = ` + link + `; + + answer(` + + + hello + + OK + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); // wait a tick for async to settle. + + inject.click(); + + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.body.textContent.trim()).toBe("OK"); + + // Title should be added when source has one but target doesn't + const title = document.head.querySelector("title"); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("hello"); + }); + + it("9.4.1.5 - Never removes title when source doesn't have one but target does", async function () { + document.head.innerHTML = ` + existing title + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + + + + Content without title + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe( + "Content without title" + ); + + // Title should be preserved even when source doesn't have one + const title = document.head.querySelector("title"); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("existing title"); + }); }); - it("9.4.2 - Does not inject a title element without history:record", async function () { - document.head.innerHTML = ` - test - `; - document.body.innerHTML = ` - link - `; + describe("9.4.2 - canonical link updates", function () { + it("9.4.2.1 - Injects canonical link with history:record", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; - answer(` - - - hello - - - OK - - - `); + answer(` + + + new page + + + + New content + + + `); - const inject = document.querySelector(".pat-inject"); + const inject = document.querySelector(".pat-inject"); - pattern.init($(inject)); - await utils.timeout(1); // wait a tick for async to settle. + pattern.init($(inject)); + await utils.timeout(1); - inject.click(); + inject.click(); + await utils.timeout(1); - await utils.timeout(1); // wait a tick for async to settle. + expect(document.body.textContent.trim()).toBe("New content"); - expect(document.body.textContent.trim()).toBe("OK"); + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-page"); + }); - const title = document.head.querySelector("title"); - expect(title).toBeTruthy(); - expect(title.textContent.trim()).toBe("test"); // Old title + it("9.4.2.2 - Does not inject canonical link without history:record", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/old-page"); // Old canonical preserved + }); + + it("9.4.2.3 - Does not break if no canonical link in source", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBe(null); // Canonical removed when not in source + }); + + it("9.4.2.4 - Does not break if no canonical link in target", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + // Canonical link was added from source to target + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-page"); + }); + + it("9.4.2.4 - Handles canonical link with different quote styles", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-page"); + }); + + it("9.4.2.5 - Handles mixed case and extra attributes in canonical link", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-page"); + }); + + it("9.4.2.6 - Removes canonical link when source doesn't have one but target does", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + New content without canonical + + + `); + + const inject = document.querySelector(".pat-inject"); + + // Verify canonical exists before injection + expect( + document.head.querySelector("link[rel=canonical]") + ).toBeTruthy(); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe( + "New content without canonical" + ); + + // Verify canonical is removed after injection + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBe(null); + }); + + it("9.4.2.7 - Adds canonical link when source has one but target doesn't", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content with canonical + + + `); + + const inject = document.querySelector(".pat-inject"); + + // Verify canonical doesn't exist before injection + expect(document.head.querySelector("link[rel=canonical]")).toBe( + null + ); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe( + "New content with canonical" + ); + + // Verify canonical is added after injection + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-canonical"); + }); + + it("9.4.2.8 - Full navigation sequence: remove then add canonical", async function () { + document.head.innerHTML = ` + initial + + `; + document.body.innerHTML = ` + + `; + + const inject_first = document.querySelector(".pat-inject.first"); + + // Step 1: Navigate to page without canonical + answer(` + + + first page + + + second link + + + `); + + pattern.init($(inject_first)); + await utils.timeout(1); + + inject_first.click(); + await utils.timeout(1); + + // Verify canonical was removed + expect(document.head.querySelector("link[rel=canonical]")).toBe( + null + ); + expect(document.head.querySelector("title").textContent.trim()).toBe( + "first page" + ); + + // Step 2: Navigate to page with canonical again + const inject_second = document.querySelector(".pat-inject.second"); + + // Reset deferred for second request + deferred = new $.Deferred(); + spy_ajax.mockImplementation(() => deferred); + + answer(` + + + second page + + + + Final content + + + `); + + pattern.init($(inject_second)); + await utils.timeout(1); + + inject_second.click(); + await utils.timeout(1); + + // Verify canonical was added back + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/second-canonical"); + expect(document.head.querySelector("title").textContent.trim()).toBe( + "second page" + ); + expect(document.querySelector("#content").textContent.trim()).toBe( + "Final content" + ); + }); + }); + + describe("9.4.3 - base tag updates", function () { + it("9.4.3.1 - Injects base tag with history:record", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/new/"); + }); + + it("9.4.3.2 - Does not inject base tag without history:record", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/old/"); // Old base preserved + }); + + it("9.4.3.3 - Does not break if no base tag in source", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const base = document.head.querySelector("base"); + expect(base).toBe(null); // Base removed when not in source + }); + + it("9.4.3.4 - Does not break if no base tag in target", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + // Base tag was added from source to target + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/new/"); + }); + + it("9.4.3.5 - Removes base tag when source doesn't have one but target does", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + New content without base + + + `); + + const inject = document.querySelector(".pat-inject"); + + // Verify base exists before injection + expect(document.head.querySelector("base")).toBeTruthy(); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe( + "New content without base" + ); + + // Verify base is removed after injection + const base = document.head.querySelector("base"); + expect(base).toBe(null); + }); + + it("9.4.3.6 - Adds base tag when source has one but target doesn't", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content with base + + + `); + + const inject = document.querySelector(".pat-inject"); + + // Verify base doesn't exist before injection + expect(document.head.querySelector("base")).toBe(null); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe( + "New content with base" + ); + + // Verify base is added after injection + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/new-base/"); + }); + + it("9.4.3.7 - Full navigation sequence: remove then add base tag", async function () { + document.head.innerHTML = ` + initial + + `; + document.body.innerHTML = ` + + `; + + const inject_first = document.querySelector(".pat-inject.first"); + + // Step 1: Navigate to page without base + answer(` + + + first page + + + second link + + + `); + + pattern.init($(inject_first)); + await utils.timeout(1); + + inject_first.click(); + await utils.timeout(1); + + // Verify base was removed + expect(document.head.querySelector("base")).toBe(null); + expect(document.head.querySelector("title").textContent.trim()).toBe( + "first page" + ); + + // Step 2: Navigate to page with base again + const inject_second = document.querySelector(".pat-inject.second"); + + // Reset deferred for second request + deferred = new $.Deferred(); + spy_ajax.mockImplementation(() => deferred); + + answer(` + + + second page + + + + Final content + + + `); + + pattern.init($(inject_second)); + await utils.timeout(1); + + inject_second.click(); + await utils.timeout(1); + + // Verify base was added back + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/second-base/"); + expect(document.head.querySelector("title").textContent.trim()).toBe( + "second page" + ); + expect(document.querySelector("#content").textContent.trim()).toBe( + "Final content" + ); + }); }); - it("9.4.3 - Does not break, if no title is found in source", async function () { + it("9.4.4 - Injects all metadata (title, canonical, base) together", async function () { document.head.innerHTML = ` - test + old title + + `; document.body.innerHTML = ` + + new title + + + - OK + New content `); @@ -1848,58 +2760,105 @@ describe("pat-inject", function () { const inject = document.querySelector(".pat-inject"); pattern.init($(inject)); - await utils.timeout(1); // wait a tick for async to settle. + await utils.timeout(1); inject.click(); + await utils.timeout(1); - await utils.timeout(1); // wait a tick for async to settle. - - expect(document.body.textContent.trim()).toBe("OK"); + expect(document.body.textContent.trim()).toBe("New content"); - // Title in head target is not modified. + // All metadata should be updated const title = document.head.querySelector("title"); expect(title).toBeTruthy(); - expect(title.textContent.trim()).toBe("test"); // Old title + expect(title.textContent.trim()).toBe("new title"); + + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-page"); + + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/new/"); }); - it("9.4.4 - Does not break, if no title is found in target", async function () { - document.head.innerHTML = ""; + it("9.4.5 - Dispatches pat-inject-before-history-update event after head modifications but before history.pushState", async function () { + // Mock history.pushState to track when it's called + const original_push_state = history.pushState; + let push_state_called = false; + let event_dispatched = false; + let head_modified_when_event_fired = false; + + history.pushState = function (...args) { + push_state_called = true; + return original_push_state.apply(this, args); + }; + + // Set up event listener to track when the event is dispatched + const event_handler = function (event) { + event_dispatched = true; + // Check if the head title has already been updated when the event fires + const title = document.head.querySelector("title"); + head_modified_when_event_fired = + title && title.textContent.trim() === "new page"; + // Ensure history.pushState hasn't been called yet + expect(push_state_called).toBe(false); + // Check that event is fired on document + expect(event.target).toBe(document); + expect(event.type).toBe("pat-inject-before-history-update"); + }; + document.addEventListener( + "pat-inject-before-history-update", + event_handler + ); + + document.head.innerHTML = ` + old title + `; document.body.innerHTML = ` - link - `; + link + `; answer(` - - - hello - - OK - - - `); + + + new page + + + New content + + + `); const inject = document.querySelector(".pat-inject"); pattern.init($(inject)); - await utils.timeout(1); // wait a tick for async to settle. + await utils.timeout(1); inject.click(); + await utils.timeout(1); - await utils.timeout(1); // wait a tick for async to settle. - - expect(document.body.textContent.trim()).toBe("OK"); - - // There is no title to be updated in target. - const title = document.head.querySelector("title"); - expect(title).toBeFalsy(); + // Cleanup + document.removeEventListener( + "pat-inject-before-history-update", + event_handler + ); + history.pushState = original_push_state; + + // Verify the event was dispatched + expect(event_dispatched).toBe(true); + // Verify the head was modified before the event was fired + expect(head_modified_when_event_fired).toBe(true); + // Verify history.pushState was eventually called + expect(push_state_called).toBe(true); + // Verify the content was injected + expect(document.body.textContent.trim()).toBe("New content"); }); - }); describe("9.5 - support multiple source element matches.", function () {