From 8fb4b3afb9d2afc2ad375cc80f52e1757a65fd0c Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Thu, 22 Oct 2026 13:31:40 +0000 Subject: [PATCH] Skip missing windows without errors and drop saAutomatedLink global auto-events.js referenced window at the call site, so it threw outside a browser. Both scripts now pass `typeof window < "u" && window`, which is also shorter than the previous check in latest.js. saAutomatedLink was only global for the onclick attributes that v12 replaced with click listeners, so it is now a local function. The tests click real links instead of calling the global. Fixes #141 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/auto-events.js | 13 +-- src/default.js | 3 +- test/unit/auto-events.test.js | 161 +++++++++++---------------- test/unit/helpers/dom-auto-events.js | 78 ------------- test/unit/no-window.test.js | 17 +++ 5 files changed, 89 insertions(+), 183 deletions(-) delete mode 100644 test/unit/helpers/dom-auto-events.js create mode 100644 test/unit/no-window.test.js diff --git a/src/auto-events.js b/src/auto-events.js index 876e9a0..e6b4503 100644 --- a/src/auto-events.js +++ b/src/auto-events.js @@ -1,6 +1,7 @@ (function saAutomatedEvents(window) { - // Skip server side rendered pages - if (typeof window === "undefined") return; + // Skip server side rendered pages. Without a window the call at the bottom + // passes false: typeof is then "undefined", which sorts after "u". + if (!window) return; var log = function (message, type) { var logger = type === "warn" ? console.warn : console.info; @@ -75,7 +76,7 @@ if (typeof optionsLink === "undefined") log("options object not found, please specify", "warn"); - var saAutomatedLink = function saAutomatedLink(element, type) { + function saAutomatedLink(element, type) { try { if (!element) return log("no element found"); @@ -139,9 +140,7 @@ } catch (error) { log(error.message, "warn"); } - }; - - window.saAutomatedLink = saAutomatedLink; + } function collectLink(link) { if (link.hasAttribute("data-simple-event")) return; @@ -208,4 +207,4 @@ if (event.target.readyState === "complete") onDOMContentLoaded(); }); } -})(window); +})(typeof window < "u" && window); diff --git a/src/default.js b/src/default.js index 28ae527..afccb81 100644 --- a/src/default.js +++ b/src/default.js @@ -1299,7 +1299,8 @@ /** endif **/ } })( - typeof window != "undefined" && window, + // typeof is "undefined" without a window, which sorts after "u" + typeof window < "u" && window, "{{baseUrl}}", "{{apiUrlPrefix}}", "{{scriptName}}", diff --git a/test/unit/auto-events.test.js b/test/unit/auto-events.test.js index 39945df..3b3f782 100644 --- a/test/unit/auto-events.test.js +++ b/test/unit/auto-events.test.js @@ -1,57 +1,25 @@ const { expect } = require("chai"); -const { createDOM } = require("./helpers/dom-auto-events"); const { createDOM: createTrackerDOM } = require("./helpers/dom"); const { readFileSync } = require("fs"); const { runInContext, runInNewContext } = require("vm"); -function setupDOM() { - const events = []; - const dom = createDOM({ - beforeRun(vm) { - vm.sa_event = function (name, metadata, cb) { - events.push({ name, metadata }); - if (typeof cb === "function") cb(); - }; - vm.sa_event_loaded = true; - }, - }); - dom.events = events; - return dom; -} - describe("auto-events", function () { - it("keeps phone metadata when the title names the event", function () { - const dom = setupDOM(); - const link = dom.window.document.createElement("a"); - link.href = "tel:+31201234567?extension=42"; - link.title = "Call sales"; - - dom.window.saAutomatedLink(link, "phone"); - - expect(dom.events).to.deep.equal([ - { - name: "phone_Call_sales", - metadata: { title: "Call sales", phone: "+31201234567" }, - }, - ]); - dom.window.close(); - }); - // Runs auto-events.js on a single link and returns what the click sends - function clickLink(href, hostname, event) { + function clickLink(href, { title, event = {} } = {}) { const calls = []; const navigations = []; const attributes = []; const listeners = []; + const { hostname, pathname } = new URL(href); const link = { href, hostname, - pathname: "/", + pathname, getAttribute(name) { - return name === "href" ? this.href : null; + return { href, title }[name] ?? null; }, - hasAttribute() { - return false; + hasAttribute(name) { + return this.getAttribute(name) !== null; }, setAttribute(name) { attributes.push(name); @@ -85,9 +53,47 @@ describe("auto-events", function () { return { calls, navigations, attributes, listeners }; } + it("keeps phone metadata when the title names the event", function () { + const { calls } = clickLink("tel:+31201234567?extension=42", { + title: "Call sales", + }); + expect(calls.map(([name, metadata]) => ({ name, metadata }))).to.deep.equal( + [ + { + name: "phone_Call_sales", + metadata: { title: "Call sales", phone: "+31201234567" }, + }, + ] + ); + }); + + for (const [href, name, metadata] of [ + [ + "https://example.org/path", + "outbound_example_org", + { url: "https://example.org/path" }, + ], + [ + "https://example.com/file.pdf", + "download_file_pdf", + { url: "https://example.com/file.pdf" }, + ], + [ + "mailto:test@example.com", + "email_test_example_com", + { email: "test@example.com" }, + ], + ]) { + it(`tracks clicks on ${href}`, function () { + const { calls } = clickLink(href); + expect(calls[0][0]).to.equal(name); + expect(calls[0][1]).to.include(metadata); + }); + } + it("collects phone clicks by default without overriding cancelled navigation", function () { - const { calls, navigations } = clickLink("tel:+31201234567", "", { - defaultPrevented: true, + const { calls, navigations } = clickLink("tel:+31201234567", { + event: { defaultPrevented: true }, }); expect(calls.map(([name, metadata]) => ({ name, metadata }))).to.deep.equal( [ @@ -104,10 +110,11 @@ describe("auto-events", function () { let prevented = false; const { calls, navigations, attributes, listeners } = clickLink( "https://example.org/", - "example.org", { - preventDefault() { - prevented = true; + event: { + preventDefault() { + prevented = true; + }, }, } ); @@ -124,22 +131,30 @@ describe("auto-events", function () { [{ namespace: "ba" }, "ba_event"], [{ saGlobal: "my_event" }, "my_event"], ]) { - it(`sends events through the ${saGlobal} tracker function`, function () { + it(`sends events through the ${saGlobal} tracker function`, async function () { const dom = createTrackerDOM({ settings: { autoCollect: false, ...settings }, }); - const script = dom.window.document.createElement("script"); + const { document } = dom.window; + const script = document.createElement("script"); script.src = "auto-events.js"; script.setAttribute("data-sa-global", saGlobal); - dom.window.document.head.appendChild(script); + document.head.appendChild(script); + const link = document.createElement("a"); + link.href = "https://example.org/path"; + document.body.appendChild(link); runInContext( readFileSync("dist/latest/auto-events.js", "utf8"), dom.getInternalVMContext() ); - const link = dom.window.document.createElement("a"); - link.href = "https://example.org/path"; + // auto-events.js binds links once the document is complete + await new Promise((resolve) => setTimeout(resolve)); + // jsdom doesn't implement navigation + document.addEventListener("click", (event) => event.preventDefault()); + + link.click(); - dom.window.saAutomatedLink(link, "outbound"); + expect(dom.window.saAutomatedLink).to.equal(undefined); expect( dom.sent.map((request) => @@ -149,52 +164,4 @@ describe("auto-events", function () { dom.window.close(); }); } - - it("tracks outbound link clicks", function (done) { - const dom = setupDOM(); - const link = dom.window.document.createElement("a"); - link.href = "https://example.org/path"; - link.target = "_blank"; - dom.window.document.body.appendChild(link); - - dom.window.saAutomatedLink(link, "outbound"); - - setTimeout(() => { - expect(dom.events[0]).to.deep.include({ name: "outbound_example_org" }); - expect(dom.events[0].metadata).to.include({ url: link.href }); - done(); - }, 0); - }); - - it("tracks download link clicks", function (done) { - const dom = setupDOM(); - const link = dom.window.document.createElement("a"); - link.href = "https://example.com/file.pdf"; - link.target = "_blank"; - dom.window.document.body.appendChild(link); - - dom.window.saAutomatedLink(link, "download"); - - setTimeout(() => { - expect(dom.events[0]).to.deep.include({ name: "download_file_pdf" }); - expect(dom.events[0].metadata).to.include({ url: link.href }); - done(); - }, 0); - }); - - it("tracks email link clicks", function (done) { - const dom = setupDOM(); - const link = dom.window.document.createElement("a"); - link.href = "mailto:test@example.com"; - link.target = "_blank"; - dom.window.document.body.appendChild(link); - - dom.window.saAutomatedLink(link, "email"); - - setTimeout(() => { - expect(dom.events[0]).to.deep.include({ name: "email_test_example_com" }); - expect(dom.events[0].metadata).to.include({ email: "test@example.com" }); - done(); - }, 0); - }); }); diff --git a/test/unit/helpers/dom-auto-events.js b/test/unit/helpers/dom-auto-events.js deleted file mode 100644 index eaf7ccc..0000000 --- a/test/unit/helpers/dom-auto-events.js +++ /dev/null @@ -1,78 +0,0 @@ -const { JSDOM } = require("jsdom"); -const { readFileSync } = require("fs"); -const vm = require("vm"); - -const SCRIPT_PATH = "dist/latest/auto-events.js"; - -/** - * @typedef {"navigate" | "reload" | "back_forward" | "prerender"} NavigationType - */ - -/** @type {Record} */ -const NAVIGATION_TYPES = { - navigate: { name: "navigate", code: 0 }, - reload: { name: "reload", code: 1 }, - back_forward: { name: "back_forward", code: 2 }, - prerender: { name: "prerender", code: 255 }, -}; - -function createDOM(options = {}) { - const { - url = "https://example.com/", - navigationType = "navigate", - settings, - beforeRun, - } = options; - const dom = new JSDOM("", { - url, - runScripts: "outside-only", - pretendToBeVisual: true, - }); - - if (settings) { - vm.runInContext( - `window.sa_settings = ${JSON.stringify(settings)}`, - dom.getInternalVMContext() - ); - } - - if (typeof beforeRun === "function") beforeRun(dom.getInternalVMContext()); - - const sent = []; - dom.window.Image = function () { - return { - set src(value) { - sent.push({ type: "image", url: value }); - }, - }; - }; - dom.window.navigator.sendBeacon = function (url, data) { - sent.push({ type: "beacon", url, data }); - return true; - }; - - Object.defineProperty(dom.window, "performance", { - writable: true, - value: { - getEntriesByType: function (type) { - if (type === "navigation") { - return [{ type: NAVIGATION_TYPES[navigationType].name }]; - } - return []; - }, - navigation: { type: NAVIGATION_TYPES[navigationType].code }, - }, - }); - - const script = readFileSync(SCRIPT_PATH, "utf8"); - vm.runInContext(script, dom.getInternalVMContext()); - - dom.sent = sent; - return dom; -} - -module.exports = { - createDOM, - SCRIPT_PATH, - NAVIGATION_TYPES, -}; diff --git a/test/unit/no-window.test.js b/test/unit/no-window.test.js new file mode 100644 index 0000000..31f1278 --- /dev/null +++ b/test/unit/no-window.test.js @@ -0,0 +1,17 @@ +const { expect } = require("chai"); +const { readFileSync } = require("fs"); +const { runInNewContext } = require("vm"); + +describe("without a window", function () { + for (const file of [ + "dist/latest/latest.js", + "dist/latest/light.js", + "dist/latest/auto-events.js", + ]) { + it(`loads ${file} without errors`, function () { + expect(() => + runInNewContext(readFileSync(file, "utf8"), { console }) + ).not.to.throw(); + }); + } +});