From 6bbf49adfe5126abbb10146462e05db5684b0f17 Mon Sep 17 00:00:00 2001 From: Adriaan <1079135+adriaandotcom@users.noreply.github.com> Date: Thu, 29 May 2025 17:32:47 +0200 Subject: [PATCH] Add modular unit tests and helpers --- dist/latest/latest.dev.js | 32 +++++---- test/unit/default.test.js | 116 --------------------------------- test/unit/helpers/dom.js | 78 ++++++++++++++++++++++ test/unit/helpers/index.js | 1 + test/unit/ignore-pages.test.js | 26 ++++++++ test/unit/metadata.test.js | 42 ++++++++++++ test/unit/pageview.test.js | 37 +++++++++++ 7 files changed, 204 insertions(+), 128 deletions(-) delete mode 100644 test/unit/default.test.js create mode 100644 test/unit/helpers/dom.js create mode 100644 test/unit/helpers/index.js create mode 100644 test/unit/ignore-pages.test.js create mode 100644 test/unit/metadata.test.js create mode 100644 test/unit/pageview.test.js diff --git a/dist/latest/latest.dev.js b/dist/latest/latest.dev.js index a2127a9e..06bf05c9 100644 --- a/dist/latest/latest.dev.js +++ b/dist/latest/latest.dev.js @@ -1,4 +1,4 @@ -/* Simple Analytics - Privacy friendly analytics (docs.simpleanalytics.com/script; 2023-05-03; ed1a; v11) */ +/* Simple Analytics - Privacy-first analytics (docs.simpleanalytics.com/script; 2025-05-29; 36a5; v12) */ /* eslint-env browser */ (function ( @@ -110,8 +110,8 @@ return Array.isArray(csv) ? csv : isString(csv) && csv.length - ? csv.split(/, ?/) - : []; + ? csv.split(/, ?/) + : []; }; var isObject = function (object) { @@ -432,12 +432,13 @@ // PAYLOAD FOR BOTH PAGE VIEWS AND EVENTS // + var phantom = window.phantom; var bot = nav.webdriver || window.__nightmare || window.callPhantom || window._phantom || - window.phantom || + (phantom && !phantom.solana) || window.__polypane || window._bot || isBotAgent || @@ -512,8 +513,12 @@ var lastSendPath; var getReferrer = function () { + // Customers can overwrite their referrer, here we check for that + var overwrittenReferrer = + overwriteOptions.referrer || attr(scriptElement, "referrer"); + return ( - (doc.referrer || "") + (overwrittenReferrer || doc.referrer || "") .replace(locationHostname, definedHostname) .replace(/^https?:\/\/((m|l|w{2,3}([0-9]+)?)\.)?([^?#]+)(.*)$/, "$4") .replace(/^([^/]+)$/, "$1") || undefinedVar @@ -738,7 +743,10 @@ : falseVar; // We set unique variable based on pushstate or back navigation, if no match we check the referrer - page.unique = isPushState || userNavigated ? falseVar : !sameSite; + page.unique = + /__cf_/.test(getReferrer()) || isPushState || userNavigated + ? falseVar + : !sameSite; metadata = appendMetadata(metadata, { type: pageviewText, @@ -841,11 +849,11 @@ } if (autoCollect) pageview(); - else { - window.sa_pageview = function (path, metadata) { - pageview(0, path, metadata); - }; - } + + window.sa_pageview = function (path, metadata) { + pageview(0, path, metadata); + }; + ///////////////////// // EVENTS @@ -938,6 +946,6 @@ {}, "simpleanalyticscdn.com", "queue.", - "cdn_latest_dev_11", + "cdn_latest_dev_12", "sa" ); diff --git a/test/unit/default.test.js b/test/unit/default.test.js deleted file mode 100644 index 3e50a2f8..00000000 --- a/test/unit/default.test.js +++ /dev/null @@ -1,116 +0,0 @@ -const { JSDOM } = require("jsdom"); -const { readFileSync } = require("fs"); -const vm = require("vm"); -const { expect } = require("chai"); - -/** - * @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, - }, -}; - -describe("default script", function () { - it("sends pageview, event and beacon requests", function (done) { - const dom = new JSDOM("", { - url: "https://example.com/", - runScripts: "outside-only", - pretendToBeVisual: true, - }); - - /** - * @type {NavigationType} - */ - const navigationType = "reload"; - - const sent = []; - dom.window.Image = function () { - return { - set src(url) { - sent.push({ type: "image", url }); - }, - }; - }; - dom.window.navigator.sendBeacon = function (url, data) { - sent.push({ type: "beacon", url, data }); - return true; - }; - - // Mock the Performance API using Object.defineProperty - 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("dist/latest/latest.dev.js", "utf8"); - vm.runInContext(script, dom.getInternalVMContext()); - - dom.window.sa_event("unit_test"); - - // The script checks for 'onpagehide' in window to determine if it should send - // the beacon on visibilitychange. If 'onpagehide' doesn't exist, it sends immediately - // when the page becomes hidden. - if (!("onpagehide" in dom.window)) { - // If pagehide is not supported, the script sends the beacon on visibilitychange - dom.window.document.hidden = true; - dom.window.document.dispatchEvent( - new dom.window.Event("visibilitychange") - ); - } else { - // If pagehide is supported, we need to dispatch the pagehide event - dom.window.dispatchEvent(new dom.window.Event("pagehide")); - } - - // Give the beacon a moment to be sent - setTimeout(() => { - const gif = sent.find( - (r) => r.type === "image" && /simple\.gif/.test(r.url) - ); - const eventReq = sent.find( - (r) => r.type === "image" && /event=unit_test/.test(r.url) - ); - const beacon = sent.find((r) => r.type === "beacon"); - - expect(gif, "pageview gif request").to.exist; - expect(eventReq, "event gif request").to.exist; - expect(beacon, "append beacon request").to.exist; - expect(beacon.url).to.match(/\/append$/); - expect(beacon.data).to.include('"type":"append"'); - - done(); - }, 10); - }); -}); diff --git a/test/unit/helpers/dom.js b/test/unit/helpers/dom.js new file mode 100644 index 00000000..b0f2bae3 --- /dev/null +++ b/test/unit/helpers/dom.js @@ -0,0 +1,78 @@ +const { JSDOM } = require("jsdom"); +const { readFileSync } = require("fs"); +const vm = require("vm"); + +const SCRIPT_PATH = "dist/latest/latest.dev.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/helpers/index.js b/test/unit/helpers/index.js new file mode 100644 index 00000000..385a0601 --- /dev/null +++ b/test/unit/helpers/index.js @@ -0,0 +1 @@ +module.exports = require("./dom"); diff --git a/test/unit/ignore-pages.test.js b/test/unit/ignore-pages.test.js new file mode 100644 index 00000000..df7b5387 --- /dev/null +++ b/test/unit/ignore-pages.test.js @@ -0,0 +1,26 @@ +const { expect } = require("chai"); +const { createDOM } = require("./helpers/dom"); + +describe("ignore pages", function () { + it("does not send a request for ignored paths", function (done) { + const dom = createDOM({ + settings: { autoCollect: false, ignorePages: "/ignore" }, + }); + + dom.window.sa_pageview("/ignore"); + dom.window.sa_pageview("/allowed"); + + setTimeout(() => { + const ignoreReq = dom.sent.find( + (r) => r.type === "image" && /path=%2Fignore/.test(r.url) + ); + const allowedReq = dom.sent.find( + (r) => r.type === "image" && /path=%2Fallowed/.test(r.url) + ); + + expect(ignoreReq, "request for ignored path").to.not.exist; + expect(allowedReq, "request for allowed path").to.exist; + done(); + }, 10); + }); +}); diff --git a/test/unit/metadata.test.js b/test/unit/metadata.test.js new file mode 100644 index 00000000..76981c86 --- /dev/null +++ b/test/unit/metadata.test.js @@ -0,0 +1,42 @@ +const { expect } = require("chai"); +const { createDOM } = require("./helpers/dom"); + +describe("metadata", function () { + it("collects metadata from global object and collector", function (done) { + const dom = createDOM({ + settings: { autoCollect: false, metadataCollector: "collector" }, + beforeRun(vmContext) { + const { runInContext } = require("vm"); + runInContext( + "window.sa_metadata = { fromGlobal: true };" + + "window.collector = function(data){ return { fromCollector: true, path: data.path }; };", + vmContext + ); + }, + }); + + const { runInContext } = require("vm"); + runInContext( + "window.manualMeta = { manual: true };", + dom.getInternalVMContext() + ); + dom.window.sa_pageview("/meta", dom.window.manualMeta); + + setTimeout(() => { + const req = dom.sent.find( + (r) => r.type === "image" && /path=%2Fmeta/.test(r.url) + ); + expect(req, "pageview request").to.exist; + const url = new URL(req.url); + const meta = JSON.parse( + decodeURIComponent(url.searchParams.get("metadata")) + ); + expect(meta).to.include({ + manual: true, + fromGlobal: true, + fromCollector: true, + }); + done(); + }, 10); + }); +}); diff --git a/test/unit/pageview.test.js b/test/unit/pageview.test.js new file mode 100644 index 00000000..c8432900 --- /dev/null +++ b/test/unit/pageview.test.js @@ -0,0 +1,37 @@ +const { expect } = require("chai"); +const { createDOM } = require("./helpers/dom"); + +describe("pageview", function () { + it("sends pageview, event and beacon requests", function (done) { + const dom = createDOM({ navigationType: "reload" }); + + dom.window.sa_event("unit_test"); + + if (!("onpagehide" in dom.window)) { + dom.window.document.hidden = true; + dom.window.document.dispatchEvent( + new dom.window.Event("visibilitychange") + ); + } else { + dom.window.dispatchEvent(new dom.window.Event("pagehide")); + } + + setTimeout(() => { + const gif = dom.sent.find( + (r) => r.type === "image" && /simple\.gif/.test(r.url) + ); + const eventReq = dom.sent.find( + (r) => r.type === "image" && /event=unit_test/.test(r.url) + ); + const beacon = dom.sent.find((r) => r.type === "beacon"); + + expect(gif, "pageview gif request").to.exist; + expect(eventReq, "event gif request").to.exist; + expect(beacon, "append beacon request").to.exist; + expect(beacon.url).to.match(/\/append$/); + expect(beacon.data).to.include('"type":"append"'); + + done(); + }, 10); + }); +});