From 37116a2b1295987a634a992bb62350c973c48a40 Mon Sep 17 00:00:00 2001 From: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com> Date: Mon, 28 Oct 2024 16:30:03 +0100 Subject: [PATCH] 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 --- .gitignore | 10 ++ tracker/compile.js | 8 +- tracker/dev-compile/can-skip-compile.js | 52 ++++++++++ tracker/package-lock.json | 50 +++++++--- tracker/package.json | 6 +- .../{test/support => }/playwright.config.js | 11 ++- .../fixtures/pageleave-hash-exclusions.html | 18 ++++ tracker/test/fixtures/pageleave-hash.html | 19 ++++ tracker/test/fixtures/pageleave-manual.html | 29 ++++++ tracker/test/fixtures/pageleave.html | 24 +++++ tracker/test/manual.spec.js | 43 ++++----- tracker/test/pageleave.spec.js | 95 +++++++++++++++++++ tracker/test/support/test-utils.js | 49 ++++++++-- 13 files changed, 363 insertions(+), 51 deletions(-) create mode 100644 tracker/dev-compile/can-skip-compile.js rename tracker/{test/support => }/playwright.config.js (82%) create mode 100644 tracker/test/fixtures/pageleave-hash-exclusions.html create mode 100644 tracker/test/fixtures/pageleave-hash.html create mode 100644 tracker/test/fixtures/pageleave-manual.html create mode 100644 tracker/test/fixtures/pageleave.html create mode 100644 tracker/test/pageleave.spec.js diff --git a/.gitignore b/.gitignore index 684a1a938..2e11afb90 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/tracker/compile.js b/tracker/compile.js index c4c5add65..b707b3a97 100644 --- a/tracker/compile.js +++ b/tracker/compile.js @@ -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) -}) +}) \ No newline at end of file diff --git a/tracker/dev-compile/can-skip-compile.js b/tracker/dev-compile/can-skip-compile.js new file mode 100644 index 000000000..9a955087d --- /dev/null +++ b/tracker/dev-compile/can-skip-compile.js @@ -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 + } +} \ No newline at end of file diff --git a/tracker/package-lock.json b/tracker/package-lock.json index 94e432413..975fe9bb7 100644 --- a/tracker/package-lock.json +++ b/tracker/package-lock.json @@ -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", diff --git a/tracker/package.json b/tracker/package.json index d1b222583..ef202664c 100644 --- a/tracker/package.json +++ b/tracker/package.json @@ -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" } diff --git a/tracker/test/support/playwright.config.js b/tracker/playwright.config.js similarity index 82% rename from tracker/test/support/playwright.config.js rename to tracker/playwright.config.js index 67c91de05..21dc56f49 100644 --- a/tracker/test/support/playwright.config.js +++ b/tracker/playwright.config.js @@ -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 }, -} +}); + diff --git a/tracker/test/fixtures/pageleave-hash-exclusions.html b/tracker/test/fixtures/pageleave-hash-exclusions.html new file mode 100644 index 000000000..aecca154e --- /dev/null +++ b/tracker/test/fixtures/pageleave-hash-exclusions.html @@ -0,0 +1,18 @@ + + + + + + + + Plausible Playwright tests + + + + + Ignored Hash Link + Hash Link 1 + Hash Link 2 + + + diff --git a/tracker/test/fixtures/pageleave-hash.html b/tracker/test/fixtures/pageleave-hash.html new file mode 100644 index 000000000..e164362cc --- /dev/null +++ b/tracker/test/fixtures/pageleave-hash.html @@ -0,0 +1,19 @@ + + + + + + + + Plausible Playwright tests + + + + + + Hash link + + + diff --git a/tracker/test/fixtures/pageleave-manual.html b/tracker/test/fixtures/pageleave-manual.html new file mode 100644 index 000000000..6fa3fd542 --- /dev/null +++ b/tracker/test/fixtures/pageleave-manual.html @@ -0,0 +1,29 @@ + + + + + + + + Plausible Playwright tests + + + + + + Navigate away + + + + + + + diff --git a/tracker/test/fixtures/pageleave.html b/tracker/test/fixtures/pageleave.html new file mode 100644 index 000000000..bb94b92ee --- /dev/null +++ b/tracker/test/fixtures/pageleave.html @@ -0,0 +1,24 @@ + + + + + + + + Plausible Playwright tests + + + + + Navigate away + + + + + + + diff --git a/tracker/test/manual.spec.js b/tracker/test/manual.spec.js index 0815d7276..f67a7fccb 100644 --- a/tracker/test/manual.spec.js +++ b/tracker/test/manual.spec.js @@ -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`} + ]) }); }); diff --git a/tracker/test/pageleave.spec.js b/tracker/test/pageleave.spec.js new file mode 100644 index 000000000..cfc2acad4 --- /dev/null +++ b/tracker/test/pageleave.spec.js @@ -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} + ], + ) + }); +}); \ No newline at end of file diff --git a/tracker/test/support/test-utils.js b/tracker/test/support/test-utils.js index 6c01e4f3b..10758ecb1 100644 --- a/tracker/test/support/test-utils.js +++ b/tracker/test/support/test-utils.js @@ -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] + }) +}