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']
+ })
+ })
+ })
+}