Average Scroll Depth Metric: extracted tracker changes (#4826)

* (cherry-pick) implement scroll depth tracking under pageleave variant

* drop unnecessary vars

* remove unused require

* add scroll depth tests

* improve error messages in test util

* reevaluate currentDocumentHeight on page load

* account for dynamically loaded content when initializing documnent height

* remove all semicolons from tracker specs

* allow window and document globals in tracker eslint

* tweak global tracker dir eslint rules

* update comment

* reevaluate document height on scroll

* add test

* remove unneccessary timeout
This commit is contained in:
RobertJoonas 2024-11-21 15:29:52 +01:00 committed by GitHub
parent 4d9ea15e9e
commit 2a7d02b6f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 402 additions and 91 deletions

View File

@ -2,12 +2,35 @@
"parserOptions": { "ecmaVersion": "latest" }, "parserOptions": { "ecmaVersion": "latest" },
"env": { "node": true, "es6": true }, "env": { "node": true, "es6": true },
"extends": ["eslint:recommended", "plugin:playwright/playwright-test"], "extends": ["eslint:recommended", "plugin:playwright/playwright-test"],
"globals": {
"window": "readonly",
"document": "readonly"
},
"rules": { "rules": {
"max-len": [0, {"code": 120}], "max-len": [0, {"code": 120}],
"max-classes-per-file": [0], "max-classes-per-file": [0],
"no-unused-expressions": [1, { "allowShortCircuit": true }], "no-unused-expressions": [1, { "allowShortCircuit": true }],
"no-unused-vars": [2, { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }], "no-unused-vars": [2, { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }],
"no-prototype-builtins": [0], "no-prototype-builtins": [0],
"playwright/no-conditional-in-test": [0] "playwright/no-conditional-in-test": [0],
"playwright/no-wait-for-timeout": "off",
"playwright/expect-expect": [
"error",
{
"assertFunctionNames": [
"expect",
"clickPageElementAndExpectEventRequests",
"expectCustomEvent"
]
}
]
},
"overrides": [
{
"files": ["*.spec.js"],
"rules": {
"semi": ["warn", "never"]
} }
} }
]
}

View File

