From 9c35a0f47ca76ab37987a72d92b5e726da0205ae Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Mon, 4 Aug 2025 13:10:08 +0300 Subject: [PATCH] Script v2: Handle exceptions on clicks on links within svgs (#5538) * Access href attribute more defensively to avoid uncaught errors WIP chore: Bump tracker_script_version to 22 Try accept limitation Clarify issue with tagged link clicks within svgs Fix potential error chain accessing object Revert fixes about tagged events props * Refactor script tests --- tracker/npm_package/CHANGELOG.md | 2 + tracker/package.json | 2 +- tracker/src/custom-events.js | 23 +- tracker/test/callbacks.spec.js | 59 ---- tracker/test/callbacks.spec.ts | 94 +++++ tracker/test/custom-properties.spec.ts | 281 +++++++++++++++ tracker/test/file-downloads.spec.ts | 197 +++++++++-- tracker/test/fixtures/callbacks.html | 36 -- tracker/test/fixtures/plausible-npm.html | 31 -- tracker/test/fixtures/plausible-web.html | 58 ---- tracker/test/hash-based-routing.spec.ts | 123 +++++++ tracker/test/logging.spec.ts | 84 +++++ tracker/test/outbound-links.spec.ts | 208 ++++++++++- tracker/test/pageview.spec.ts | 102 +++++- tracker/test/plausible-npm-init.spec.ts | 179 ++++++++++ tracker/test/plausible-npm.spec.js | 77 ---- tracker/test/plausible-web-init.spec.ts | 154 ++++++++ tracker/test/plausible-web.spec.js | 97 ------ tracker/test/shared-configuration-tests.js | 328 ------------------ .../support/initialize-page-dynamically.ts | 53 ++- tracker/test/support/mock-many-requests.ts | 2 +- tracker/test/support/test-utils.js | 1 + tracker/test/support/types.ts | 3 + tracker/test/tagged-events.spec.ts | 127 ++++++- tracker/test/transform_request.spec.ts | 255 ++++++++++++++ 25 files changed, 1832 insertions(+), 744 deletions(-) delete mode 100644 tracker/test/callbacks.spec.js create mode 100644 tracker/test/callbacks.spec.ts create mode 100644 tracker/test/custom-properties.spec.ts delete mode 100644 tracker/test/fixtures/callbacks.html delete mode 100644 tracker/test/fixtures/plausible-npm.html delete mode 100644 tracker/test/fixtures/plausible-web.html create mode 100644 tracker/test/hash-based-routing.spec.ts create mode 100644 tracker/test/logging.spec.ts create mode 100644 tracker/test/plausible-npm-init.spec.ts delete mode 100644 tracker/test/plausible-npm.spec.js create mode 100644 tracker/test/plausible-web-init.spec.ts delete mode 100644 tracker/test/plausible-web.spec.js delete mode 100644 tracker/test/shared-configuration-tests.js create mode 100644 tracker/test/transform_request.spec.ts diff --git a/tracker/npm_package/CHANGELOG.md b/tracker/npm_package/CHANGELOG.md index 071b15a857..d16180cba7 100644 --- a/tracker/npm_package/CHANGELOG.md +++ b/tracker/npm_package/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Fix issue with link tracking features (tagged events, file downloads, outbound links) causing errors on the page when clicking on `a` tags within `svg` tags. + ## [0.3.4] - 2025-07-23 - Plausible loaded indicator `window.plausible.l = true` is set last in initialisation functions diff --git a/tracker/package.json b/tracker/package.json index 66ce925351..3ed45622d9 100644 --- a/tracker/package.json +++ b/tracker/package.json @@ -1,5 +1,5 @@ { - "tracker_script_version": 25, + "tracker_script_version": 26, "type": "module", "scripts": { "deploy": "node compile.js", diff --git a/tracker/src/custom-events.js b/tracker/src/custom-events.js index efee6f8b24..399a5533d2 100644 --- a/tracker/src/custom-events.js +++ b/tracker/src/custom-events.js @@ -19,20 +19,23 @@ function isLink(element) { return element && element.tagName && element.tagName.toLowerCase() === 'a' } -function shouldFollowLink(event, link) { +function shouldInterceptNavigation(event, link) { // If default has been prevented by an external script, Plausible should not intercept navigation. - if (event.defaultPrevented) { return false } + if (event.defaultPrevented) return false; + var target = link.target; + // If the link directs to open the link in a different context, or we're not sure, do not intercept navigation + if (target && (typeof target !== 'string' || !target.match(/^_(self|parent|top)$/i))) return false; + // If the click is not a regular click (e.g. ctrl, meta, shift, or not a click event), do not intercept navigation + if (event.ctrlKey || event.metaKey || event.shiftKey || event.type !== 'click') return false; - var targetsCurrentWindow = !link.target || link.target.match(/^_(self|parent|top)$/i) - var isRegularClick = !(event.ctrlKey || event.metaKey || event.shiftKey) && event.type === 'click' - return targetsCurrentWindow && isRegularClick + return true; } function handleLinkClickEvent(event) { if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { return } var link = getLinkEl(event.target) - var hrefWithoutQuery = link && link.href && link.href.split('?')[0] + var hrefWithoutQuery = link && typeof link.href === 'string' && link.href.split('?')[0] if (COMPILE_TAGGED_EVENTS) { if (isElementOrParentTagged(link, 0)) { @@ -57,7 +60,7 @@ function handleLinkClickEvent(event) { function sendLinkClickEvent(event, link, eventAttrs) { // In some legacy variants, this block delays opening the link up to 5 seconds, - // or until analytics request finishes, otherwise navigation prevents the analytics event from being sent. + // or until analytics request finishes, otherwise navigation could prevent the analytics event from being sent. if (COMPILE_COMPAT) { var followedLink = false @@ -68,7 +71,7 @@ function sendLinkClickEvent(event, link, eventAttrs) { } } - if (shouldFollowLink(event, link)) { + if (shouldInterceptNavigation(event, link)) { var attrs = { props: eventAttrs.props, callback: followLink } if (COMPILE_REVENUE) { attrs.revenue = eventAttrs.revenue @@ -93,7 +96,7 @@ function sendLinkClickEvent(event, link, eventAttrs) { } function isOutboundLink(link) { - return link && link.href && link.host && link.host !== location.host + return link && typeof link.href === 'string' && link.host && link.host !== location.host } function isDownloadToTrack(url) { @@ -205,7 +208,7 @@ export function init() { if (!eventAttrs.name) { return } // In some legacy variants, this block delays submitting the form for up to 5 seconds, - // or until analytics request finishes, otherwise form-related navigation can prevent the analytics event from being sent. + // or until analytics request finishes, otherwise form-related navigation could prevent the analytics event from being sent. if (COMPILE_COMPAT) { event.preventDefault() var formSubmitted = false diff --git a/tracker/test/callbacks.spec.js b/tracker/test/callbacks.spec.js deleted file mode 100644 index 6e26c957d6..0000000000 --- a/tracker/test/callbacks.spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { test, expect } from '@playwright/test' -import { LOCAL_SERVER_ADDR } from './support/server' - -async function openPage(page, src, endpoint = `${LOCAL_SERVER_ADDR}/api/event`) { - await page.goto(`/callbacks.html?src=${src}&endpoint=${endpoint}`) -} - -function trackWithCallback(page) { - return page.evaluate(() => window.callPlausible()) -} - -function testCallbacks(trackerScriptSrc) { - test("on successful request", async ({ page }) => { - await openPage(page, trackerScriptSrc) - - const callbackResult = await trackWithCallback(page) - expect(callbackResult).toEqual({ status: 202 }) - }) - - test('on ignored request', async ({ page }) => { - const trackerScriptSrcwithoutLocal = trackerScriptSrc.replace('.local', '') - await openPage(page, trackerScriptSrcwithoutLocal) - - const callbackResult = await trackWithCallback(page) - expect(callbackResult).toEqual(undefined) - }) - - test('on 404', async ({ page }) => { - await openPage(page, trackerScriptSrc, `${LOCAL_SERVER_ADDR}/api/404`) - - const callbackResult = await trackWithCallback(page) - expect(callbackResult).toEqual({ status: 404 }) - }) - - test('on network error', async ({ page }) => { - await openPage(page, trackerScriptSrc, `h://bad.url////`) - - const callbackResult = await trackWithCallback(page) - expect(callbackResult.error).toBeInstanceOf(Error) - }) -} - -test.beforeEach(async ({ page }) => { - await page.route(`${LOCAL_SERVER_ADDR}/api/event`, (route) => { - return route.fulfill({ status: 202, contentType: 'text/plain', body: 'ok' }) - }) - await page.route(`${LOCAL_SERVER_ADDR}/api/404`, (route) => { - return route.fulfill({ status: 404, contentType: 'text/plain', body: 'ok' }) - }) -}) - - -test.describe("callbacks behavior (with fetch)", () => { - testCallbacks('/tracker/js/plausible.local.manual.js') -}) - -test.describe("callbacks behavior (with xhr/compat)", () => { - testCallbacks('/tracker/js/plausible.compat.local.manual.js') -}) diff --git a/tracker/test/callbacks.spec.ts b/tracker/test/callbacks.spec.ts new file mode 100644 index 0000000000..6226383314 --- /dev/null +++ b/tracker/test/callbacks.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { mockManyRequests } from './support/mock-many-requests' +import { switchByMode } from './support/test-utils' + +const DOMAIN = 'example.com' + +for (const mode of ['web', 'esm', 'legacy']) { + test.describe(`callback results (${mode})`, () => { + for (const { + name, + captureOnLocalhost, + apiPath, + mockPath, + fulfill, + expectedResult + } of [ + { + name: 'on successful request', + captureOnLocalhost: true, + apiPath: '/api/event', + mockPath: `/api/event`, + fulfill: { status: 202 }, + expectedResult: { status: 202 } + }, + { + name: 'on 404', + captureOnLocalhost: true, + apiPath: '/api/event', + mockPath: `/api/event`, + fulfill: { status: 404 }, + expectedResult: { status: 404 } + }, + { + name: 'on network error', + captureOnLocalhost: true, + apiPath: 'h://no-exist', + mockPath: `/api/event`, + fulfill: { status: 202 }, + expectedResult: { error: expect.any(Error)} + }, + { + name: 'on ignored request (because of not having capturing events on localhost)', + captureOnLocalhost: false, + apiPath: '/api/event', + mockPath: `/api/event`, + fulfill: { status: 202 }, + expectedResult: undefined + } + ]) { + test(name, async ({ page }, { testId }) => { + const config = { domain: DOMAIN, endpoint: apiPath, captureOnLocalhost } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + legacy: ``, + web: { domain: DOMAIN, endpoint: apiPath, captureOnLocalhost }, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await mockManyRequests({ + page, + path: mockPath, + fulfill, + awaitedRequestCount: 1, + mockRequestTimeout: 2000 + }) + await page.goto(url) + await page.waitForFunction(() => (window as any).plausible?.l) + const callbackResult = await page.evaluate( + () => + new Promise((resolve) => + (window as any).plausible('Purchase', { + callback: (result) => resolve(result) + }) + ) + ) + expect(callbackResult).toEqual(expectedResult) + }) + } + }) +} diff --git a/tracker/test/custom-properties.spec.ts b/tracker/test/custom-properties.spec.ts new file mode 100644 index 0000000000..04c0564c8b --- /dev/null +++ b/tracker/test/custom-properties.spec.ts @@ -0,0 +1,281 @@ +import { + initializePageDynamically, + serializeWithFunctions +} from './support/initialize-page-dynamically' +import { + e, + expectPlausibleInAction, + hideAndShowCurrentTab, + isEngagementEvent, + isPageviewEvent, + switchByMode +} from './support/test-utils' +import { test } from '@playwright/test' +import { ScriptConfig } from './support/types' +import { LOCAL_SERVER_ADDR } from './support/server' +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +for (const mode of ['web', 'esm']) { + test.describe(`respects "customProperties" config option (${mode})`, () => { + test('if "customProperties" is not set, pageviews and engagement events are sent without "p" parameter', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: DEFAULT_CONFIG, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + p: e.toBeUndefined() + }, + { n: 'engagement', p: e.toBeUndefined() } + ] + }) + }) + + test('if "customProperties" is set to a fixed value, pageviews and engagement events are sent with "p" parameter', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: { author: 'John Smith' } + } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + p: config.customProperties + }, + { n: 'engagement', p: config.customProperties } + ] + }) + }) + + test('if "customProperties" is set to be a function, pageviews and engagement events are sent with "p" parameter', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: (eventName) => ({ + author: 'John Smith', + eventName: eventName, + documentTitle: document.title + }) + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + p: { + author: 'John Smith', + eventName: 'pageview', + documentTitle: 'Plausible Playwright tests' + } + }, + { + n: 'engagement', + // Engagement event is sent with the same custom properties as the pageview event, customProperties function does not get called for these! + p: { + author: 'John Smith', + eventName: 'pageview', + documentTitle: 'Plausible Playwright tests' + } + } + ] + }) + }) + + test('specificity: props given in "track" call override any custom properties set in "customProperties"', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: () => ({ + author: 'John Smith', + title: document.title + }) + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + }, + shouldIgnoreRequest: [isEngagementEvent], + expectedRequests: [ + { + n: 'pageview', + p: { + author: 'John Smith', + title: 'Plausible Playwright tests' + } + }, + { + n: 'subscribed from blog', + p: { + author: 'John Smith', + title: 'A blog post title' + } + } + ] + }) + }) + + test('if "customProperties" is defined as not an object or function, it is ignored', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: 123 + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + }, + shouldIgnoreRequest: [isEngagementEvent], + expectedRequests: [ + { + n: 'pageview', + p: e.toBeUndefined() + }, + { + n: 'subscribed from blog', + p: { + title: 'A blog post title' + } + } + ] + }) + }) + + test('if "customProperties" is defined to be a function that does not return an object, its output is ignored', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: () => 'not an object' + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + }, + shouldIgnoreRequest: [isEngagementEvent], + expectedRequests: [ + { + n: 'pageview', + p: e.toBeUndefined() + }, + { + n: 'subscribed from blog', + p: { + title: 'A blog post title' + } + } + ] + }) + }) + }) +} diff --git a/tracker/test/file-downloads.spec.ts b/tracker/test/file-downloads.spec.ts index 8d5d6a1523..9a72db8c61 100644 --- a/tracker/test/file-downloads.spec.ts +++ b/tracker/test/file-downloads.spec.ts @@ -19,6 +19,156 @@ const DEFAULT_CONFIG: ScriptConfig = { captureOnLocalhost: true } +for (const mode of ['web', 'esm']) { + test.describe(`respects "fileDownloads" v2 config option (${mode})`, () => { + test('does not track file downloads when `fileDownloads: false`', async ({ + page + }, { testId }) => { + const filePath = '/file.csv' + const downloadMock = await mockManyRequests({ + page, + path: `${LOCAL_SERVER_ADDR}${filePath}`, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1 + }) + const config = { ...DEFAULT_CONFIG, fileDownloads: false } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `📥` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'pageview', + d: DEFAULT_CONFIG.domain, + u: `${LOCAL_SERVER_ADDR}${url}` + } + ], + refutedRequests: [{ n: 'File Download' }], + shouldIgnoreRequest: isEngagementEvent + }) + await expect(downloadMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('tracks file downloads when `fileDownloads: true`', async ({ page }, { + testId + }) => { + const filePath = '/file.csv' + const downloadMock = await mockManyRequests({ + page, + path: `${LOCAL_SERVER_ADDR}${filePath}`, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1 + }) + const config = { ...DEFAULT_CONFIG, fileDownloads: true } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `📥` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'pageview', + d: DEFAULT_CONFIG.domain, + u: `${LOCAL_SERVER_ADDR}${url}` + }, + { n: 'File Download', p: { url: `${LOCAL_SERVER_ADDR}${filePath}` } } + ], + shouldIgnoreRequest: isEngagementEvent + }) + await expect(downloadMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('malformed `fileDownloads: "iso"` option enables the feature with default file types', async ({ + page + }, { testId }) => { + const csvFileURL = `https://example.com/file.csv` + const isoFileURL = `https://example.com/file.iso` + + const csvMock = await mockManyRequests({ + page, + path: csvFileURL, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1 + }) + const isoMock = await mockManyRequests({ + page, + path: isoFileURL, + fulfill: { + contentType: 'application/octet-stream' + }, + awaitedRequestCount: 1 + }) + + const config = { + ...DEFAULT_CONFIG, + fileDownloads: 'iso' + } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `📥📥` + }) + await page.goto(url) + await expectPlausibleInAction(page, { + action: () => page.click(`a[href="${csvFileURL}"]`), + expectedRequests: [{ n: 'File Download', p: { url: csvFileURL } }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + await expect(csvMock.getRequestList()).resolves.toHaveLength(1) + + await expectPlausibleInAction(page, { + action: () => page.click(`a[href="${isoFileURL}"]`), + refutedRequests: [{ n: 'File Download' }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + await expect(isoMock.getRequestList()).resolves.toHaveLength(1) + }) + }) +} + for (const mode of ['legacy', 'web']) { test.describe(`file downloads feature legacy/v2 parity (${mode})`, () => { test('tracks download when link opens in same tab', async ({ page }, { @@ -343,17 +493,11 @@ for (const mode of ['legacy', 'web']) { }) await expect(isoMock.getRequestList()).resolves.toHaveLength(1) }) - }) -} -for (const mode of ['web', 'esm']) { - test.describe(`file downloads feature v2-specific (${mode})`, () => { - test('malformed `fileDownloads: "iso"` option enables the feature with default file types', async ({ + test('limitation: does track downloads of links within svg elements', async ({ page }, { testId }) => { const csvFileURL = `https://example.com/file.csv` - const isoFileURL = `https://example.com/file.iso` - const csvMock = await mockManyRequests({ page, path: csvFileURL, @@ -362,46 +506,35 @@ for (const mode of ['web', 'esm']) { }, awaitedRequestCount: 1 }) - const isoMock = await mockManyRequests({ - page, - path: isoFileURL, - fulfill: { - contentType: 'application/octet-stream' - }, - awaitedRequestCount: 1 - }) - const config = { - ...DEFAULT_CONFIG, - fileDownloads: 'iso' - } const { url } = await initializePageDynamically(page, { testId, scriptConfig: switchByMode( { - web: config, - esm: `` + web: { ...DEFAULT_CONFIG, fileDownloads: true }, + legacy: + '' }, mode ), - bodyContent: `📥📥` + bodyContent: ` + + ` }) + + const pageErrors: Error[] = [] + page.on('pageerror', (err) => pageErrors.push(err)) + await page.goto(url) - await expectPlausibleInAction(page, { - action: () => page.click(`a[href="${csvFileURL}"]`), - expectedRequests: [{ n: 'File Download', p: { url: csvFileURL } }], - shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] - }) - await expect(csvMock.getRequestList()).resolves.toHaveLength(1) await expectPlausibleInAction(page, { - action: () => page.click(`a[href="${isoFileURL}"]`), + action: () => page.click('a'), refutedRequests: [{ n: 'File Download' }], shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] }) - await expect(isoMock.getRequestList()).resolves.toHaveLength(1) + + expect(pageErrors).toHaveLength(0) + await expect(csvMock.getRequestList()).resolves.toHaveLength(1) }) }) } diff --git a/tracker/test/fixtures/callbacks.html b/tracker/test/fixtures/callbacks.html deleted file mode 100644 index 54324dd714..0000000000 --- a/tracker/test/fixtures/callbacks.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - callbacks - - - - - - diff --git a/tracker/test/fixtures/plausible-npm.html b/tracker/test/fixtures/plausible-npm.html deleted file mode 100644 index 288a1857cb..0000000000 --- a/tracker/test/fixtures/plausible-npm.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - Plausible NPM package tests - - - - - Download - - Outbound link - - Manual pageview - Manual pageview 2 - - Custom event - - - - diff --git a/tracker/test/fixtures/plausible-web.html b/tracker/test/fixtures/plausible-web.html deleted file mode 100644 index d9d9e3263f..0000000000 --- a/tracker/test/fixtures/plausible-web.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - plausible-web.js tests - - - - Download - Download ISO - - Outbound link - - Manual pageview - Manual pageview 2 - - Custom event - - - - - - diff --git a/tracker/test/hash-based-routing.spec.ts b/tracker/test/hash-based-routing.spec.ts new file mode 100644 index 0000000000..e5ad29a261 --- /dev/null +++ b/tracker/test/hash-based-routing.spec.ts @@ -0,0 +1,123 @@ +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { + e, + expectPlausibleInAction, + hideAndShowCurrentTab, + isEngagementEvent, + switchByMode, + tracker_script_version +} from './support/test-utils' +import { test } from '@playwright/test' +import { ScriptConfig } from './support/types' +import { LOCAL_SERVER_ADDR } from './support/server' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +for (const mode of ['web', 'esm']) { + test.describe(`respects hashBasedRouting config option (${mode})`, () => { + test('pageviews and engagement events are sent without "h" parameter if hashBasedRouting is not set', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: DEFAULT_CONFIG, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + h: e.toBeUndefined() + }, + { n: 'engagement', h: e.toBeUndefined() } + ] + }) + }) + + test('pageviews and engagement events are sent with "h:1" parameter if hashBasedRouting is set to true', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG, hashBasedRouting: true } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(`${url}#page1`) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + h: 1, + u: `${LOCAL_SERVER_ADDR}${url}#page1` + }, + { + n: 'engagement', + h: 1, + u: `${LOCAL_SERVER_ADDR}${url}#page1` + } + ] + }) + }) + }) +} + +test.describe('hash-based routing (legacy)', () => { + test('pageviews and engagement events are sent with "h:1" parameter if using the hash extension', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: ``, + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(`${url}#page1`) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + h: 1, + u: `${LOCAL_SERVER_ADDR}${url}#page1` + }, + { + n: 'engagement', + h: 1, + u: `${LOCAL_SERVER_ADDR}${url}#page1` + } + ] + }) + }) +}) diff --git a/tracker/test/logging.spec.ts b/tracker/test/logging.spec.ts new file mode 100644 index 0000000000..20552bb641 --- /dev/null +++ b/tracker/test/logging.spec.ts @@ -0,0 +1,84 @@ +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { + expectPlausibleInAction, + isEngagementEvent, + switchByMode, + tracker_script_version +} from './support/test-utils' +import { expect, test } from '@playwright/test' +import { ScriptConfig } from './support/types' +import { LOCAL_SERVER_ADDR } from './support/server' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: false +} + +for (const mode of ['web', 'esm']) { + test.describe(`respects "logging" v2 config option (${mode})`, () => { + test('if logging is not explicitly set, it is treated as true and logs are emitted on ingored events', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const consoleMessages: [string, string][] = [] + page.on('console', (message) => { + consoleMessages.push([message.type(), message.text()]) + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: 'hello world' + }) + + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [], + refutedRequests: [{ n: 'pageview' }] + }) + + expect(consoleMessages).toEqual([ + ['warning', 'Ignoring Event: localhost'] + ]) + }) + + test('if logging is explicitly set to false, logs are not emitted on ingored events', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG, logging: false } + const consoleMessages: [string, string][] = [] + page.on('console', (message) => { + consoleMessages.push([message.type(), message.text()]) + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: 'hello world' + }) + + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [], + refutedRequests: [{ n: 'pageview' }] + }) + + expect(consoleMessages).toEqual([]) + }) + }) +} diff --git a/tracker/test/outbound-links.spec.ts b/tracker/test/outbound-links.spec.ts index 172ba79dc3..a165e300fe 100644 --- a/tracker/test/outbound-links.spec.ts +++ b/tracker/test/outbound-links.spec.ts @@ -3,7 +3,12 @@ import { mockManyRequests, resolveWithTimestamps } from './support/mock-many-requests' -import { expectPlausibleInAction, switchByMode } from './support/test-utils' +import { + expectPlausibleInAction, + isEngagementEvent, + isPageviewEvent, + switchByMode +} from './support/test-utils' import { expect, test } from '@playwright/test' import { ScriptConfig } from './support/types' import { LOCAL_SERVER_ADDR } from './support/server' @@ -14,6 +19,104 @@ const DEFAULT_CONFIG: ScriptConfig = { captureOnLocalhost: true } +for (const mode of ['web', 'esm']) { + test.describe(`respects "outboundLinks" v2 config option (${mode})`, () => { + test('does not track outbound links when "outboundLinks: false"', async ({ + page + }, { testId }) => { + const outboundUrl = 'https://other.example.com/target' + const outboundMock = await mockManyRequests({ + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: 'other pageother page' + }, + awaitedRequestCount: 1 + }) + const config = { ...DEFAULT_CONFIG, outboundLinks: false } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `➡️` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'pageview', + d: DEFAULT_CONFIG.domain, + u: `${LOCAL_SERVER_ADDR}${url}` + } + ], + refutedRequests: [{ n: 'Outbound Link: Click' }], + shouldIgnoreRequest: isEngagementEvent + }) + await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('tracks outbound links when "outboundLinks: true"', async ({ page }, { + testId + }) => { + const outboundUrl = 'https://other.example.com/target' + const outboundMock = await mockManyRequests({ + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: 'other pageother page' + }, + awaitedRequestCount: 1 + }) + const config = { ...DEFAULT_CONFIG, outboundLinks: true } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `➡️` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'pageview', + d: DEFAULT_CONFIG.domain, + u: `${LOCAL_SERVER_ADDR}${url}` + }, + { n: 'Outbound Link: Click', p: { url: outboundUrl } } + ], + shouldIgnoreRequest: isEngagementEvent + }) + await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) + }) + }) +} + for (const mode of ['legacy', 'web']) test.describe(`outbound links feature legacy/v2 parity (${mode})`, () => { for (const { clickName, click, skip } of [ @@ -139,6 +242,55 @@ for (const mode of ['legacy', 'web']) }) ]) }) + + test('limitation: does not track outbound links within svg elements', async ({ + page + }, { testId }) => { + const outboundUrl = 'https://other.example.com/target' + const outboundMock = await mockManyRequests({ + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: 'other pageother page' + }, + awaitedRequestCount: 1 + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG, outboundLinks: true }, + legacy: + '' + }, + mode + ), + bodyContent: ` + + + + + + ` + }) + + const pageErrors: Error[] = [] + page.on('pageerror', (err) => pageErrors.push(err)) + + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + refutedRequests: [{ n: 'Outbound Link: Click' }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + expect(pageErrors).toHaveLength(0) + await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) + }) }) test.describe('outbound links feature when using legacy .compat extension', () => { @@ -313,4 +465,58 @@ test.describe('outbound links feature when using legacy .compat extension', () = await expect(page.getByText('other page')).toBeVisible() await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) }) + + test(`limitation: does not track outbound links within svg elements, but follows link properly`, async ({ + page + }, { testId }) => { + const outboundUrl = 'https://other.example.com/target' + const outboundMockOptions = { + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: 'other pageother page' + }, + awaitedRequestCount: 2, + mockRequestTimeout: 2000 + } + + const outboundMockForOtherPages = await mockManyRequests({ + ...outboundMockOptions, + scopeMockToPage: false + }) + const outboundMockForSamePage = await mockManyRequests({ + ...outboundMockOptions, + scopeMockToPage: true + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: ` + + + + + + ` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + refutedRequests: [{ n: 'Outbound Link: Click' }] + }) + + const [requestsOnOtherPages, requestsOnSamePage] = await Promise.all([ + outboundMockForOtherPages.getRequestList().then((d) => d.length), + outboundMockForSamePage.getRequestList().then((d) => d.length) + ]) + expect({ requestsOnOtherPages, requestsOnSamePage }).toEqual({ + requestsOnOtherPages: 0, + requestsOnSamePage: 1 + }) + }) }) diff --git a/tracker/test/pageview.spec.ts b/tracker/test/pageview.spec.ts index 6ffce6aa2c..57725c0983 100644 --- a/tracker/test/pageview.spec.ts +++ b/tracker/test/pageview.spec.ts @@ -1,6 +1,8 @@ import { initializePageDynamically } from './support/initialize-page-dynamically' import { + e, expectPlausibleInAction, + hideAndShowCurrentTab, isEngagementEvent, switchByMode, tracker_script_version @@ -15,6 +17,93 @@ const DEFAULT_CONFIG: ScriptConfig = { captureOnLocalhost: true } +for (const mode of ['web', 'esm']) { + test.describe(`"autoCapturePageviews" v2 config option (${mode})`, () => { + test('if autoCapturePageviews is not explicitly set, it is treated as true and a pageview is sent on navigating to page, engagement tracking is triggered', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: 'hello world' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + d: config.domain, + u: `${LOCAL_SERVER_ADDR}${url}`, + v: tracker_script_version, + p: e.toBeUndefined() + }, + { + n: 'engagement', + d: config.domain, + u: `${LOCAL_SERVER_ADDR}${url}`, + v: tracker_script_version, + p: e.toBeUndefined() + } + ] + }) + }) + + test('if autoCapturePageviews is explicitly set to false, a pageview is not sent on navigating to page, but sending pageviews manually works and it starts engagement tracking logic', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG, autoCapturePageviews: false } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: ` + A + B + ` + }) + + await expectPlausibleInAction(page, { + action: () => page.goto(url), + refutedRequests: [{ n: 'pageview' }, { n: 'engagement' }] + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.click('#alfa') + await page.click('#beta') + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { n: 'pageview', u: '/:masked/alfa', d: config.domain }, + { n: 'engagement', u: '/:masked/alfa', d: config.domain }, + { n: 'pageview', u: '/:masked/beta', d: config.domain }, + { n: 'engagement', u: '/:masked/beta', d: config.domain } + ] + }) + }) + }) +} + for (const mode of ['legacy', 'web', 'esm']) { test.describe(`pageviews parity legacy/v2 (${mode})`, () => { test('sends pageview on navigating to page', async ({ page }, { @@ -28,8 +117,7 @@ for (const mode of ['legacy', 'web', 'esm']) { esm: ``, - legacy: - '' + legacy: `` }, mode ), @@ -38,7 +126,15 @@ for (const mode of ['legacy', 'web', 'esm']) { await expectPlausibleInAction(page, { action: () => page.goto(url), - expectedRequests: [{ n: 'pageview', v: tracker_script_version }], + expectedRequests: [ + { + n: 'pageview', + d: DEFAULT_CONFIG.domain, + u: `${LOCAL_SERVER_ADDR}${url}`, + v: tracker_script_version, + h: e.toBeUndefined(), + } + ], shouldIgnoreRequest: [isEngagementEvent] }) }) diff --git a/tracker/test/plausible-npm-init.spec.ts b/tracker/test/plausible-npm-init.spec.ts new file mode 100644 index 0000000000..0093d6813b --- /dev/null +++ b/tracker/test/plausible-npm-init.spec.ts @@ -0,0 +1,179 @@ +import { test, expect } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' +import { + isEngagementEvent, + expectPlausibleInAction, + tracker_script_version, + hideAndShowCurrentTab +} from './support/test-utils' +import { initializePageDynamically } from './support/initialize-page-dynamically' + +const DEFAULT_CONFIG = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +test('if `init` is called without domain, it throws', async ({ page }, { + testId +}) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: ``, + bodyContent: 'body' + }) + await page.goto(url) + const config = { ...DEFAULT_CONFIG, domain: undefined } + await expect( + page.evaluate((config) => (window as any).init(config), { config }) + ).rejects.toThrow('plausible.init(): domain argument is required') +}) + +test('if `init` is called with no configuration, it throws', async ({ page }, { + testId +}) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: ``, + bodyContent: 'body' + }) + await page.goto(url) + await expect(page.evaluate(() => (window as any).init())).rejects.toThrow( + 'plausible.init(): domain argument is required' + ) +}) + +test('if `track` is called before `init`, it throws', async ({ page }, { + testId +}) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: ``, + bodyContent: 'body' + }) + await page.goto(url) + await expect( + page.evaluate(() => (window as any).track('purchase')) + ).rejects.toThrow( + 'plausible.track() can only be called after plausible.init()' + ) +}) + +test('if `init` is called twice, it throws, but tracking still works', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: ``, + bodyContent: 'body' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + }, + expectedRequests: [{ n: 'pageview' }], + shouldIgnoreRequest: isEngagementEvent + }) + + await expect( + page.evaluate((config) => (window as any).init(config), config) + ).rejects.toThrow('plausible.init() can only be called once') + + await expectPlausibleInAction(page, { + action: async () => { + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [{ n: 'engagement' }] + }) +}) + +test('`bindToWindow` is true by default, and plausible is attached to window', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: ``, + bodyContent: 'body' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + }, + expectedRequests: [{ n: 'pageview' }] + }) + await expect( + page.waitForFunction(() => (window as any).plausible?.l !== undefined) + ).resolves.toBeTruthy() + await expect( + page.evaluate(() => { + if ((window as any).plausible?.l) { + return { + l: (window as any).plausible.l, + v: (window as any).plausible.v, + s: (window as any).plausible.s + } + } + return false + }) + ).resolves.toEqual({ l: true, v: tracker_script_version, s: 'npm' }) +}) + +test('if `bindToWindow` is false, `plausible` is not attached to window', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG, bindToWindow: false } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: ``, + bodyContent: 'body' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + }, + expectedRequests: [{ n: 'pageview' }], + shouldIgnoreRequest: isEngagementEvent + }) + + await expect( + page.waitForFunction( + () => (window as any).plausible !== undefined, + undefined, + { + timeout: 1000 + } + ) + ).rejects.toThrow('page.waitForFunction: Timeout 1000ms exceeded.') +}) + +test('allows overriding `endpoint` with a custom URL via `init`', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG, endpoint: 'http://example.com/event' } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: ``, + bodyContent: 'body' + }) + await expectPlausibleInAction(page, { + pathToMock: config.endpoint, + action: () => page.goto(url), + expectedRequests: [ + { n: 'pageview', d: config.domain, u: `${LOCAL_SERVER_ADDR}${url}` } + ], + shouldIgnoreRequest: isEngagementEvent + }) +}) diff --git a/tracker/test/plausible-npm.spec.js b/tracker/test/plausible-npm.spec.js deleted file mode 100644 index ff225105c7..0000000000 --- a/tracker/test/plausible-npm.spec.js +++ /dev/null @@ -1,77 +0,0 @@ -/* -Tests for plausible-npm.js variant - -Config is set at init(), as we expect consumers to do in production. -*/ -import { test, expect } from '@playwright/test' -import { LOCAL_SERVER_ADDR } from './support/server' -import { testPlausibleConfiguration, callInit } from './shared-configuration-tests' - -const DEFAULT_CONFIG = { - domain: 'example.com', - endpoint: `${LOCAL_SERVER_ADDR}/api/event`, - captureOnLocalhost: true -} - -async function openPage(page, config, options = {}) { - await page.goto("/plausible-npm.html") - await page.waitForFunction('window.init !== undefined') - - if (!options.skipPlausibleInit) { - await callInit(page, { ...DEFAULT_CONFIG, ...config }, 'window') - } -} - -test.describe('NPM package', () => { - testPlausibleConfiguration({ - openPage, - initPlausible: (page, config) => callInit(page, { ...DEFAULT_CONFIG, ...config }, 'window'), - fixtureName: 'plausible-npm.html', - fixtureTitle: 'Plausible NPM package tests' - }) - - test('does not support calling `init` without `domain`', async ({ page }) => { - await openPage(page, {}, { skipPlausibleInit: true }) - await expect(async () => { - await callInit(page, { hashBasedRouting: true }, 'window') - }).rejects.toThrow("plausible.init(): domain argument is required") - }) - - test('does not support calling `init` with no configuration', async ({ page }) => { - await openPage(page, {}, { skipPlausibleInit: true }) - await expect(async () => { - await callInit(page, undefined, 'window') - }).rejects.toThrow("plausible.init(): domain argument is required") - }) - - test('track throws if called before init', async ({ page }) => { - await openPage(page, {}, { skipPlausibleInit: true }) - await expect(async () => { - await page.evaluate(() => { - window.track('pageview') - }) - }).rejects.toThrow("plausible.track() can only be called after plausible.init()") - }) - - test('does not support calling `init` twice', async ({ page }) => { - await openPage(page, {}, { skipPlausibleInit: true }) - await callInit(page, DEFAULT_CONFIG, 'window') - await expect(async () => { - await callInit(page, DEFAULT_CONFIG, 'window') - }).rejects.toThrow("plausible.init() can only be called once") - }) - - test('binds to window by default', async ({ page }) => { - await openPage(page, {}, { skipPlausibleInit: true }) - await callInit(page, DEFAULT_CONFIG, 'window') - await page.waitForFunction('window.plausible !== undefined') - }) - - test('does not bind to window if bindToWindow is false', async ({ page }) => { - await openPage(page, {}, { skipPlausibleInit: true }) - await callInit(page, { ...DEFAULT_CONFIG, bindToWindow: false }, 'window') - await expect( - page.waitForFunction(() => window.plausible !== undefined, undefined, { timeout: 1000 }), - ).rejects.toThrow('page.waitForFunction: Timeout 1000ms exceeded.') - }) -}) diff --git a/tracker/test/plausible-web-init.spec.ts b/tracker/test/plausible-web-init.spec.ts new file mode 100644 index 0000000000..a07509e966 --- /dev/null +++ b/tracker/test/plausible-web-init.spec.ts @@ -0,0 +1,154 @@ +/* +Tests for plausible-web.js script variant + +Unlike in production, we're manually interpolating the script config in this file to +better test the script in isolation of the plausible codebase. +*/ + +import { + expectPlausibleInAction, + isEngagementEvent +} from './support/test-utils' +import { test, expect } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' +import { + getConfiguredPlausibleWebSnippet, + initializePageDynamically +} from './support/initialize-page-dynamically' + +const DEFAULT_CONFIG = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +test('with queue code from the web snippet, tracks `plausible` calls made before the script is loaded', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: config, + bodyContent: + '' + }) + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [ + { + n: 'loaded', + p: { plausibleLoadedAtEventTime: false }, + i: false, + d: config.domain, + u: `${LOCAL_SERVER_ADDR}${url}` + }, + { n: 'pageview', d: config.domain, u: `${LOCAL_SERVER_ADDR}${url}` } + ] + }) +}) + +test('handles double-initialization of the script with a console.warn', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG, customProperties: { init: 1 } } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: config, + bodyContent: `` + }) + const messages: [string, string][] = [] + page.on('console', (message) => { + messages.push([message.type(), message.text()]) + }) + + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [{ n: 'pageview', p: { init: 1 } }], + shouldIgnoreRequest: isEngagementEvent + }) + + await expect( + page.evaluate(() => + (window as any).plausible.init({ + captureOnLocalhost: true, + customProperties: { init: 2 } + }) + ) + ).resolves.toBeUndefined() + + expect(messages).toEqual([ + [ + 'warning', + 'Plausible analytics script was already initialized, skipping init' + ] + ]) + + await expectPlausibleInAction(page, { + action: () => page.click('button'), + expectedRequests: [{ n: 'Purchase', p: { init: 2 } }] // bug or maybe feature: expected to be { init: 1 } + }) +}) + +test('if there are two snippets on the page, the second one that loads interacts with the first one, no warning is emitted', async ({ page }, { + testId +}) => { + const config = { ...DEFAULT_CONFIG } + const snippetAlfa = getConfiguredPlausibleWebSnippet({...config, customProperties: { alfa: true }}) + const initCallAlfa = 'plausible.init({"captureOnLocalhost":true,"customProperties":{"alfa":true}})' + expect(snippetAlfa).toEqual(expect.stringContaining(initCallAlfa)) + const snippetBeta = getConfiguredPlausibleWebSnippet({...config, customProperties: { beta: true }}) + const initCallBeta = `plausible.init({"captureOnLocalhost":true,"customProperties":{"beta":true}})` + expect(snippetBeta).toEqual(expect.stringContaining(initCallBeta)) + + const messages: [string, string][] = [] + page.on('console', (message) => { + messages.push([message.type(), message.text()]) + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: `${snippetAlfa}${snippetBeta}`, + bodyContent: '' + }) + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [ + { n: 'pageview', d: config.domain, u: `${LOCAL_SERVER_ADDR}${url}`, p: { beta: true } }, + ], + shouldIgnoreRequest: isEngagementEvent + }) + expect(messages).toEqual([]) + +}) + +test('if domain is provided in `init`, it is ignored', async ({ page }, { + testId +}) => { + const config = { ...DEFAULT_CONFIG } + const scriptConfig = getConfiguredPlausibleWebSnippet(config) + const originalInitCall = 'plausible.init({"captureOnLocalhost":true})' + // verify that the original snippet is what we expect it to be + expect(scriptConfig).toEqual(expect.stringContaining(originalInitCall)) + const initCallWithDomainOverride = `plausible.init({"captureOnLocalhost":true,"domain":"sub.${config.domain}"})` + const updatedScriptConfig = scriptConfig.replace( + originalInitCall, + initCallWithDomainOverride + ) + // verify that the updated snippet has the domain override + expect(updatedScriptConfig).toEqual( + expect.stringContaining(initCallWithDomainOverride) + ) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: updatedScriptConfig, + bodyContent: '' + }) + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [ + { n: 'pageview', d: config.domain, u: `${LOCAL_SERVER_ADDR}${url}` } + ], + shouldIgnoreRequest: isEngagementEvent + }) +}) diff --git a/tracker/test/plausible-web.spec.js b/tracker/test/plausible-web.spec.js deleted file mode 100644 index b448bac839..0000000000 --- a/tracker/test/plausible-web.spec.js +++ /dev/null @@ -1,97 +0,0 @@ -/* -Tests for plausible-web.js script variant - -Unlike in production, we're manually interpolating the script config in this file to -better test the script in isolation of the plausible codebase. -*/ - -import { - expectPlausibleInAction, - e as expecting -} from './support/test-utils' -import { test, expect } from '@playwright/test' -import { LOCAL_SERVER_ADDR } from './support/server' -import { testPlausibleConfiguration, callInit } from './shared-configuration-tests' - -const DEFAULT_CONFIG = { - domain: 'example.com', - endpoint: `${LOCAL_SERVER_ADDR}/api/event`, - captureOnLocalhost: true -} - -async function openPage(page, config, options = {}) { - const configJson = JSON.stringify({ ...DEFAULT_CONFIG, ...config }) - let path = `/plausible-web.html?script_config=${configJson}` - if (options.beforeScriptLoaded) { - path += `&beforeScriptLoaded=${options.beforeScriptLoaded}` - } - if (options.skipPlausibleInit) { - path += `&skipPlausibleInit=1` - } - await page.goto(path) - await page.waitForFunction('window.plausible !== undefined') -} - -test.describe('plausible-web.js', () => { - testPlausibleConfiguration({ - openPage, - initPlausible: (page, config) => callInit(page, config, 'window.plausible'), - fixtureName: 'plausible-web.html', - fixtureTitle: 'plausible-web.js tests' - }) - - test('with queue code included, respects `plausible` calls made before the script is loaded', async ({ page }) => { - await expectPlausibleInAction(page, { - action: () => openPage(page, {}, { beforeScriptLoaded: 'window.plausible("custom-event", { props: { foo: "bar" }, interactive: false })' }), - expectedRequests: [{ n: 'custom-event', p: { foo: 'bar' }, i: false }, { n: 'pageview' }] - }) - }) - - test('handles double-initialization of the script with a console.warn', async ({ page }) => { - const consolePromise = page.waitForEvent('console') - - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}) - await page.evaluate(() => { - window.plausible.init() - }) - await consolePromise - }, - expectedRequests: [{ n: 'pageview' }] - }) - - const warning = await consolePromise - expect(warning.type()).toBe("warning") - expect(warning.text()).toContain('Plausible analytics script was already initialized, skipping init') - }) - - test('handles the script being loaded and initialized multiple times', async ({ page }) => { - const consolePromise = page.waitForEvent('console') - - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}) - await page.evaluate(() => { - window.includePlausibleScript() - }) - await consolePromise - }, - expectedRequests: [{ n: 'pageview' }] - }) - - const warning = await consolePromise - expect(warning.type()).toBe("warning") - expect(warning.text()).toContain('Plausible analytics script was already initialized, skipping init') - }) - - test('does not support overriding domain via `init`', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await callInit(page, { domain: 'another-domain.com' }, 'window.plausible') - }, - expectedRequests: [{ n: 'pageview', d: 'example.com', u: expecting.stringContaining('plausible-web.html') }] - }) - }) -}) diff --git a/tracker/test/shared-configuration-tests.js b/tracker/test/shared-configuration-tests.js deleted file mode 100644 index ca94d5c516..0000000000 --- a/tracker/test/shared-configuration-tests.js +++ /dev/null @@ -1,328 +0,0 @@ -import { - expectPlausibleInAction, - hideAndShowCurrentTab, - e as expecting, - isPageviewEvent, - isEngagementEvent, - delay -} from './support/test-utils' -import { test, expect } from '@playwright/test' - -// Wrapper around calling `plausible.init` in the page context for users of `testPlausibleConfiguration` -export async function callInit(page, config, parent) { - // Stringify the customProperties and transformRequest functions to work around evaluate not being able to serialize functions - if (config && typeof config.customProperties === 'function') { - config.customProperties = { "_wrapFunction": config.customProperties.toString() } - } - if (config && typeof config.transformRequest === 'function') { - config.transformRequest = { "_wrapFunction": config.transformRequest.toString() } - } - - await page.evaluate(({ config, parent }) => { - if (config && config.customProperties && config.customProperties._wrapFunction) { - config.customProperties = new Function(`return (${config.customProperties._wrapFunction})`)(); - } - if (config && config.transformRequest && config.transformRequest._wrapFunction) { - config.transformRequest = new Function(`return (${config.transformRequest._wrapFunction})`)(); - } - eval(parent).init(config) - }, { config, parent }) -} - -export function testPlausibleConfiguration({ openPage, initPlausible, fixtureName, fixtureTitle }) { - test.describe('shared configuration tests', () => { - test.beforeEach(async ({ page }) => { - // Mock file download requests - await page.context().route('https://awesome.website.com/file.pdf', async (request) => { - await request.fulfill({status: 200, contentType: 'application/pdf', body: 'mocked pdf content'}) - }) - }) - - test('triggers pageview and engagement automatically', async ({ page }) => { - await expectPlausibleInAction(page, { - action: () => openPage(page, {}), - expectedRequests: [{ n: 'pageview', d: 'example.com', u: expecting.stringContaining(fixtureName)}], - }) - - await expectPlausibleInAction(page, { - action: () => hideAndShowCurrentTab(page, {delay: 2000}), - expectedRequests: [{n: 'engagement', d: 'example.com', u: expecting.stringContaining(fixtureName)}], - }) - }) - - test('logs, does not trigger any events when `local` config is disabled', async ({ page }) => { - const consolePromise = page.waitForEvent('console') - - await expectPlausibleInAction(page, { - action: () => openPage(page, { captureOnLocalhost: false }), - expectedRequests: [], - refutedRequests: [{ n: 'pageview' }] - }) - - const warning = await consolePromise - expect(warning.type()).toBe("warning") - expect(warning.text()).toEqual('Ignoring Event: localhost') - }) - - test('does not log when `local` and `logging` config is disabled', async ({ page }) => { - const consolePromise = page.waitForEvent('console') - - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { captureOnLocalhost: false, logging: false }) - }, - expectedRequests: [], - refutedRequests: [{ n: 'pageview' }] - }) - - const result = await Promise.race([consolePromise, delay(1000)]) - expect(result).toBeUndefined() - }) - - test('does not track pageview props without the feature being enabled', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}) - }, - expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }], - shouldIgnoreRequest: isEngagementEvent - }) - }) - - test('does not track outbound links without the feature being enabled', async ({ page, browserName }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}) - await page.click('#outbound-link') - }, - expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }], - refutedRequests: [{ n: 'Outbound Link: Click' }], - shouldIgnoreRequest: isEngagementEvent - }) - }) - - test('does not track file downloads without feature being enabled', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}) - await page.click('#file-download', { modifiers: ['ControlOrMeta'] }) - }, - expectedRequests: [{ n: 'pageview' } ], - refutedRequests: [{ n: 'File Download' }], - shouldIgnoreRequest: isEngagementEvent - }) - }) - - test('tracks outbound links (when feature enabled)', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, { outboundLinks: true }) - await page.click('#outbound-link') - }, - expectedRequests: [ - { n: 'pageview', d: 'example.com', u: expecting.stringContaining(fixtureName) }, - { n: 'Outbound Link: Click', d: 'example.com', u: expecting.stringContaining(fixtureName), p: { url: 'https://example.com/' } } - ], - shouldIgnoreRequest: isEngagementEvent - }) - }) - - test('tracks file downloads (when feature enabled)', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, { fileDownloads: true }) - await page.click('#file-download') - }, - expectedRequests: [{ n: 'File Download', p: { url: 'https://awesome.website.com/file.pdf' } }], - shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] - }) - }) - - test('tracks static custom pageview properties (when feature enabled)', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { customProperties: { "some-prop": "456" } }) - }, - expectedRequests: [{ n: 'pageview', p: { "some-prop": "456" } }] - }) - }) - - test('tracks dynamic custom pageview properties (when feature enabled)', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { customProperties: () => ({ "title": document.title }) }) - }, - expectedRequests: [{ n: 'pageview', p: { "title": fixtureTitle } }] - }) - }) - - test('tracks dynamic custom pageview properties with custom events and engagements', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { - customProperties: (eventName) => ({ "author": "Uku", "some-prop": "456", [eventName]: "1" }) - }) - - await page.click('#custom-event') - await hideAndShowCurrentTab(page, { delay: 200 }) - }, - expectedRequests: [ - { n: 'pageview', p: { "author": "Uku", "some-prop": "456", "pageview": "1"} }, - // Passed property to `plausible` call overrides the default from `config.customProperties` - { n: 'Custom event', p: { "author": "Karl", "some-prop": "456", "Custom event": "1" } }, - // Engagement event inherits props from the pageview event - { n: 'engagement', p: { "author": "Uku", "some-prop": "456", "pageview": "1"} }, - ] - }) - }) - - test('invalid function customProperties are ignored', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { customProperties: () => document.title }) - }, - expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }] - }) - }) - - test('invalid customProperties are ignored', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { customProperties: "abcdef" }) - }, - expectedRequests: [{ n: 'pageview', p: expecting.toBeUndefined() }] - }) - }) - - test('autoCapturePageviews=false mode does not track pageviews', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { autoCapturePageviews: false }) - await hideAndShowCurrentTab(page, { delay: 200 }) - }, - expectedRequests: [], - refutedRequests: [{ n: 'pageview' }, { n: 'engagement' }] - }) - }) - - test('autoCapturePageviews=false mode after manual pageview continues tracking', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { autoCapturePageviews: false }) - await page.click('#manual-pageview-1') - await page.click('#manual-pageview-2') - await hideAndShowCurrentTab(page, { delay: 200 }) - }, - expectedRequests: [ - { n: 'pageview', u: '/:test-plausible', d: 'example.com' }, - { n: 'engagement', u: '/:test-plausible', d: 'example.com' }, - { n: 'pageview', u: '/:test-plausible-2', d: 'example.com' }, - { n: 'engagement', u: '/:test-plausible-2', d: 'example.com' }, - ], - }) - }) - - test('does not send `h` parameter when `hashBasedRouting` config is disabled', async ({ page }) => { - await expectPlausibleInAction(page, { - action: () => openPage(page, {}), - expectedRequests: [{ n: 'pageview', h: expecting.toBeUndefined() }] - }) - }) - - test('sends `h` parameter when `hash` config is enabled', async ({ page }) => { - await expectPlausibleInAction(page, { - action: () => openPage(page, { hashBasedRouting: true }), - expectedRequests: [{ n: 'pageview', h: 1 }] - }) - }) - - test('tracking tagged events with revenue', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}) - await page.click('#tagged-event') - }, - expectedRequests: [{ n: 'Purchase', p: { foo: 'bar' }, $: { currency: 'EUR', amount: '13.32' } }], - shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] - }) - }) - - test('supports overriding the endpoint with a custom proxy via `init`', async ({ page }) => { - await expectPlausibleInAction(page, { - pathToMock: 'http://proxy.io/endpoint', - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { endpoint: 'http://proxy.io/endpoint' }) - }, - expectedRequests: [{ n: 'pageview', d: 'example.com', u: expecting.stringContaining(fixtureName)}] - }) - }) - - test('supports ignoring requests with `transformRequest`', async ({ page }) => { - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { transformRequest: () => null }) - await page.click('#custom-event') - }, - expectedRequests: [], - refutedRequests: [{ n: 'Custom event' }, { n: 'pageview' }] - }) - }) - - test('supports modifying the request payload with `transformRequest`', async ({ page }) => { - const transformRequest = (payload) => { - payload.p = payload.p || {} - payload.p.eventName = payload.n - payload.i = false - - return payload - } - - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { transformRequest }) - await page.click('#custom-event') - }, - expectedRequests: [ - { n: 'pageview', p: { eventName: 'pageview' }, i: false }, - { n: 'Custom event', p: { eventName: 'Custom event', author: 'Karl' }, i: false } - ] - }) - }) - - test('transformRequest props are sent with engagement events', async ({ page }) => { - const transformRequest = (payload) => { - payload.p = payload.p || {} - - window.requestCount = (window.requestCount || 0) + 1 - payload.p.requestCount = window.requestCount - - return payload - } - - await expectPlausibleInAction(page, { - action: async () => { - await openPage(page, {}, { skipPlausibleInit: true }) - await initPlausible(page, { transformRequest }) - await page.click('#custom-event') - await hideAndShowCurrentTab(page, { delay: 200 }) - }, - expectedRequests: [ - { n: 'pageview', p: { requestCount: 1 } }, - { n: 'Custom event', p: { author: 'Karl', requestCount: 2 } }, - { n: 'engagement', p: { requestCount: 1 } } - ] - }) - }) - }) -} diff --git a/tracker/test/support/initialize-page-dynamically.ts b/tracker/test/support/initialize-page-dynamically.ts index 493c585bf4..52a9154b1e 100644 --- a/tracker/test/support/initialize-page-dynamically.ts +++ b/tracker/test/support/initialize-page-dynamically.ts @@ -42,21 +42,62 @@ const RESPONSE_BODY_TEMPLATE = ` const PLAUSIBLE_WEB_SNIPPET = compileWebSnippet() -function getConfiguredPlausibleWebSnippet({ - autoCapturePageviews, - ...injectedScriptConfig +export function serializeWithFunctions(obj: Record): string { + const functions: Record = {} + let counter = 0 + + const jsonString = JSON.stringify(obj, (_key, value) => { + if (typeof value === 'function') { + const placeholder = `__FUNCTION_${counter++}__` + functions[placeholder] = value.toString() + return placeholder + } + return value + }) + + // Replace placeholders with actual function strings + let result = jsonString + for (const [placeholder, funcString] of Object.entries(functions)) { + result = result.replace(`"${placeholder}"`, funcString) + } + + return result +} + +export function getConfiguredPlausibleWebSnippet({ + hashBasedRouting, + outboundLinks, + fileDownloads, + formSubmissions, + domain, + endpoint, + ...initOverrideOptions }: ScriptConfig): string { + const injectedScriptConfig = { + domain, + endpoint, + hashBasedRouting, + outboundLinks, + fileDownloads, + formSubmissions + } const snippet = PLAUSIBLE_WEB_SNIPPET.replace( '<%= plausible_script_url %>', `/tracker/js/plausible-web.js?script_config=${encodeURIComponent( JSON.stringify(injectedScriptConfig) )}` ) - // This option, if provided, must be lifted to script init(overrides) overrides, otherwise it is ignored. It was not meant to be injected. - if (autoCapturePageviews !== undefined) { + + if ( + Object.entries(initOverrideOptions).some( + ([_key, value]) => value !== undefined + ) + ) { + const serializedOptions = serializeWithFunctions(initOverrideOptions) + return snippet.replace( 'plausible.init()', - `plausible.init({"autoCapturePageviews":${JSON.stringify(autoCapturePageviews)}})` + `plausible.init(${serializedOptions})` ) } return snippet diff --git a/tracker/test/support/mock-many-requests.ts b/tracker/test/support/mock-many-requests.ts index 3694b9faea..2580abb214 100644 --- a/tracker/test/support/mock-many-requests.ts +++ b/tracker/test/support/mock-many-requests.ts @@ -47,7 +47,7 @@ export async function mockManyRequests({ awaitedRequestCount, responseDelay, shouldIgnoreRequest, - mockRequestTimeout = 3000 + mockRequestTimeout = 1000 }: MockManyRequestsOptions) { const requestList: unknown[] = [] const scope = scopeMockToPage ? page : page.context() diff --git a/tracker/test/support/test-utils.js b/tracker/test/support/test-utils.js index 55f1be1ff6..c314bc965a 100644 --- a/tracker/test/support/test-utils.js +++ b/tracker/test/support/test-utils.js @@ -14,6 +14,7 @@ export const tracker_script_version = packageJson.tracker_script_version * @param {Function} args.action - A function that returns a promise. The function is called * without arguments, and is `await`ed. This is the action that should or should not trigger * Plausible requests on the page. + * @param {string} [args.pathToMock] - The path to create the route at and mock the response for. * @param {Array} [args.expectedRequests] - A list of partial JSON payloads that get matched * against the bodies of event requests made. An `expectedRequest` is considered as having * occurred if all of its key-value pairs are found from the JSON body of an event request diff --git a/tracker/test/support/types.ts b/tracker/test/support/types.ts index ba20c215a4..441e8789bb 100644 --- a/tracker/test/support/types.ts +++ b/tracker/test/support/types.ts @@ -5,6 +5,9 @@ export type Options = { formSubmissions: boolean captureOnLocalhost: boolean autoCapturePageviews: boolean + customProperties: Record | ((eventName: string) => Record) + transformRequest: (payload: unknown) => unknown, + logging: boolean } export type ScriptConfig = { diff --git a/tracker/test/tagged-events.spec.ts b/tracker/test/tagged-events.spec.ts index 5a3a98edce..b6a8c0458f 100644 --- a/tracker/test/tagged-events.spec.ts +++ b/tracker/test/tagged-events.spec.ts @@ -31,6 +31,42 @@ test.beforeEach(async ({ page }) => { }) }) +for (const mode of ['web', 'esm']) { + test(`tagged events tracking is always on (${mode})`, async ({ page }, { + testId + }) => { + const config = { ...DEFAULT_CONFIG } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + esm: `` + }, + mode + ), + bodyContent: `Purchase` + }) + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'Purchase', + p: { discounted: 'true', url: 'https://example.com/target' }, + $: { currency: 'EUR', amount: '13.32' } + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + await expect(page.getByText('mocked page')).toBeVisible() + }) +} + for (const mode of ['legacy', 'web']) { test.describe(`tagged events feature legacy/v2 parity (${mode})`, () => { test('tracks link click and child of link click when link is tagged (using plausible-event-... double dash syntax)', async ({ @@ -361,6 +397,54 @@ for (const mode of ['legacy', 'web']) { ]) }) + test('tracks tagged link clicks even if the link is within an svg tag, but fails to include href properly', async ({ + page + }, { testId }) => { + const targetPage = await initializePageDynamically(page, { + testId, + scriptConfig: '', + bodyContent: `

Navigation successful

`, + path: '/target' + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: ` + + + + + + ` + }) + + const pageErrors: Error[] = [] + page.on('pageerror', (err) => pageErrors.push(err)) + + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('circle'), + expectedRequests: [ + { + n: 'link click' + // bug with p.url, can't assert + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + expect(pageErrors).toHaveLength(0) + await expect(page.getByText('Navigation successful')).toBeVisible() + }) + test('does not track button without plausible-event-name class', async ({ page }, { testId }) => { @@ -380,7 +464,7 @@ for (const mode of ['legacy', 'web']) { await expectPlausibleInAction(page, { action: () => page.click('button'), - refutedRequests: [{ n: expect.any(String) }], + refutedRequests: [{}], shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] }) }) @@ -404,7 +488,7 @@ for (const mode of ['legacy', 'web']) { await expectPlausibleInAction(page, { action: () => page.click('span'), - refutedRequests: [{ n: expect.any(String) }], + refutedRequests: [{}], shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] }) }) @@ -588,10 +672,45 @@ test.describe('tagged events feature when using legacy .compat extension', () => await expectPlausibleInAction(page, { action: () => page.click('a'), - refutedRequests: [{ n: expect.any(String) }], - shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + refutedRequests: [{}] }) await expect(page.getByText('Subscription successful')).toBeVisible() }) + + test('does not intercept navigation for links within svgs, causing slow track requests to fail', async ({ + page + }, { testId }) => { + const targetPage = await initializePageDynamically(page, { + testId, + scriptConfig: '', + bodyContent: `

Subscription successful

`, + path: '/target' + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: ` + + + + + + ` + }) + const pageErrors: Error[] = [] + page.on('pageerror', (err) => pageErrors.push(err)) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + refutedRequests: [{}], + responseDelay: 500, + mockRequestTimeout: 250 + }) + expect(pageErrors).toHaveLength(0) + + await expect(page.getByText('Subscription successful')).toBeVisible() + }) }) diff --git a/tracker/test/transform_request.spec.ts b/tracker/test/transform_request.spec.ts new file mode 100644 index 0000000000..12989118e6 --- /dev/null +++ b/tracker/test/transform_request.spec.ts @@ -0,0 +1,255 @@ +import { + initializePageDynamically, + serializeWithFunctions +} from './support/initialize-page-dynamically' +import { + e, + expectPlausibleInAction, + hideAndShowCurrentTab, + isEngagementEvent, + isPageviewEvent, + switchByMode +} from './support/test-utils' +import { test } from '@playwright/test' +import { ScriptConfig } from './support/types' +import { LOCAL_SERVER_ADDR } from './support/server' +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +for (const mode of ['web', 'esm']) { + test.describe(`respects "transformRequest" config option (${mode})`, () => { + test('if "transformRequest" is not a function, nothing happens', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: 123 + } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + p: e.toBeUndefined() + }, + { n: 'engagement', p: e.toBeUndefined() } + ] + }) + }) + + test('if "transformRequest" is set to be a function that returns null conditionally, those events are not sent', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: (payload) => { + if (payload.n === 'Purchase') { + return null + } + return payload + } + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + }, + expectedRequests: [{ n: 'pageview' }], + refutedRequests: [ + { + n: 'Purchase' + } + ], + shouldIgnoreRequest: [isEngagementEvent] + }) + }) + + test('if "transformRequest" is set to be a function, it will be called for all events', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: (payload) => { + return { ...payload, u: '/:masked/path' } + } + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { n: 'pageview', u: '/:masked/path' }, + { n: 'Purchase', u: '/:masked/path' }, + { n: 'engagement', u: '/:masked/path' } + ] + }) + }) + + test('"transformRequest" does not allow making engagement event props different from pageview event props', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: (payload) => { + const w = window as any + w.requestCount = (w.requestCount ?? 0) + 1 + return { ...payload, p: { requestCount: w.requestCount } } + } + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { n: 'pageview', p: { requestCount: 1 } }, + { n: 'Purchase', p: { requestCount: 2 } }, + { n: 'engagement', p: { requestCount: 1 } } + ] + }) + }) + + test('specificity: "transformRequest" runs after custom properties are determined', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: () => ({ + author: 'John Smith' + }), + transformRequest: (payload) => { + return { ...payload, p: { author: 'Jane Doe' } } + } + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + }, + shouldIgnoreRequest: [isEngagementEvent], + expectedRequests: [ + { + n: 'pageview', + p: { + author: 'Jane Doe' + } + } + ] + }) + }) + + test('if "transformRequest" is defined to be a function that does not return an object, the request is still attempted', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: () => 'not an object' + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + }, + shouldIgnoreRequest: [isEngagementEvent], + expectedRequests: ['not an object', 'not an object'] + }) + }) + }) +}