218 lines
7.9 KiB
JavaScript
218 lines
7.9 KiB
JavaScript
import { expect } from "@playwright/test"
|
|
import packageJson from '../../package.json' with { type: 'json' }
|
|
import { mockManyRequests } from "./mock-many-requests"
|
|
|
|
export const tracker_script_version = packageJson.tracker_script_version
|
|
|
|
/**
|
|
* A powerful utility function that makes it easy to assert on the event
|
|
* requests that should or should not have been made after doing a page
|
|
* action (e.g. navigating to the page, clicking a page element, etc).
|
|
*
|
|
* @param {Page} page - The Playwright Page object.
|
|
* @param {Object} args - The object configuring the action and related expectations.
|
|
* @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
|
|
* that was made. The default value is `[]`
|
|
* @param {Array} [args.refutedRequests] - Same as `expectedRequests` but the opposite. The
|
|
* expectation passes if none of the made requests match with these partial payloads. Note
|
|
* that the condition on which a partial payload matches an event request payload is exactly
|
|
* the same as it is for `expectedRequests`. The default value is `[]`
|
|
* @param {number} [args.awaitedRequestCount] - Sometimes we might want to wait for more events
|
|
* to happen, just to make sure they didn't. By default, the number of requests we wait for
|
|
* is `expectedRequests.length + refutedRequests.length`.
|
|
* @param {number} [args.expectedRequestCount] - When provided, expects the total amount of
|
|
* event requests made to match this number.
|
|
* @param {Array|Function} [args.shouldIgnoreRequest] - When provided, ignores certain requests
|
|
* @param {number} [args.responseDelay] - When provided, delays the response from the Plausible
|
|
* API by the given number of milliseconds.
|
|
* @param {number} [args.mockRequestTimeout] - How long to wait for the requests to be made
|
|
*/
|
|
export const expectPlausibleInAction = async function (page, {
|
|
action,
|
|
expectedRequests = [],
|
|
refutedRequests = [],
|
|
pathToMock = '/api/event',
|
|
awaitedRequestCount,
|
|
expectedRequestCount,
|
|
responseDelay,
|
|
shouldIgnoreRequest,
|
|
mockRequestTimeout = 3000
|
|
}) {
|
|
const requestsToExpect = expectedRequestCount ? expectedRequestCount : expectedRequests.length
|
|
const requestsToAwait = awaitedRequestCount ? awaitedRequestCount : requestsToExpect + refutedRequests.length
|
|
|
|
const { getRequestList } = await mockManyRequests({
|
|
page,
|
|
path: pathToMock,
|
|
fulfill: { status: 202, contentType: 'text/plain', body: 'ok' },
|
|
responseDelay,
|
|
shouldIgnoreRequest,
|
|
awaitedRequestCount: requestsToAwait,
|
|
mockRequestTimeout
|
|
})
|
|
await action()
|
|
const requestBodies = await getRequestList()
|
|
|
|
const expectedButNotFoundBodySubsets = []
|
|
|
|
expectedRequests.forEach((bodySubset) => {
|
|
const wasFound = requestBodies.some((requestBody) => {
|
|
return includesSubset(requestBody, bodySubset)
|
|
})
|
|
|
|
if (!wasFound) {expectedButNotFoundBodySubsets.push(bodySubset)}
|
|
})
|
|
|
|
const refutedButFoundRequestBodies = []
|
|
|
|
refutedRequests.forEach((bodySubset) => {
|
|
const found = requestBodies.find((requestBody) => {
|
|
return includesSubset(requestBody, bodySubset)
|
|
})
|
|
|
|
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)
|
|
|
|
const unexpectedRequestBodiesErrorMessage = `Expected ${requestsToExpect} requests, but received ${requestBodies.length}:\n\n${JSON.stringify(requestBodies, null, 4)}`
|
|
expect(requestBodies.length, unexpectedRequestBodiesErrorMessage).toBe(requestsToExpect)
|
|
|
|
return requestBodies
|
|
}
|
|
|
|
export const isPageviewEvent = function(requestPostData) {
|
|
return requestPostData.n === 'pageview'
|
|
}
|
|
|
|
export const isEngagementEvent = function(requestPostData) {
|
|
return requestPostData.n === 'engagement'
|
|
}
|
|
|
|
async function toggleTabVisibility(page, hide) {
|
|
await page.evaluate((hide) => {
|
|
Object.defineProperty(document, 'visibilityState', { value: hide ? 'hidden' : 'visible', writable: true })
|
|
Object.defineProperty(document, 'hidden', { value: hide, writable: true })
|
|
document.dispatchEvent(new Event('visibilitychange'))
|
|
}, hide)
|
|
}
|
|
|
|
export const hideCurrentTab = async function(page) {
|
|
return toggleTabVisibility(page, true)
|
|
}
|
|
|
|
export const showCurrentTab = async function(page) {
|
|
return toggleTabVisibility(page, false)
|
|
}
|
|
|
|
async function setFocus(page, focus) {
|
|
await page.evaluate((focus) => {
|
|
Object.defineProperty(document, 'hasFocus', { value: () => focus, writable: true })
|
|
|
|
const eventName = focus ? 'focus' : 'blur'
|
|
window.dispatchEvent(new Event(eventName))
|
|
}, focus)
|
|
}
|
|
|
|
export const focus = async function(page) {
|
|
return setFocus(page, true)
|
|
}
|
|
|
|
export const blur = async function(page) {
|
|
return setFocus(page, false)
|
|
}
|
|
|
|
export const hideAndShowCurrentTab = async function(page, options = {}) {
|
|
await hideCurrentTab(page)
|
|
if (options.delay > 0) {
|
|
await delay(options.delay)
|
|
}
|
|
await showCurrentTab(page)
|
|
}
|
|
|
|
export const blurAndFocusPage = async function(page, options = {}) {
|
|
await blur(page)
|
|
if (options.delay > 0) {
|
|
await delay(options.delay)
|
|
}
|
|
await focus(page)
|
|
}
|
|
|
|
// Custom assertion methods for checking plausible request bodies
|
|
export const e = {
|
|
stringContaining: (value) => ({
|
|
expected: value,
|
|
__expectation__: (actual) => actual.includes(value)
|
|
}),
|
|
toBeUndefined: () => ({
|
|
expected: undefined,
|
|
__expectation__: (actual) => actual === undefined
|
|
})
|
|
}
|
|
|
|
function includesSubset(body, subset) {
|
|
return Object.keys(subset).every((key) => {
|
|
if (typeof subset[key] === 'object' && !subset[key].__expectation__) {
|
|
return typeof body[key] === 'object' && areFlatObjectsEqual(body[key], subset[key])
|
|
} else {
|
|
return checkEqual(body[key], subset[key])
|
|
}
|
|
})
|
|
}
|
|
|
|
// For comparing custom props - all key-value pairs
|
|
// must match but the order is not important.
|
|
function areFlatObjectsEqual(obj1, obj2) {
|
|
const keys1 = Object.keys(obj1)
|
|
const keys2 = Object.keys(obj2)
|
|
|
|
if (keys1.length !== keys2.length) return false;
|
|
|
|
return keys1.every(key => checkEqual(obj2[key], obj1[key]))
|
|
}
|
|
|
|
function checkEqual(a, b) {
|
|
if (typeof b === 'object' && b.__expectation__) {
|
|
return b.__expectation__(a)
|
|
}
|
|
return a === b
|
|
}
|
|
|
|
export function delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
}
|
|
|
|
export function switchByMode(cases, mode) {
|
|
switch (mode) {
|
|
case 'web':
|
|
return cases.web
|
|
case 'esm':
|
|
return cases.esm
|
|
case 'legacy':
|
|
return cases.legacy
|
|
default:
|
|
throw new Error(`Unimplemented mode: ${mode}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This function ensures that the tracker script has attached the event listener before test is run.
|
|
* Note that this race condition happens in the real world as well:
|
|
* Events from features like form submissions, file downloads, outbound links, tagged events
|
|
* that work with event handlers registered on the document
|
|
* will not be tracked if the event happens before the tracker script has attached the event listener.
|
|
*/
|
|
export function ensurePlausibleInitialized(page) {
|
|
return page.waitForFunction(() =>(window.plausible?.l === true))
|
|
}
|