@ -48,6 +48,49 @@
// flag prevents sending multiple pageleaves in those cases. // flag prevents sending multiple pageleaves in those cases.
var pageLeaveSending = false var pageLeaveSending = false
function getDocumentHeight() {
return Math.max(
document.body.scrollHeight || 0,
document.body.offsetHeight || 0,
document.body.clientHeight || 0,
document.documentElement.scrollHeight || 0,
document.documentElement.offsetHeight || 0,
document.documentElement.clientHeight || 0
)
}
function getCurrentScrollDepthPx() {
var viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0
var scrollTop = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0
return currentDocumentHeight <= viewportHeight ? currentDocumentHeight : scrollTop + viewportHeight
}
var currentDocumentHeight = getDocumentHeight()
var maxScrollDepthPx = getCurrentScrollDepthPx()
window.addEventListener('load', function () {
currentDocumentHeight = getDocumentHeight()
// Update the document height again after every 200ms during the
// next 3 seconds. This makes sure dynamically loaded content is
// also accounted for.
var count = 0
var interval = setInterval(function () {
currentDocumentHeight = getDocumentHeight()
if (++count === 15) {clearInterval(interval)}
}, 200)
})
document.addEventListener('scroll', function() {
currentDocumentHeight = getDocumentHeight()
var currentScrollDepthPx = getCurrentScrollDepthPx()
if (currentScrollDepthPx > maxScrollDepthPx) {
maxScrollDepthPx = currentScrollDepthPx
}
})
function triggerPageLeave() { function triggerPageLeave() {
if (pageLeaveSending) {return} if (pageLeaveSending) {return}
pageLeaveSending = true pageLeaveSending = true
@ -55,6 +98,7 @@
var payload = { var payload = {
n: 'pageleave', n: 'pageleave',
sd: Math.round((maxScrollDepthPx / currentDocumentHeight) * 100),
d: dataDomain, d: dataDomain,
u: currentPageLeaveURL, u: currentPageLeaveURL,
} }
@ -202,6 +246,8 @@
if (isSPANavigation && listeningPageLeave) { if (isSPANavigation && listeningPageLeave) {
triggerPageLeave(); triggerPageLeave();
currentPageLeaveURL = location.href; currentPageLeaveURL = location.href;
currentDocumentHeight = getDocumentHeight()
maxScrollDepthPx = getCurrentScrollDepthPx()
} }
{{/if}} {{/if}}

View File

@ -1,7 +1,6 @@
const { mockRequest, mockManyRequests, expectCustomEvent } = require('./support/test-utils'); const { mockRequest, mockManyRequests, expectCustomEvent } = require('./support/test-utils')
const { expect, test } = require('@playwright/test'); const { expect, test } = require('@playwright/test')
const { LOCAL_SERVER_ADDR } = require('./support/server'); const { LOCAL_SERVER_ADDR } = require('./support/server')
test.describe('script.file-downloads.outbound-links.tagged-events.js', () => { test.describe('script.file-downloads.outbound-links.tagged-events.js', () => {
test('sends only outbound link event when clicked link is both download and outbound', async ({ page }) => { test('sends only outbound link event when clicked link is both download and outbound', async ({ page }) => {
@ -14,7 +13,7 @@ test.describe('script.file-downloads.outbound-links.tagged-events.js', () => {
const requests = await plausibleRequestMockList const requests = await plausibleRequestMockList
expect(requests.length).toBe(1) expect(requests.length).toBe(1)
expectCustomEvent(requests[0], 'Outbound Link: Click', {url: downloadURL}) expectCustomEvent(requests[0], 'Outbound Link: Click', {url: downloadURL})
}); })
test('sends file download event when local download link clicked', async ({ page }) => { test('sends file download event when local download link clicked', async ({ page }) => {
await page.goto('/custom-event-edge-case.html') await page.goto('/custom-event-edge-case.html')
@ -24,7 +23,7 @@ test.describe('script.file-downloads.outbound-links.tagged-events.js', () => {
await page.click('#local-download') await page.click('#local-download')
expectCustomEvent(await plausibleRequestMock, 'File Download', {url: downloadURL}) expectCustomEvent(await plausibleRequestMock, 'File Download', {url: downloadURL})
}); })
test('sends only tagged event when clicked link is tagged + outbound + download', async ({ page }) => { test('sends only tagged event when clicked link is tagged + outbound + download', async ({ page }) => {
await page.goto('/custom-event-edge-case.html') await page.goto('/custom-event-edge-case.html')
@ -35,5 +34,5 @@ test.describe('script.file-downloads.outbound-links.tagged-events.js', () => {
const requests = await plausibleRequestMockList const requests = await plausibleRequestMockList
expect(requests.length).toBe(1) expect(requests.length).toBe(1)
expectCustomEvent(requests[0], 'Foo', {}) expectCustomEvent(requests[0], 'Foo', {})
}); })
}); })

View File

@ -1,7 +1,6 @@
const { mockRequest, expectCustomEvent, mockManyRequests, metaKey } = require('./support/test-utils'); const { mockRequest, expectCustomEvent, mockManyRequests, metaKey } = require('./support/test-utils')
const { expect, test } = require('@playwright/test'); const { expect, test } = require('@playwright/test')
const { LOCAL_SERVER_ADDR } = require('./support/server'); const { LOCAL_SERVER_ADDR } = require('./support/server')
test.describe('file-downloads extension', () => { test.describe('file-downloads extension', () => {
test('sends event and does not start download when link opens in new tab', async ({ page }) => { test('sends event and does not start download when link opens in new tab', async ({ page }) => {
@ -14,7 +13,7 @@ test.describe('file-downloads extension', () => {
expectCustomEvent(await plausibleRequestMock, 'File Download', { url: downloadURL }) expectCustomEvent(await plausibleRequestMock, 'File Download', { url: downloadURL })
expect(await downloadRequestMock, "should not make download request").toBeNull() expect(await downloadRequestMock, "should not make download request").toBeNull()
}); })
test('sends event and starts download when link child is clicked', async ({ page }) => { test('sends event and starts download when link child is clicked', async ({ page }) => {
await page.goto('/file-download.html') await page.goto('/file-download.html')
@ -26,7 +25,7 @@ test.describe('file-downloads extension', () => {
expectCustomEvent(await plausibleRequestMock, 'File Download', { url: downloadURL }) expectCustomEvent(await plausibleRequestMock, 'File Download', { url: downloadURL })
expect((await downloadRequestMock).url()).toContain(downloadURL) expect((await downloadRequestMock).url()).toContain(downloadURL)
}); })
test('sends File Download event with query-stripped url property', async ({ page }) => { test('sends File Download event with query-stripped url property', async ({ page }) => {
await page.goto('/file-download.html') await page.goto('/file-download.html')
@ -37,7 +36,7 @@ test.describe('file-downloads extension', () => {
const expectedURL = downloadURL.split("?")[0] const expectedURL = downloadURL.split("?")[0]
expectCustomEvent(await plausibleRequestMock, 'File Download', { url: expectedURL }) expectCustomEvent(await plausibleRequestMock, 'File Download', { url: expectedURL })
}); })
test('starts download only once', async ({ page }) => { test('starts download only once', async ({ page }) => {
await page.goto('/file-download.html') await page.goto('/file-download.html')
@ -47,5 +46,5 @@ test.describe('file-downloads extension', () => {
await page.click('#local-download') await page.click('#local-download')
expect((await downloadRequestMockList).length).toBe(1) expect((await downloadRequestMockList).length).toBe(1)
}); })
}); })

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 B

View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Plausible Playwright tests</title>
<script defer src="/tracker/js/plausible.local.pageleave.js"></script>
</head>
<body>
<div id="main" style="height: 3000px; background-color: lightblue;">
<a id="navigate-away" href="/manual.html">Navigate away</a>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const mainDiv = document.getElementById("main")
let loadedMore = false
window.addEventListener("scroll", () => {
if (!loadedMore && window.innerHeight + window.scrollY >= document.body.offsetHeight) {
loadedMore = true
const newDiv = document.createElement("div")
newDiv.setAttribute("id", "more-content")
newDiv.setAttribute("style", "height: 2000px; background-color: lightcoral;")
document.body.appendChild(newDiv);
}
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script defer src="/tracker/js/plausible.local.pageleave.js"></script>
</head>
<body>
<br>
<div id="content"></div>
<script>
setTimeout(() => {
document.getElementById('content').innerHTML =
`
<div style="height: 5000px; background-color: gray;">
<a id="navigate-away" href="/manual.html">
Navigate away
</a>
</div>
`
}, 500)
</script>
</body>
</html>

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Plausible Playwright tests</title>
<script defer src="/tracker/js/plausible.hash.local.pageleave.js"></script>
</head>
<body>
<nav>
<a id="home-link" href="#home">Home</a>
<a id="about-link" href="#about">About</a>
</nav>
<div id="content"></div>
<script>
const routes = {
'#home': '<h1>Home</h1><p>Welcome to the Home page!</p>',
'#about': '<h1>About</h1><p style="height: 2000px;">Learn more about us here</p>',
};
function loadContent() {
const hash = window.location.hash || '#home';
const content = routes[hash]
document.getElementById('content').innerHTML = content;
}
window.addEventListener('hashchange', loadContent);
window.addEventListener('DOMContentLoaded', loadContent);
</script>
</body>
</html>

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script defer src="/tracker/js/plausible.local.pageleave.js"></script>
</head>
<body>
<a id="navigate-away" href="/manual.html">
Navigate away
</a>
<br>
<img id="slow-image" src="/img/slow-image" alt="slow image">
</body>
</html>

14
tracker/test/fixtures/scroll-depth.html vendored Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script defer src="/tracker/js/plausible.local.pageleave.js"></script>
<title>Document</title>
</head>
<body>
<div style="height: 5000px; background: repeating-linear-gradient(white, gray 500px);">
<a id="navigate-away" href="/manual.html">Navigate away</a>
</div>
</body>
</html>

View File

@ -1,12 +1,12 @@
const { mockRequest } = require('./support/test-utils') const { mockRequest } = require('./support/test-utils')
const { expect, test } = require('@playwright/test'); const { expect, test } = require('@playwright/test')
test.describe('combination of hash and exclusions script extensions', () => { test.describe('combination of hash and exclusions script extensions', () => {
test('excludes by hash part of the URL', async ({ page }) => { test('excludes by hash part of the URL', async ({ page }) => {
const plausibleRequestMock = mockRequest(page, '/api/event') const plausibleRequestMock = mockRequest(page, '/api/event')
await page.goto('/hash-exclusions.html#this/hash/should/be/ignored'); await page.goto('/hash-exclusions.html#this/hash/should/be/ignored')
expect(await plausibleRequestMock, "should not have sent event").toBeNull() expect(await plausibleRequestMock, "should not have sent event").toBeNull()
}); })
}); })

View File

@ -1,11 +1,10 @@
/* eslint-disable playwright/expect-expect */
const { clickPageElementAndExpectEventRequests } = require('./support/test-utils') const { clickPageElementAndExpectEventRequests } = require('./support/test-utils')
const { test } = require('@playwright/test'); const { test } = require('@playwright/test')
const { LOCAL_SERVER_ADDR } = require('./support/server'); const { LOCAL_SERVER_ADDR } = require('./support/server')
test.describe('manual extension', () => { test.describe('manual extension', () => {
test('can trigger custom events with and without a custom URL if pageview was sent with the default URL', async ({ page }) => { test('can trigger custom events with and without a custom URL if pageview was sent with the default URL', async ({ page }) => {
await page.goto('/manual.html'); await page.goto('/manual.html')
await clickPageElementAndExpectEventRequests(page, '#pageview-trigger', [ await clickPageElementAndExpectEventRequests(page, '#pageview-trigger', [
{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/manual.html`} {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/manual.html`}
@ -16,10 +15,10 @@ test.describe('manual extension', () => {
await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger-custom-url', [ await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger-custom-url', [
{n: 'CustomEvent', u: `https://example.com/custom/location`} {n: 'CustomEvent', u: `https://example.com/custom/location`}
]) ])
}); })
test('can trigger custom events with and without a custom URL if pageview was sent with a custom URL', async ({ page }) => { test('can trigger custom events with and without a custom URL if pageview was sent with a custom URL', async ({ page }) => {
await page.goto('/manual.html'); await page.goto('/manual.html')
await clickPageElementAndExpectEventRequests(page, '#pageview-trigger-custom-url', [ await clickPageElementAndExpectEventRequests(page, '#pageview-trigger-custom-url', [
{n: 'pageview', u: `https://example.com/custom/location`} {n: 'pageview', u: `https://example.com/custom/location`}
@ -30,5 +29,5 @@ test.describe('manual extension', () => {
await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger-custom-url', [ await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger-custom-url', [
{n: 'CustomEvent', u: `https://example.com/custom/location`} {n: 'CustomEvent', u: `https://example.com/custom/location`}
]) ])
}); })
}); })

View File

@ -1,5 +1,5 @@
const { mockRequest, expectCustomEvent, metaKey } = require('./support/test-utils') const { mockRequest, expectCustomEvent, metaKey } = require('./support/test-utils')
const { expect, test } = require('@playwright/test'); const { expect, test } = require('@playwright/test')
test.describe('outbound-links extension', () => { test.describe('outbound-links extension', () => {
@ -14,7 +14,7 @@ test.describe('outbound-links extension', () => {
expectCustomEvent(await plausibleRequestMock, 'Outbound Link: Click', { url: outboundURL }) expectCustomEvent(await plausibleRequestMock, 'Outbound Link: Click', { url: outboundURL })
expect(await navigationRequestMock, "should not have made navigation request").toBeNull() expect(await navigationRequestMock, "should not have made navigation request").toBeNull()
}); })
test('sends event and navigates to target when link child is clicked', async ({ page }) => { test('sends event and navigates to target when link child is clicked', async ({ page }) => {
await page.goto('/outbound-link.html') await page.goto('/outbound-link.html')
@ -28,7 +28,7 @@ test.describe('outbound-links extension', () => {
const navigationRequest = await navigationRequestMock const navigationRequest = await navigationRequestMock
expectCustomEvent(await plausibleRequestMock, 'Outbound Link: Click', { url: outboundURL }) expectCustomEvent(await plausibleRequestMock, 'Outbound Link: Click', { url: outboundURL })
expect(navigationRequest.url()).toContain(outboundURL) expect(navigationRequest.url()).toContain(outboundURL)
}); })
test('sends event and does not navigate if default externally prevented', async ({ page }) => { test('sends event and does not navigate if default externally prevented', async ({ page }) => {
await page.goto('/outbound-link.html') await page.goto('/outbound-link.html')
@ -41,5 +41,5 @@ test.describe('outbound-links extension', () => {
expectCustomEvent(await plausibleRequestMock, 'Outbound Link: Click', { url: outboundURL }) expectCustomEvent(await plausibleRequestMock, 'Outbound Link: Click', { url: outboundURL })
expect(await navigationRequestMock, "should not have made navigation request").toBeNull() expect(await navigationRequestMock, "should not have made navigation request").toBeNull()
}); })
}); })

View File

@ -1,46 +1,45 @@
/* eslint-disable playwright/expect-expect */
/* eslint-disable playwright/no-skipped-test */ /* eslint-disable playwright/no-skipped-test */
const { clickPageElementAndExpectEventRequests, mockRequest } = require('./support/test-utils') const { clickPageElementAndExpectEventRequests, mockRequest } = require('./support/test-utils')
const { test, expect } = require('@playwright/test'); const { test } = require('@playwright/test')
const { LOCAL_SERVER_ADDR } = require('./support/server'); const { LOCAL_SERVER_ADDR } = require('./support/server')
test.describe('pageleave extension', () => { test.describe('pageleave extension', () => {
test.skip(({browserName}) => browserName === 'webkit', 'Not testable on Webkit'); test.skip(({browserName}) => browserName === 'webkit', 'Not testable on Webkit')
test('sends a pageleave when navigating to the next page', async ({ page }) => { test('sends a pageleave when navigating to the next page', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event') const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/pageleave.html'); await page.goto('/pageleave.html')
await pageviewRequestMock; await pageviewRequestMock
await clickPageElementAndExpectEventRequests(page, '#navigate-away', [ await clickPageElementAndExpectEventRequests(page, '#navigate-away', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave.html`} {n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave.html`}
]) ])
}); })
test('sends pageleave and pageview on hash-based SPA navigation', async ({ page }) => { test('sends pageleave and pageview on hash-based SPA navigation', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event') const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/pageleave-hash.html'); await page.goto('/pageleave-hash.html')
await pageviewRequestMock; await pageviewRequestMock
await clickPageElementAndExpectEventRequests(page, '#hash-nav', [ await clickPageElementAndExpectEventRequests(page, '#hash-nav', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave-hash.html`}, {n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave-hash.html`},
{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/pageleave-hash.html#some-hash`} {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/pageleave-hash.html#some-hash`}
]) ])
}); })
test('sends pageleave and pageview on history-based SPA navigation', async ({ page }) => { test('sends pageleave and pageview on history-based SPA navigation', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event') const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/pageleave.html'); await page.goto('/pageleave.html')
await pageviewRequestMock; await pageviewRequestMock
await clickPageElementAndExpectEventRequests(page, '#history-nav', [ await clickPageElementAndExpectEventRequests(page, '#history-nav', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave.html`}, {n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave.html`},
{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/another-page`} {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/another-page`}
]) ])
}); })
test('sends pageleave with the manually overridden URL', async ({ page }) => { test('sends pageleave with the manually overridden URL', async ({ page }) => {
await page.goto('/pageleave-manual.html'); await page.goto('/pageleave-manual.html')
await clickPageElementAndExpectEventRequests(page, '#pageview-trigger-custom-url', [ await clickPageElementAndExpectEventRequests(page, '#pageview-trigger-custom-url', [
{n: 'pageview', u: 'https://example.com/custom/location'} {n: 'pageview', u: 'https://example.com/custom/location'}
@ -49,22 +48,22 @@ test.describe('pageleave extension', () => {
await clickPageElementAndExpectEventRequests(page, '#navigate-away', [ await clickPageElementAndExpectEventRequests(page, '#navigate-away', [
{n: 'pageleave', u: 'https://example.com/custom/location'} {n: 'pageleave', u: 'https://example.com/custom/location'}
]) ])
}); })
test('does not send pageleave when pageview was not sent in manual mode', async ({ page }) => { test('does not send pageleave when pageview was not sent in manual mode', async ({ page }) => {
await page.goto('/pageleave-manual.html'); await page.goto('/pageleave-manual.html')
await clickPageElementAndExpectEventRequests(page, '#navigate-away', [], [ await clickPageElementAndExpectEventRequests(page, '#navigate-away', [], [
{n: 'pageleave'} {n: 'pageleave'}
]) ])
}); })
test('script.exclusions.hash.pageleave.js sends pageleave only from URLs where a pageview was sent', async ({ page }) => { test('script.exclusions.hash.pageleave.js sends pageleave only from URLs where a pageview was sent', async ({ page }) => {
const pageBaseURL = `${LOCAL_SERVER_ADDR}/pageleave-hash-exclusions.html` const pageBaseURL = `${LOCAL_SERVER_ADDR}/pageleave-hash-exclusions.html`
const pageviewRequestMock = mockRequest(page, '/api/event') const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/pageleave-hash-exclusions.html'); await page.goto('/pageleave-hash-exclusions.html')
await pageviewRequestMock; await pageviewRequestMock
// After the initial pageview is sent, navigate to ignored page -> // After the initial pageview is sent, navigate to ignored page ->
// pageleave event is sent from the initial page URL // pageleave event is sent from the initial page URL
@ -91,5 +90,5 @@ test.describe('pageleave extension', () => {
{n: 'pageview', u: `${pageBaseURL}#hash2`, h: 1} {n: 'pageview', u: `${pageBaseURL}#hash2`, h: 1}
], ],
) )
}); })
}); })

View File

@ -1,13 +1,13 @@
const { mockRequest } = require('./support/test-utils') const { mockRequest } = require('./support/test-utils')
const { expect, test } = require('@playwright/test'); const { expect, test } = require('@playwright/test')
test.describe('Basic installation', () => { test.describe('Basic installation', () => {
test('Sends pageview automatically', async ({ page }) => { test('Sends pageview automatically', async ({ page }) => {
const plausibleRequestMock = mockRequest(page, '/api/event') const plausibleRequestMock = mockRequest(page, '/api/event')
await page.goto('/simple.html'); await page.goto('/simple.html')
const plausibleRequest = await plausibleRequestMock; const plausibleRequest = await plausibleRequestMock
expect(plausibleRequest.url()).toContain('/api/event') expect(plausibleRequest.url()).toContain('/api/event')
expect(plausibleRequest.postDataJSON().n).toEqual('pageview') expect(plausibleRequest.postDataJSON().n).toEqual('pageview')
}); })
}); })

View File

@ -1,22 +1,22 @@
const { mockRequest, expectCustomEvent } = require('./support/test-utils'); const { mockRequest, expectCustomEvent } = require('./support/test-utils')
const { expect, test } = require('@playwright/test'); const { expect, test } = require('@playwright/test')
test.describe('with revenue script extension', () => { test.describe('with revenue script extension', () => {
test('sends revenue currency and amount in manual mode', async ({ page }) => { test('sends revenue currency and amount in manual mode', async ({ page }) => {
const plausibleRequestMock = mockRequest(page, '/api/event') const plausibleRequestMock = mockRequest(page, '/api/event')
await page.goto('/revenue.html'); await page.goto('/revenue.html')
await page.click('#manual-purchase') await page.click('#manual-purchase')
const plausibleRequest = await plausibleRequestMock const plausibleRequest = await plausibleRequestMock
expect(plausibleRequest.postDataJSON()["$"]).toEqual({amount: 15.99, currency: "USD"}) expect(plausibleRequest.postDataJSON()["$"]).toEqual({amount: 15.99, currency: "USD"})
}); })
test('sends revenue currency and amount with tagged class name', async ({ page }) => { test('sends revenue currency and amount with tagged class name', async ({ page }) => {
const plausibleRequestMock = mockRequest(page, '/api/event') const plausibleRequestMock = mockRequest(page, '/api/event')
await page.goto('/revenue.html'); await page.goto('/revenue.html')
await page.click('#tagged-purchase') await page.click('#tagged-purchase')
const plausibleRequest = await plausibleRequestMock const plausibleRequest = await plausibleRequestMock
expect(plausibleRequest.postDataJSON()["$"]).toEqual({amount: "13.32", currency: "EUR"}) expect(plausibleRequest.postDataJSON()["$"]).toEqual({amount: "13.32", currency: "EUR"})
}); })
}); })

View File

@ -0,0 +1,89 @@
/* eslint-disable playwright/no-skipped-test */
const { clickPageElementAndExpectEventRequests, mockRequest } = require('./support/test-utils')
const { test } = require('@playwright/test')
const { LOCAL_SERVER_ADDR } = require('./support/server')
test.describe('scroll depth', () => {
test.skip(({browserName}) => browserName === 'webkit', 'Not testable on Webkit')
test('sends scroll_depth in the pageleave payload when navigating to the next page', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/scroll-depth.html')
await pageviewRequestMock
await page.evaluate(() => window.scrollBy(0, 300))
await page.evaluate(() => window.scrollBy(0, 0))
await clickPageElementAndExpectEventRequests(page, '#navigate-away', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20}
])
})
test('sends scroll depth on hash navigation', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/scroll-depth-hash.html')
await pageviewRequestMock
await clickPageElementAndExpectEventRequests(page, '#about-link', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html`, sd: 100},
{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#about`}
])
// Wait 600ms before navigating again because pageleave events are throttled to 500ms.
await page.waitForTimeout(600)
await clickPageElementAndExpectEventRequests(page, '#home-link', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#about`, sd: 34},
{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#home`}
])
})
test('document height gets reevaluated after window load', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/scroll-depth-slow-window-load.html')
await pageviewRequestMock
// Wait for the image to be loaded
await page.waitForFunction(() => {
return document.getElementById('slow-image').complete
})
await clickPageElementAndExpectEventRequests(page, '#navigate-away', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/scroll-depth-slow-window-load.html`, sd: 24}
])
})
test('dynamically loaded content affects documentHeight', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/scroll-depth-dynamic-content-load.html')
await pageviewRequestMock
// The link appears dynamically after 500ms.
await clickPageElementAndExpectEventRequests(page, '#navigate-away', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/scroll-depth-dynamic-content-load.html`, sd: 14}
])
})
test('document height gets reevaluated on scroll', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/scroll-depth-content-onscroll.html')
await pageviewRequestMock
// During the first 3 seconds, the script periodically updates document height
// to account for dynamically loaded content. Since we want to test document
// height also getting updated on scroll, we need to just wait for 3 seconds.
await page.waitForTimeout(3100)
// scroll to the bottom of the page
await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight))
// Wait until documentHeight gets increased by the fixture JS
await page.waitForSelector('#more-content')
await page.evaluate(() => window.scrollBy(0, 1000))
await clickPageElementAndExpectEventRequests(page, '#navigate-away', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/scroll-depth-content-onscroll.html`, sd: 80}
])
})
})

View File

@ -11,6 +11,13 @@ exports.runLocalFileServer = function () {
app.use(express.static(FIXTURES_PATH)); app.use(express.static(FIXTURES_PATH));
app.use('/tracker', express.static(TRACKERS_PATH)); app.use('/tracker', express.static(TRACKERS_PATH));
// A test utility - serve an image with an artificial delay
app.get('/img/slow-image', (_req, res) => {
setTimeout(() => {
res.sendFile(path.join(FIXTURES_PATH, '/img/black3x3000.png'));
}, 100);
});
app.listen(LOCAL_SERVER_PORT, function () { app.listen(LOCAL_SERVER_PORT, function () {
console.log(`Local server listening on ${LOCAL_SERVER_ADDR}`) console.log(`Local server listening on ${LOCAL_SERVER_ADDR}`)
}); });

View File

@ -54,33 +54,56 @@ exports.expectCustomEvent = function (request, eventName, eventProps) {
} }
} }
/**
* A powerful utility function that makes it easy to assert on the event
* requests that should or should not have been made after clicking a page
* element.
*
* This function accepts subsets of request bodies (the JSON payloads) as
* arguments, and compares them with the bodies of the requests that were
* actually made. For a body subset to match a request, all the key-value
* pairs present in the subset should also appear in the request body.
*/
exports.clickPageElementAndExpectEventRequests = async function (page, locatorToClick, expectedBodySubsets, refutedBodySubsets = []) { exports.clickPageElementAndExpectEventRequests = async function (page, locatorToClick, expectedBodySubsets, refutedBodySubsets = []) {
const requestsToExpect = expectedBodySubsets.length const requestsToExpect = expectedBodySubsets.length
const requestsToAwait = requestsToExpect + refutedBodySubsets.length const requestsToAwait = requestsToExpect + refutedBodySubsets.length
const plausibleRequestMockList = mockManyRequests(page, '/api/event', requestsToAwait) const plausibleRequestMockList = mockManyRequests(page, '/api/event', requestsToAwait)
await page.click(locatorToClick) await page.click(locatorToClick)
const requests = await plausibleRequestMockList const requestBodies = (await plausibleRequestMockList).map(r => r.postDataJSON())
expect(requests.length).toBe(requestsToExpect) const expectedButNotFoundBodySubsets = []
expectedBodySubsets.forEach((bodySubset) => { expectedBodySubsets.forEach((bodySubset) => {
expect(requests.some((request) => { const wasFound = requestBodies.some((requestBody) => {
return hasExpectedBodyParams(request, bodySubset) return includesSubset(requestBody, bodySubset)
})).toBe(true)
}) })
if (!wasFound) {expectedButNotFoundBodySubsets.push(bodySubset)}
})
const refutedButFoundRequestBodies = []
refutedBodySubsets.forEach((bodySubset) => { refutedBodySubsets.forEach((bodySubset) => {
expect(requests.every((request) => { const found = requestBodies.find((requestBody) => {
return !hasExpectedBodyParams(request, bodySubset) return includesSubset(requestBody, bodySubset)
})).toBe(true)
}) })
if (found) {refutedButFoundRequestBodies.push(found)}
})
const expectedBodySubsetsErrorMessage = `The following body subsets were not found from the requests that were made:\n\n${JSON.stringify(expectedButNotFoundBodySubsets, null, 4)}\n\nReceived requests with the following bodies:\n\n${JSON.stringify(requestBodies, null, 4)}`
expect(expectedButNotFoundBodySubsets, expectedBodySubsetsErrorMessage).toHaveLength(0)
const refutedBodySubsetsErrorMessage = `The following requests were made, but were not expected:\n\n${JSON.stringify(refutedButFoundRequestBodies, null, 4)}`
expect(refutedButFoundRequestBodies, refutedBodySubsetsErrorMessage).toHaveLength(0)
expect(requestBodies.length).toBe(requestsToExpect)
} }
function hasExpectedBodyParams(request, expectedBodyParams) { function includesSubset(body, subset) {
const body = request.postDataJSON() return Object.keys(subset).every((key) => {
return body[key] === subset[key]
return Object.keys(expectedBodyParams).every((key) => {
return body[key] === expectedBodyParams[key]
}) })
} }

View File

@ -10,7 +10,7 @@ test.describe('tagged-events extension', () => {
const plausibleRequestMock = mockRequest(page, '/api/event') const plausibleRequestMock = mockRequest(page, '/api/event')
await page.click('#link') await page.click('#link')
expectCustomEvent(await plausibleRequestMock, 'Payment Complete', { amount: '100', method: "Credit Card", url: linkURL }) expectCustomEvent(await plausibleRequestMock, 'Payment Complete', { amount: '100', method: "Credit Card", url: linkURL })
}); })
test('tracks a tagged form submit with custom props when submitting by pressing enter', async ({ page }) => { test('tracks a tagged form submit with custom props when submitting by pressing enter', async ({ page }) => {
await page.goto('/tagged-event.html') await page.goto('/tagged-event.html')
@ -21,7 +21,7 @@ test.describe('tagged-events extension', () => {
await inputLocator.press('Enter') await inputLocator.press('Enter')
expectCustomEvent(await plausibleRequestMock, 'Signup', { type: "Newsletter" }) expectCustomEvent(await plausibleRequestMock, 'Signup', { type: "Newsletter" })
}); })
test('tracks submit on a form with a tagged parent when submit button is clicked', async ({ page }) => { test('tracks submit on a form with a tagged parent when submit button is clicked', async ({ page }) => {
await page.goto('/tagged-event.html') await page.goto('/tagged-event.html')
@ -34,7 +34,7 @@ test.describe('tagged-events extension', () => {
expect(requests.length).toBe(1) expect(requests.length).toBe(1)
expectCustomEvent(requests[0], "Form Submit", {}) expectCustomEvent(requests[0], "Form Submit", {})
}); })
test('tracks click and auxclick on any tagged HTML element', async ({ page }) => { test('tracks click and auxclick on any tagged HTML element', async ({ page }) => {
await page.goto('/tagged-event.html') await page.goto('/tagged-event.html')
@ -48,7 +48,7 @@ test.describe('tagged-events extension', () => {
const requests = await plausibleRequestMockList const requests = await plausibleRequestMockList
expect(requests.length).toBe(3) expect(requests.length).toBe(3)
requests.forEach(request => expectCustomEvent(request, 'Custom Event', { foo: "bar" })) requests.forEach(request => expectCustomEvent(request, 'Custom Event', { foo: "bar" }))
}); })
test('does not track elements without plausible-event-name class + link elements navigate', async ({ page }) => { test('does not track elements without plausible-event-name class + link elements navigate', async ({ page }) => {
await page.goto('/tagged-event.html') await page.goto('/tagged-event.html')
@ -65,7 +65,7 @@ test.describe('tagged-events extension', () => {
expect(await plausibleRequestMock, "should not have made Plausible request").toBeNull() expect(await plausibleRequestMock, "should not have made Plausible request").toBeNull()
expect((await navigationRequestMock).url()).toContain(linkURL) expect((await navigationRequestMock).url()).toContain(linkURL)
}); })
test('tracks tagged HTML elements when their child element is clicked', async ({ page }) => { test('tracks tagged HTML elements when their child element is clicked', async ({ page }) => {
await page.goto('/tagged-event.html') await page.goto('/tagged-event.html')
@ -78,7 +78,7 @@ test.describe('tagged-events extension', () => {
const requests = await plausibleRequestMockList const requests = await plausibleRequestMockList
expect(requests.length).toBe(2) expect(requests.length).toBe(2)
requests.forEach(request => expectCustomEvent(request, 'Custom Event', { foo: "bar" })) requests.forEach(request => expectCustomEvent(request, 'Custom Event', { foo: "bar" }))
}); })
test('tracks tagged element that is dynamically added to the DOM', async ({ page }) => { test('tracks tagged element that is dynamically added to the DOM', async ({ page }) => {
await page.goto('/tagged-event.html') await page.goto('/tagged-event.html')
@ -92,7 +92,7 @@ test.describe('tagged-events extension', () => {
await buttonLocator.click() await buttonLocator.click()
expectCustomEvent(await plausibleRequestMock, 'Custom Event', {}) expectCustomEvent(await plausibleRequestMock, 'Custom Event', {})
}); })
test('does not track clicks inside a tagged form, except submit click', async ({ page }) => { test('does not track clicks inside a tagged form, except submit click', async ({ page }) => {
await page.goto('/tagged-event.html') await page.goto('/tagged-event.html')
@ -104,5 +104,5 @@ test.describe('tagged-events extension', () => {
await page.click('#form-div') await page.click('#form-div')
expect(await plausibleRequestMock, "should not have made Plausible request").toBeNull() expect(await plausibleRequestMock, "should not have made Plausible request").toBeNull()
}); })
}); })