Add tests for the pageleave script (#4744)

* move util function to util module

* move playwright config file to tracker dir root

* update Playwright + add gitignores

* Playwright: enable reuseExistingServer (non-CI env)

* store tracker src copies to avoid re-compilation in dev env

* test pageleave on simple navigation

* fix test util fn

* rename local_test npm script

* make test util able to expect multiple requests

* test pageleaves in SPAs

* test pageleave with manual URL

* test pageleave not sent in manual when pageview not triggered

* extend util fn to refute event requests

* test pageleaves not sent in excluded hash pages

* store hashes instead of file copies to detect /tracker/src changes

* drop async

* speed up test suite
This commit is contained in:
RobertJoonas 2024-10-28 16:30:03 +01:00 committed by GitHub
parent 1a6fd1dc15
commit 37116a2b12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 363 additions and 51 deletions

10
.gitignore vendored
View File

@ -35,6 +35,16 @@ npm-debug.log
/assets/node_modules/
/tracker/node_modules/
# Files generated by Playwright when running tracker tests
/tracker/test-results/
/tracker/playwright-report/
/tracker/blob-report/
/tracker/playwright/.cache/
# Stored hash of source tracker files used in development environment
# to detect changes in /tracker/src and avoid unnecessary compilation.
/tracker/dev-compile/last-hash.txt
# test coverage directory
/assets/coverage

View File

@ -3,6 +3,12 @@ const fs = require('fs')
const path = require('path')
const Handlebars = require("handlebars");
const g = require("generatorics");
const { canSkipCompile } = require("./dev-compile/can-skip-compile");
if (process.env.NODE_ENV === 'dev' && canSkipCompile()) {
console.info('COMPILATION SKIPPED: No changes detected in tracker dependencies')
process.exit(0)
}
Handlebars.registerHelper('any', function (...args) {
return args.slice(0, -1).some(Boolean)
@ -35,4 +41,4 @@ compilefile(relPath('src/p.js'), relPath('../priv/tracker/js/p.js'))
variants.map(variant => {
const options = variant.map(variant => variant.replace('-', '_')).reduce((acc, curr) => (acc[curr] = true, acc), {})
compilefile(relPath('src/plausible.js'), relPath(`../priv/tracker/js/plausible.${variant.join('.')}.js`), options)
})
})

View File

@ -0,0 +1,52 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const LAST_HASH_FILEPATH = path.join(__dirname, './last-hash.txt')
// Re-compilation is only required if any of these files have been changed.
const COMPILE_DEPENDENCIES = [
path.join(__dirname, '../compile.js'),
path.join(__dirname, '../src/plausible.js'),
path.join(__dirname, '../src/customEvents.js')
]
function currentHash() {
const combinedHash = crypto.createHash('sha256');
for (const filePath of COMPILE_DEPENDENCIES) {
try {
const fileContent = fs.readFileSync(filePath);
const fileHash = crypto.createHash('sha256').update(fileContent).digest();
combinedHash.update(fileHash);
} catch (error) {
throw new Error(`Failed to read or hash ${filePath}: ${error.message}`);
}
}
return combinedHash.digest('hex');
}
function lastHash() {
if (fs.existsSync(LAST_HASH_FILEPATH)) {
return fs.readFileSync(LAST_HASH_FILEPATH).toString()
}
}
/**
* Returns a boolean indicating whether the tracker compilation can be skipped.
* Every time this function gets executed, the hash of the tracker dependencies
* will be updated. Compilation can be skipped if the hash hasn't changed since
* the last execution.
*/
exports.canSkipCompile = function() {
const current = currentHash()
const last = lastHash()
if (current === last) {
return true
} else {
fs.writeFileSync(LAST_HASH_FILEPATH, current)
return false
}
}

View File

@ -6,13 +6,14 @@
"": {
"license": "MIT",
"dependencies": {
"@playwright/test": "^1.41.1",
"express": "^4.18.1",
"generatorics": "^1.1.0",
"handlebars": "^4.7.8",
"uglify-js": "^3.9.4"
},
"devDependencies": {
"@playwright/test": "^1.48.1",
"@types/node": "^22.7.7",
"eslint": "^8.56.0",
"eslint-plugin-playwright": "^0.20.0"
}
@ -151,17 +152,27 @@
}
},
"node_modules/@playwright/test": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.1.tgz",
"integrity": "sha512-9g8EWTjiQ9yFBXc6HjCWe41msLpxEX0KhmfmPl9RPLJdfzL4F0lg2BdJ91O9azFdl11y1pmpwdjBiSxvqc+btw==",
"version": "1.48.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz",
"integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==",
"dev": true,
"dependencies": {
"playwright": "1.41.1"
"playwright": "1.48.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.7.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.7.tgz",
"integrity": "sha512-SRxCrrg9CL/y54aiMCG3edPKdprgMVGDXjA3gB8UmmBW5TcXzRUYAh8EWzTnSJFAd1rgImPELza+A3bJ+qxz8Q==",
"dev": true,
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/@ungap/structured-clone": {
@ -855,6 +866,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@ -1395,31 +1407,33 @@
"integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
},
"node_modules/playwright": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.1.tgz",
"integrity": "sha512-gdZAWG97oUnbBdRL3GuBvX3nDDmUOuqzV/D24dytqlKt+eI5KbwusluZRGljx1YoJKZ2NRPaeWiFTeGZO7SosQ==",
"version": "1.48.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz",
"integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==",
"dev": true,
"dependencies": {
"playwright-core": "1.41.1"
"playwright-core": "1.48.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.1.tgz",
"integrity": "sha512-/KPO5DzXSMlxSX77wy+HihKGOunh3hqndhqeo/nMxfigiKzogn8kfL0ZBDu0L1RKgan5XHCPmn6zXd2NUJgjhg==",
"version": "1.48.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz",
"integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
"node": ">=18"
}
},
"node_modules/prelude-ls": {
@ -1796,6 +1810,12 @@
"node": ">=0.8.0"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@ -1,19 +1,21 @@
{
"scripts": {
"deploy": "node compile.js",
"test": "npm run deploy && npx playwright test --config=./test/support/playwright.config.js",
"test": "npm run deploy && npx playwright test",
"test:local": "NODE_ENV=dev npm run deploy && npx playwright test",
"report-sizes": "node report-sizes.js",
"start": "node test/support/server.js"
},
"license": "MIT",
"dependencies": {
"@playwright/test": "^1.41.1",
"express": "^4.18.1",
"generatorics": "^1.1.0",
"handlebars": "^4.7.8",
"uglify-js": "^3.9.4"
},
"devDependencies": {
"@playwright/test": "^1.48.1",
"@types/node": "^22.7.7",
"eslint": "^8.56.0",
"eslint-plugin-playwright": "^0.20.0"
}

View File

@ -1,10 +1,11 @@
const { devices } = require('@playwright/test');
// @ts-check
const { defineConfig, devices } = require('@playwright/test');
/**
* @see https://playwright.dev/docs/test-configuration
*/
module.exports = {
testDir: '../',
module.exports = defineConfig({
testDir: './test',
timeout: 60 * 1000,
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@ -33,5 +34,7 @@ module.exports = {
webServer: {
command: 'npm run start',
port: 3000,
reuseExistingServer: !process.env.CI
},
}
});

View File

@ -0,0 +1,18 @@
<!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 data-exclude='/*#*/hash/**/ignored' src="/tracker/js/plausible.exclusions.hash.local.pageleave.js"></script>
</head>
<body>
<a id="ignored-hash-link" href="#this/hash/should/be/ignored">Ignored Hash Link</a>
<a id="hash-link-1" href="#hash1">Hash Link 1</a>
<a id="hash-link-2" href="#hash2">Hash Link 2</a>
</body>
</html>

View File

@ -0,0 +1,19 @@
<!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>
<script>
</script>
</head>
<body>
<a id="hash-nav" href="#some-hash">Hash link</a>
</body>
</html>

View File

@ -0,0 +1,29 @@
<!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.manual.pageleave.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
</head>
<body>
<a id="navigate-away" href="/manual.html">Navigate away</a>
<button id="pageview-trigger-custom-url">
Triggers a pageview with custom URL
</button>
<script>
document.addEventListener('click', (e) => {
if (e.target.id === 'pageview-trigger-custom-url') {
window.plausible('pageview', {u: 'https://example.com/custom/location'})
}
})
</script>
</body>
</html>

24
tracker/test/fixtures/pageleave.html vendored Normal file
View File

@ -0,0 +1,24 @@
<!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>
<a id="navigate-away" href="/manual.html">Navigate away</a>
<button id="history-nav">Navigate with history</button>
<script>
document.getElementById('history-nav').addEventListener('click', () => {
history.pushState({ page: 2 }, 'Another Page', '/another-page');
});
</script>
</body>
</html>

View File

@ -1,35 +1,34 @@
const { mockRequest } = require('./support/test-utils')
const { expect, test } = require('@playwright/test');
/* eslint-disable playwright/expect-expect */
const { clickPageElementAndExpectEventRequests } = require('./support/test-utils')
const { test } = require('@playwright/test');
const { LOCAL_SERVER_ADDR } = require('./support/server');
async function clickPageElementAndExpectEventRequest(page, buttonId, expectedBodyParams) {
const plausibleRequestMock = mockRequest(page, '/api/event')
await page.click(buttonId)
const plausibleRequest = await plausibleRequestMock;
expect(plausibleRequest.url()).toContain('/api/event')
const body = plausibleRequest.postDataJSON()
Object.keys(expectedBodyParams).forEach((key) => {
expect(body[key]).toEqual(expectedBodyParams[key])
})
}
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 }) => {
await page.goto('/manual.html');
await clickPageElementAndExpectEventRequest(page, '#pageview-trigger', {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/manual.html`})
await clickPageElementAndExpectEventRequest(page, '#custom-event-trigger', {n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html`})
await clickPageElementAndExpectEventRequest(page, '#custom-event-trigger-custom-url', {n: 'CustomEvent', u: `https://example.com/custom/location`})
await clickPageElementAndExpectEventRequests(page, '#pageview-trigger', [
{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/manual.html`}
])
await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger', [
{n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html`}
])
await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger-custom-url', [
{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 }) => {
await page.goto('/manual.html');
await clickPageElementAndExpectEventRequest(page, '#pageview-trigger-custom-url', {n: 'pageview', u: `https://example.com/custom/location`})
await clickPageElementAndExpectEventRequest(page, '#custom-event-trigger', {n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html`})
await clickPageElementAndExpectEventRequest(page, '#custom-event-trigger-custom-url', {n: 'CustomEvent', u: `https://example.com/custom/location`})
await clickPageElementAndExpectEventRequests(page, '#pageview-trigger-custom-url', [
{n: 'pageview', u: `https://example.com/custom/location`}
])
await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger', [
{n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html`}
])
await clickPageElementAndExpectEventRequests(page, '#custom-event-trigger-custom-url', [
{n: 'CustomEvent', u: `https://example.com/custom/location`}
])
});
});

View File

@ -0,0 +1,95 @@
/* eslint-disable playwright/expect-expect */
/* eslint-disable playwright/no-skipped-test */
const { clickPageElementAndExpectEventRequests, mockRequest } = require('./support/test-utils')
const { test, expect } = require('@playwright/test');
const { LOCAL_SERVER_ADDR } = require('./support/server');
test.describe('pageleave extension', () => {
test.skip(({browserName}) => browserName === 'webkit', 'Not testable on Webkit');
test('sends a pageleave when navigating to the next page', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/pageleave.html');
await pageviewRequestMock;
await clickPageElementAndExpectEventRequests(page, '#navigate-away', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave.html`}
])
});
test('sends pageleave and pageview on hash-based SPA navigation', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/pageleave-hash.html');
await pageviewRequestMock;
await clickPageElementAndExpectEventRequests(page, '#hash-nav', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave-hash.html`},
{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/pageleave-hash.html#some-hash`}
])
});
test('sends pageleave and pageview on history-based SPA navigation', async ({ page }) => {
const pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/pageleave.html');
await pageviewRequestMock;
await clickPageElementAndExpectEventRequests(page, '#history-nav', [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave.html`},
{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/another-page`}
])
});
test('sends pageleave with the manually overridden URL', async ({ page }) => {
await page.goto('/pageleave-manual.html');
await clickPageElementAndExpectEventRequests(page, '#pageview-trigger-custom-url', [
{n: 'pageview', u: 'https://example.com/custom/location'}
])
await clickPageElementAndExpectEventRequests(page, '#navigate-away', [
{n: 'pageleave', u: 'https://example.com/custom/location'}
])
});
test('does not send pageleave when pageview was not sent in manual mode', async ({ page }) => {
await page.goto('/pageleave-manual.html');
await clickPageElementAndExpectEventRequests(page, '#navigate-away', [], [
{n: 'pageleave'}
])
});
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 pageviewRequestMock = mockRequest(page, '/api/event')
await page.goto('/pageleave-hash-exclusions.html');
await pageviewRequestMock;
// After the initial pageview is sent, navigate to ignored page ->
// pageleave event is sent from the initial page URL
await clickPageElementAndExpectEventRequests(page, '#ignored-hash-link', [
{n: 'pageleave', u: pageBaseURL, h: 1}
])
// Navigate from ignored page to a tracked page ->
// no pageleave from the current page, pageview on the next page
await clickPageElementAndExpectEventRequests(
page,
'#hash-link-1',
[{n: 'pageview', u: `${pageBaseURL}#hash1`, h: 1}],
[{n: 'pageleave'}]
)
// Navigate from a tracked page to another tracked page ->
// pageleave with the last page URL, pageview with the new URL
await clickPageElementAndExpectEventRequests(
page,
'#hash-link-2',
[
{n: 'pageleave', u: `${pageBaseURL}#hash1`, h: 1},
{n: 'pageview', u: `${pageBaseURL}#hash2`, h: 1}
],
)
});
});

View File

@ -1,10 +1,10 @@
const { expect } = require("@playwright/test");
// Mocks an HTTP request call with the given path. Returns a Promise that resolves to the request
// data. If the request is not made, resolves to null after 10 seconds.
exports.mockRequest = function (page, path) {
// data. If the request is not made, resolves to null after 3 seconds.
const mockRequest = function (page, path) {
return new Promise((resolve, _reject) => {
const requestTimeoutTimer = setTimeout(() => resolve(null), 10000)
const requestTimeoutTimer = setTimeout(() => resolve(null), 3000)
page.route(path, (route, request) => {
clearTimeout(requestTimeoutTimer)
@ -14,6 +14,8 @@ exports.mockRequest = function (page, path) {
})
}
exports.mockRequest = mockRequest
exports.metaKey = function() {
if (process.platform === 'darwin') {
return 'Meta'
@ -23,13 +25,13 @@ exports.metaKey = function() {
}
// Mocks a specified number of HTTP requests with given path. Returns a promise that resolves to a
// list of requests as soon as the specified number of requests is made, or 10 seconds has passed.
exports.mockManyRequests = function(page, path, numberOfRequests) {
// list of requests as soon as the specified number of requests is made, or 3 seconds has passed.
const mockManyRequests = function(page, path, numberOfRequests) {
return new Promise((resolve, _reject) => {
let requestList = []
const requestTimeoutTimer = setTimeout(() => resolve(requestList), 10000)
const requestTimeoutTimer = setTimeout(() => resolve(requestList), 3000)
page.route('/api/event', (route, request) => {
page.route(path, (route, request) => {
requestList.push(request)
if (requestList.length === numberOfRequests) {
clearTimeout(requestTimeoutTimer)
@ -40,6 +42,8 @@ exports.mockManyRequests = function(page, path, numberOfRequests) {
})
}
exports.mockManyRequests = mockManyRequests
exports.expectCustomEvent = function (request, eventName, eventProps) {
const payload = request.postDataJSON()
@ -49,3 +53,34 @@ exports.expectCustomEvent = function (request, eventName, eventProps) {
expect(payload.p[key]).toEqual(value)
}
}
exports.clickPageElementAndExpectEventRequests = async function (page, locatorToClick, expectedBodySubsets, refutedBodySubsets = []) {
const requestsToExpect = expectedBodySubsets.length
const requestsToAwait = requestsToExpect + refutedBodySubsets.length
const plausibleRequestMockList = mockManyRequests(page, '/api/event', requestsToAwait)
await page.click(locatorToClick)
const requests = await plausibleRequestMockList
expect(requests.length).toBe(requestsToExpect)
expectedBodySubsets.forEach((bodySubset) => {
expect(requests.some((request) => {
return hasExpectedBodyParams(request, bodySubset)
})).toBe(true)
})
refutedBodySubsets.forEach((bodySubset) => {
expect(requests.every((request) => {
return !hasExpectedBodyParams(request, bodySubset)
})).toBe(true)
})
}
function hasExpectedBodyParams(request, expectedBodyParams) {
const body = request.postDataJSON()
return Object.keys(expectedBodyParams).every((key) => {
return body[key] === expectedBodyParams[key]
})
}