From 52bb5ccc502747b8736d51ab0ea72f0db64140c7 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sat, 18 Nov 2023 08:09:34 +0000 Subject: [PATCH 1/8] latest cypress action version (#900) --- .eslintrc.js | 1 + .github/workflows/library-ci.yml | 2 +- cypress/e2e/capture.cy.js | 77 ++++++++++--------- cypress/e2e/session-recording.cy.js | 42 ++++------ cypress/support/commands.js | 5 -- cypress/support/compression.js | 4 +- cypress/support/e2e.js | 8 +- package.json | 6 +- yarn.lock | 115 +++++++++++++++++++++------- 9 files changed, 156 insertions(+), 104 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 02b03cf7f..2c11d94f9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -88,6 +88,7 @@ module.exports = { files: 'cypress/**/*', globals: { cy: true, + Cypress: true, }, }, ], diff --git a/.github/workflows/library-ci.yml b/.github/workflows/library-ci.yml index a8e01ba8a..e3589fa4c 100644 --- a/.github/workflows/library-ci.yml +++ b/.github/workflows/library-ci.yml @@ -39,7 +39,7 @@ jobs: - run: yarn install && yarn build - name: Cypress run - uses: cypress-io/github-action@v4 + uses: cypress-io/github-action@v6 functional: name: Functional tests diff --git a/cypress/e2e/capture.cy.js b/cypress/e2e/capture.cy.js index 1c08b30d1..f5411cfc0 100644 --- a/cypress/e2e/capture.cy.js +++ b/cypress/e2e/capture.cy.js @@ -12,21 +12,17 @@ describe('Event capture', () => { // :TRICKY: Use a custom start command over beforeEach to deal with given2 not being ready yet. const start = ({ waitForDecide = true } = {}) => { - cy.route({ - method: 'POST', - url: '**/decide/*', - response: { - config: { - enable_collect_everything: true, - }, - editorParams: {}, - featureFlags: ['session-recording-player'], - isAuthenticated: false, - sessionRecording: given.sessionRecording, - supportedCompression: given.supportedCompression, - excludedDomains: [], - autocaptureExceptions: false, + cy.intercept('POST', '**/decide/*', { + config: { + enable_collect_everything: true, }, + editorParams: {}, + featureFlags: ['session-recording-player'], + isAuthenticated: false, + sessionRecording: given.sessionRecording, + supportedCompression: given.supportedCompression, + excludedDomains: [], + autocaptureExceptions: false, }).as('decide') cy.visit('./playground/cypress-full') @@ -220,8 +216,9 @@ describe('Event capture', () => { it('makes a single decide request', () => { start() - cy.wait(200) - cy.shouldBeCalled('decide', 1) + cy.get('@decide.all').then((calls) => { + expect(calls.length).to.equal(1) + }) cy.phCaptures().should('include', '$pageview') cy.get('@decide').should(({ request }) => { @@ -322,31 +319,32 @@ describe('Event capture', () => { it('contains the correct payload after an event', () => { start() // Pageview will be sent immediately - cy.wait('@capture').should(({ request, url }) => { - expect(request.headers).to.eql({ - 'Content-Type': 'application/x-www-form-urlencoded', - }) + cy.wait('@capture').should(({ request }) => { + expect(request.headers['content-type']).to.eql('application/x-www-form-urlencoded') - expect(url).to.match(urlWithVersion) + expect(request.url).to.match(urlWithVersion) const data = decodeURIComponent(request.body.match(/data=(.*)/)[1]) const captures = JSON.parse(Buffer.from(data, 'base64')) expect(captures['event']).to.equal('$pageview') }) + // the code below is going to trigger an event capture + // we want to assert on the request + cy.intercept('POST', '**/e/*', async (request) => { + expect(request.headers['content-type']).to.eq('text/plain') + + const captures = await getGzipEncodedPayload(request) + expect(captures.map(({ event }) => event)).to.deep.equal(['$autocapture', 'custom-event']) + }).as('capture-assertion') + cy.get('[data-cy-custom-event-button]').click() cy.phCaptures().should('have.length', 3) cy.phCaptures().should('include', '$pageview') cy.phCaptures().should('include', '$autocapture') cy.phCaptures().should('include', 'custom-event') - cy.wait('@capture').its('requestBody.type').should('deep.equal', 'text/plain') - - cy.get('@capture').should(async ({ requestBody }) => { - const captures = await getGzipEncodedPayload(requestBody) - - expect(captures.map(({ event }) => event)).to.deep.equal(['$autocapture', 'custom-event']) - }) + cy.wait('@capture-assertion') }) }) }) @@ -374,11 +372,12 @@ describe('Event capture', () => { cy.get('[data-cy-input]') .type('hello posthog!') .then(() => { - const requests = cy - .state('requests') - .filter(({ alias }) => alias === 'session-recording' || alias === 'recorder') - expect(requests.length).to.be.equal(0) + cy.get('@session-recording.all').then((calls) => { + expect(calls.length).to.equal(0) + }) }) + + cy.phCaptures().should('not.include', '$snapshot') }) }) @@ -394,8 +393,9 @@ describe('Event capture', () => { it('makes a single decide request on start', () => { start() - cy.wait(200) - cy.shouldBeCalled('decide', 1) + cy.get('@decide.all').then((calls) => { + expect(calls.length).to.equal(1) + }) cy.get('@decide').should(({ request }) => { const payload = getBase64EncodedPayload(request) @@ -419,14 +419,19 @@ describe('Event capture', () => { start() cy.wait(200) - cy.shouldBeCalled('decide', 1) + cy.get('@decide.all').then((calls) => { + expect(calls.length).to.equal(1) + }) cy.posthog().invoke('group', 'company', 'id:6') cy.posthog().invoke('group', 'playlist', 'id:77') cy.posthog().invoke('group', 'anothergroup', 'id:99') cy.wait('@decide') - cy.shouldBeCalled('decide', 2) + + cy.get('@decide.all').then((calls) => { + expect(calls.length).to.equal(2) + }) }) }) }) diff --git a/cypress/e2e/session-recording.cy.js b/cypress/e2e/session-recording.cy.js index e4c829084..5f3a07585 100644 --- a/cypress/e2e/session-recording.cy.js +++ b/cypress/e2e/session-recording.cy.js @@ -13,19 +13,15 @@ describe('Session recording', () => { describe('array.full.js', () => { beforeEach(() => { - cy.route({ - method: 'POST', - url: '**/decide/*', - response: { - config: { enable_collect_everything: false }, - editorParams: {}, - featureFlags: ['session-recording-player'], - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - }, - capture_performance: true, + cy.intercept('POST', '**/decide/*', { + config: { enable_collect_everything: false }, + editorParams: {}, + featureFlags: ['session-recording-player'], + isAuthenticated: false, + sessionRecording: { + endpoint: '/ses/', }, + capture_performance: true, }).as('decide') cy.visit('./playground/cypress-full') @@ -58,20 +54,16 @@ describe('Session recording', () => { describe('array.js', () => { beforeEach(() => { - cy.route({ - method: 'POST', - url: '**/decide/*', - response: { - config: { enable_collect_everything: false }, - editorParams: {}, - featureFlags: ['session-recording-player'], - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - }, - supportedCompression: ['gzip', 'lz64'], - capture_performance: true, + cy.intercept('POST', '**/decide/*', { + config: { enable_collect_everything: false }, + editorParams: {}, + featureFlags: ['session-recording-player'], + isAuthenticated: false, + sessionRecording: { + endpoint: '/ses/', }, + supportedCompression: ['gzip', 'lz64'], + capture_performance: true, }).as('decide') cy.visit('./playground/cypress') diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 99ea071c0..4c686d983 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -58,8 +58,3 @@ Cypress.Commands.add('resetPhCaptures', () => { $captures = [] $fullCaptures = [] }) - -Cypress.Commands.add('shouldBeCalled', (alias, timesCalled) => { - const calls = cy.state('requests').filter((call) => call.alias === alias) - expect(calls).to.have.length(timesCalled, `${alias} should have been called ${timesCalled} times`) -}) diff --git a/cypress/support/compression.js b/cypress/support/compression.js index 00e505634..f603cbf9c 100644 --- a/cypress/support/compression.js +++ b/cypress/support/compression.js @@ -5,8 +5,8 @@ export function getBase64EncodedPayload(request) { return JSON.parse(Buffer.from(data, 'base64')) } -export async function getGzipEncodedPayload(requestBody) { - const data = new Uint8Array(await requestBody.arrayBuffer()) +export async function getGzipEncodedPayload(request) { + const data = new Uint8Array(await request.body) const decoded = fflate.strFromU8(fflate.decompressSync(data)) return JSON.parse(decoded) } diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index e65e0af41..3795d6e87 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -30,11 +30,9 @@ Cypress.on('window:before:load', (win) => { }) beforeEach(() => { - cy.server() - - cy.route('POST', '**/decide/*').as('decide') - cy.route('POST', '**/e/*').as('capture') - cy.route('POST', '**/ses/*').as('session-recording') + cy.intercept('POST', '**/decide/*').as('decide') + cy.intercept('POST', '**/e/*').as('capture') + cy.intercept('POST', '**/ses/*').as('session-recording') cy.readFile('dist/array.full.js').then((body) => { cy.intercept('**/static/array.full.js', { body }) diff --git a/package.json b/package.json index 2ed643487..a6ca85e04 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,6 @@ "fflate": "^0.4.1" }, "devDependencies": { - "eslint-config-posthog-js": "link:./eslint-rules", - "eslint-plugin-posthog-js": "link:./eslint-rules", "@babel/core": "7.18.9", "@babel/preset-env": "7.18.9", "@babel/preset-typescript": "^7.18.6", @@ -55,11 +53,13 @@ "@typescript-eslint/parser": "^6.4.0", "babel-eslint": "10.1.0", "babel-jest": "^26.6.3", - "cypress": "10.3.1", + "cypress": "13.5.1", "eslint": "8.20.0", + "eslint-config-posthog-js": "link:./eslint-rules", "eslint-config-prettier": "^8.5.0", "eslint-plugin-compat": "^4.1.4", "eslint-plugin-jest": "^27.2.3", + "eslint-plugin-posthog-js": "link:./eslint-rules", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.30.1", "eslint-plugin-react-hooks": "^4.6.0", diff --git a/yarn.lock b/yarn.lock index 8d766a887..e09365bfa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2568,10 +2568,10 @@ resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== -"@cypress/request@^2.88.10": - version "2.88.10" - resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.10.tgz#b66d76b07f860d3a4b8d7a0604d020c662752cce" - integrity sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg== +"@cypress/request@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@cypress/request/-/request-3.0.1.tgz#72d7d5425236a2413bd3d8bb66d02d9dc3168960" + integrity sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -2586,9 +2586,9 @@ json-stringify-safe "~5.0.1" mime-types "~2.1.19" performance-now "^2.1.0" - qs "~6.5.2" + qs "6.10.4" safe-buffer "^5.1.2" - tough-cookie "~2.5.0" + tough-cookie "^4.1.3" tunnel-agent "^0.6.0" uuid "^8.3.2" @@ -3636,10 +3636,12 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.37.tgz#abb38afa9d6e8a2f627a8cb52290b3c80fbe61ed" integrity sha512-i1KGxqcvJaLQali+WuypQnXwcplhtNtjs66eNsZpp2P2FL/trJJxx/VWsM0YCL2iMoIJrbXje48lvIQAQ4p2ZA== -"@types/node@^14.14.31": - version "14.18.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.2.tgz#00fe4d1686d5f6cf3a2f2e9a0eef42594d06abfc" - integrity sha512-fqtSN5xn/bBzDxMT77C1rJg6CsH/R49E7qsGuvdPJa20HtV5zSTuLJPNfnlyVH3wauKnkHdLggTVkOW/xP9oQg== +"@types/node@^18.17.5": + version "18.18.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.18.9.tgz#5527ea1832db3bba8eb8023ce8497b7d3f299592" + integrity sha512-0f5klcuImLnG4Qreu9hPj/rEfFq6YRc5n2mAjSsH+ec/mJL+3voBH0+8T7o8RpFjH7ovc+TRsL/c7OYIQsPTfQ== + dependencies: + undici-types "~5.26.4" "@types/parse-json@^4.0.0": version "4.0.0" @@ -5016,6 +5018,11 @@ commander@^5.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== +commander@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + commander@^8.3.0: version "8.3.0" resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" @@ -5169,14 +5176,14 @@ cuint@^0.2.2: resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" integrity sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs= -cypress@10.3.1: - version "10.3.1" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-10.3.1.tgz#7fab4ef43481c05a9a17ebe9a0ec860e15b95a19" - integrity sha512-As9HrExjAgpgjCnbiQCuPdw5sWKx5HUJcK2EOKziu642akwufr/GUeqL5UnCPYXTyyibvEdWT/pSC2qnGW/e5w== +cypress@13.5.1: + version "13.5.1" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.5.1.tgz#8b19bf0b9f31ea43f78980b2479bd3f25197d5cc" + integrity sha512-yqLViT0D/lPI8Kkm7ciF/x/DCK/H/DnogdGyiTnQgX4OVR2aM30PtK+kvklTOD1u3TuItiD9wUQAF8EYWtyZug== dependencies: - "@cypress/request" "^2.88.10" + "@cypress/request" "^3.0.0" "@cypress/xvfb" "^1.2.4" - "@types/node" "^14.14.31" + "@types/node" "^18.17.5" "@types/sinonjs__fake-timers" "8.1.1" "@types/sizzle" "^2.3.2" arch "^2.2.0" @@ -5188,12 +5195,12 @@ cypress@10.3.1: check-more-types "^2.24.0" cli-cursor "^3.1.0" cli-table3 "~0.6.1" - commander "^5.1.0" + commander "^6.2.1" common-tags "^1.8.0" dayjs "^1.10.4" - debug "^4.3.2" + debug "^4.3.4" enquirer "^2.3.6" - eventemitter2 "^6.4.3" + eventemitter2 "6.4.7" execa "4.1.0" executable "^4.1.1" extract-zip "2.0.1" @@ -5206,12 +5213,13 @@ cypress@10.3.1: listr2 "^3.8.3" lodash "^4.17.21" log-symbols "^4.0.0" - minimist "^1.2.6" + minimist "^1.2.8" ospath "^1.2.2" pretty-bytes "^5.6.0" + process "^0.11.10" proxy-from-env "1.0.0" request-progress "^3.0.0" - semver "^7.3.2" + semver "^7.5.3" supports-color "^8.1.1" tmp "~0.2.1" untildify "^4.0.0" @@ -5754,6 +5762,7 @@ escodegen@^2.0.0: "eslint-config-posthog-js@link:./eslint-rules": version "0.0.0" + uid "" eslint-config-prettier@^8.5.0: version "8.5.0" @@ -5783,6 +5792,7 @@ eslint-plugin-jest@^27.2.3: "eslint-plugin-posthog-js@link:./eslint-rules": version "0.0.0" + uid "" eslint-plugin-prettier@^4.2.1: version "4.2.1" @@ -5978,10 +5988,10 @@ event-stream@=3.3.4: stream-combiner "~0.0.4" through "~2.3.1" -eventemitter2@^6.4.3: - version "6.4.5" - resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.5.tgz#97380f758ae24ac15df8353e0cc27f8b95644655" - integrity sha512-bXE7Dyc1i6oQElDG0jMRZJrRAn9QR2xyyFGmBdZleNmyQX0FqGYmhZIrIrpPfm/w//LTo4tVQGOGQcGCb5q9uw== +eventemitter2@6.4.7: + version "6.4.7" + resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.7.tgz#a7f6c4d7abf28a14c1ef3442f21cb306a054271d" + integrity sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg== events@^3.3.0: version "3.3.0" @@ -8988,11 +8998,16 @@ minimatch@^3.0.4, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + mitt@^1.1.3: version "1.2.0" resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.2.0.tgz#cb24e6569c806e31bd4e3995787fe38a04fdf90d" @@ -9767,6 +9782,7 @@ posix-character-classes@^0.1.0: "posthog-js@link:.": version "0.0.0" + uid "" prelude-ls@^1.2.1: version "1.2.1" @@ -9913,6 +9929,13 @@ qrcode-terminal@^0.10.0: resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.10.0.tgz#a76a48e2610a18f97fa3a2bd532b682acff86c53" integrity sha1-p2pI4mEKGPl/o6K9UytoKs/4bFM= +qs@6.10.4: + version "6.10.4" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.4.tgz#6a3003755add91c0ec9eacdc5f878b034e73f9e7" + integrity sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g== + dependencies: + side-channel "^1.0.4" + qs@6.11.0: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" @@ -9925,6 +9948,11 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + queue-microtask@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.2.tgz#abf64491e6ecf0f38a6502403d4cda04f372dfd3" @@ -10228,6 +10256,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + reselect@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" @@ -10565,7 +10598,7 @@ semver@^7.3.7: dependencies: lru-cache "^6.0.0" -semver@^7.5.4: +semver@^7.5.3, semver@^7.5.4: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -11531,6 +11564,16 @@ tough-cookie@^2.3.3, tough-cookie@~2.5.0: psl "^1.1.28" punycode "^2.1.1" +tough-cookie@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + tr46@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" @@ -11673,6 +11716,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" @@ -11734,6 +11782,11 @@ universalify@^0.1.2: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + universalify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" @@ -11795,6 +11848,14 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From 8ed9d0ad693faea8ef703fade878e43d0e3b2883 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Mon, 20 Nov 2023 08:30:10 +0000 Subject: [PATCH 2/8] chore: convert more tests to TS (#903) * chore: convert more tests to TS * more tests to TS * more tests to TS * more tests to TS * remove some given * and more * updated snapshot * fix * fix --- package.json | 1 + ...ssion.js.snap => compression.test.ts.snap} | 0 ...ure-utils.js => autocapture-utils.test.ts} | 160 ++--- .../{autocapture.js => autocapture.test.ts} | 656 +++++++++--------- src/__tests__/compression.js | 44 -- src/__tests__/compression.test.ts | 38 + .../{gdpr-utils.js => gdpr-utils.test.ts} | 53 +- src/__tests__/{loader.js => loader.test.ts} | 12 +- .../{page-view.ts => page-view.test.ts} | 0 src/__tests__/request-queue.js | 119 ---- src/__tests__/request-queue.test.ts | 128 ++++ .../{retry-queue.js => retry-queue.test.ts} | 131 ++-- src/__tests__/{surveys.js => surveys.test.ts} | 287 ++++---- .../{test-uuid.js => test-uuid.test.ts} | 0 src/__tests__/utils.js | 261 ------- src/__tests__/utils.test.ts | 279 ++++++++ yarn.lock | 12 + 17 files changed, 1121 insertions(+), 1060 deletions(-) rename src/__tests__/__snapshots__/{compression.js.snap => compression.test.ts.snap} (100%) rename src/__tests__/{autocapture-utils.js => autocapture-utils.test.ts} (72%) rename src/__tests__/{autocapture.js => autocapture.test.ts} (76%) delete mode 100644 src/__tests__/compression.js create mode 100644 src/__tests__/compression.test.ts rename src/__tests__/{gdpr-utils.js => gdpr-utils.test.ts} (92%) rename src/__tests__/{loader.js => loader.test.ts} (77%) rename src/__tests__/{page-view.ts => page-view.test.ts} (100%) delete mode 100644 src/__tests__/request-queue.js create mode 100644 src/__tests__/request-queue.test.ts rename src/__tests__/{retry-queue.js => retry-queue.test.ts} (71%) rename src/__tests__/{surveys.js => surveys.test.ts} (59%) rename src/__tests__/{test-uuid.js => test-uuid.test.ts} (100%) delete mode 100644 src/__tests__/utils.js create mode 100644 src/__tests__/utils.test.ts diff --git a/package.json b/package.json index a6ca85e04..6694c8c71 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/eslint": "^8.44.6", "@types/jest": "^29.5.1", "@types/react-dom": "^18.0.10", + "@types/sinon": "^17.0.1", "@types/uuid": "^9.0.1", "@typescript-eslint/eslint-plugin": "^6.4.0", "@typescript-eslint/parser": "^6.4.0", diff --git a/src/__tests__/__snapshots__/compression.js.snap b/src/__tests__/__snapshots__/compression.test.ts.snap similarity index 100% rename from src/__tests__/__snapshots__/compression.js.snap rename to src/__tests__/__snapshots__/compression.test.ts.snap diff --git a/src/__tests__/autocapture-utils.js b/src/__tests__/autocapture-utils.test.ts similarity index 72% rename from src/__tests__/autocapture-utils.js rename to src/__tests__/autocapture-utils.test.ts index 142a9ef20..162c7dbf2 100644 --- a/src/__tests__/autocapture-utils.js +++ b/src/__tests__/autocapture-utils.test.ts @@ -1,3 +1,5 @@ +/// + import sinon from 'sinon' import { @@ -10,15 +12,17 @@ import { getNestedSpanText, getDirectAndNestedSpanText, } from '../autocapture-utils' +import { document } from '../utils/globals' +import { makeMouseEvent } from './autocapture.test' describe(`Autocapture utility functions`, () => { afterEach(() => { - document.getElementsByTagName('html')[0].innerHTML = '' + document!.getElementsByTagName('html')[0].innerHTML = '' }) describe(`getSafeText`, () => { it(`should collect and normalize text from elements`, () => { - const el = document.createElement(`div`) + const el = document!.createElement(`div`) el.innerHTML = ` Why hello there ` expect(getSafeText(el)).toBe(`Why hello there`) @@ -41,7 +45,7 @@ describe(`Autocapture utility functions`, () => { }) it(`shouldn't collect text from element children`, () => { - const el = document.createElement(`div`) + const el = document!.createElement(`div`) let safeText el.innerHTML = `
sensitive
` @@ -64,26 +68,26 @@ describe(`Autocapture utility functions`, () => { it(`shouldn't collect text from potentially sensitive elements`, () => { let el - el = document.createElement(`input`) + el = document!.createElement(`input`) el.innerHTML = `Why hello there` expect(getSafeText(el)).toBe(``) - el = document.createElement(`textarea`) + el = document!.createElement(`textarea`) el.innerHTML = `Why hello there` expect(getSafeText(el)).toBe(``) - el = document.createElement(`select`) + el = document!.createElement(`select`) el.innerHTML = `Why hello there` expect(getSafeText(el)).toBe(``) - el = document.createElement(`div`) + el = document!.createElement(`div`) el.setAttribute(`contenteditable`, `true`) el.innerHTML = `Why hello there` expect(getSafeText(el)).toBe(``) }) it(`shouldn't collect sensitive values`, () => { - const el = document.createElement(`div`) + const el = document!.createElement(`div`) el.innerHTML = `Why 123-58-1321 hello there` expect(getSafeText(el)).toBe(`Why hello there`) @@ -105,17 +109,17 @@ describe(`Autocapture utility functions`, () => { describe(`shouldCaptureDomEvent`, () => { it(`should capture "submit" events on
elements`, () => { expect( - shouldCaptureDomEvent(document.createElement(`form`), { + shouldCaptureDomEvent(document!.createElement(`form`), { type: `submit`, - }) + } as unknown as Event) ).toBe(true) }) ;[`input`, `SELECT`, `textarea`].forEach((tagName) => { it(`should capture "change" events on <` + tagName.toLowerCase() + `> elements`, () => { expect( - shouldCaptureDomEvent(document.createElement(tagName), { + shouldCaptureDomEvent(document!.createElement(tagName), { type: `change`, - }) + } as unknown as Event) ).toBe(true) }) }) @@ -123,81 +127,53 @@ describe(`Autocapture utility functions`, () => { // [`div`, `sPan`, `A`, `strong`, `table`] ;['a'].forEach((tagName) => { it(`should capture "click" events on <` + tagName.toLowerCase() + `> elements`, () => { - expect( - shouldCaptureDomEvent(document.createElement(tagName), { - type: `click`, - }) - ).toBe(true) + expect(shouldCaptureDomEvent(document!.createElement(tagName), makeMouseEvent({}))).toBe(true) }) }) it(`should capture "click" events on
- +
@@ -852,15 +877,13 @@ describe('Autocapture system', () => { ` - document.body.innerHTML = dom const span1 = document.getElementById('span1') const span2 = document.getElementById('span2') const img2 = document.getElementById('img2') - const e1 = { + const e1 = makeMouseEvent({ target: span2, - type: 'click', - } + }) autocapture._captureEvent(e1, lib) const props1 = getCapturedProps(lib.capture) @@ -868,22 +891,20 @@ describe('Autocapture system', () => { "Some super duper really long Text with new lines that we'll strip out and also we will want to make this text shorter since it's not likely people really care about text content that's super long and it also takes up more space and bandwidth. Some super d" expect(props1['$elements'][0]).toHaveProperty('$el_text', text1) expect(props1['$el_text']).toEqual(text1) - lib.capture.resetHistory() + ;(lib.capture as sinon.SinonSpy).resetHistory() - const e2 = { + const e2 = makeMouseEvent({ target: span1, - type: 'click', - } + }) autocapture._captureEvent(e2, lib) const props2 = getCapturedProps(lib.capture) expect(props2['$elements'][0]).toHaveProperty('$el_text', 'Some text') expect(props2['$el_text']).toEqual('Some text') - lib.capture.resetHistory() + ;(lib.capture as sinon.SinonSpy).resetHistory() - const e3 = { + const e3 = makeMouseEvent({ target: img2, - type: 'click', - } + }) autocapture._captureEvent(e3, lib) const props3 = getCapturedProps(lib.capture) expect(props3['$elements'][0]).toHaveProperty('$el_text', '') @@ -891,7 +912,8 @@ describe('Autocapture system', () => { }) it('does not capture sensitive text content', () => { - const dom = ` + // ^ valid credit card and social security numbers + document.body.innerHTML = `
@@ -903,37 +925,32 @@ describe('Autocapture system', () => { Why hello there 5105-1051-0510-5100 - ` // ^ valid credit card and social security numbers - - document.body.innerHTML = dom + ` const button1 = document.getElementById('button1') const button2 = document.getElementById('button2') const button3 = document.getElementById('button3') - const e1 = { + const e1 = makeMouseEvent({ target: button1, - type: 'click', - } + }) autocapture._captureEvent(e1, lib) const props1 = getCapturedProps(lib.capture) expect(props1['$elements'][0]).toHaveProperty('$el_text') expect(props1['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) - lib.capture.resetHistory() + ;(lib.capture as sinon.SinonSpy).resetHistory() - const e2 = { + const e2 = makeMouseEvent({ target: button2, - type: 'click', - } + }) autocapture._captureEvent(e2, lib) const props2 = getCapturedProps(lib.capture) expect(props2['$elements'][0]).toHaveProperty('$el_text') expect(props2['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) - lib.capture.resetHistory() + ;(lib.capture as sinon.SinonSpy).resetHistory() - const e3 = { + const e3 = makeMouseEvent({ target: button3, - type: 'click', - } + }) autocapture._captureEvent(e3, lib) const props3 = getCapturedProps(lib.capture) expect(props3['$elements'][0]).toHaveProperty('$el_text') @@ -944,44 +961,42 @@ describe('Autocapture system', () => { const e = { target: document.createElement('form'), type: 'submit', - } + } as unknown as FormDataEvent autocapture._captureEvent(e, lib) - expect(lib.capture.calledOnce).toBe(true) + expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) const props = getCapturedProps(lib.capture) expect(props['$event_type']).toBe('submit') }) it('should capture a click event inside a form with form field props', () => { - var form = document.createElement('form') - var link = document.createElement('a') - var input = document.createElement('input') + const form = document.createElement('form') + const link = document.createElement('a') + const input = document.createElement('input') input.name = 'test input' input.value = 'test val' form.appendChild(link) form.appendChild(input) - const e = { + const e = makeMouseEvent({ target: link, - type: 'click', - } + }) autocapture._captureEvent(e, lib) - expect(lib.capture.calledOnce).toBe(true) - const props = getCapturedProps(lib.capture) + expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) + const props = getCapturedProps(lib.capture as sinon.SinonSpy) expect(props['$event_type']).toBe('click') }) it('should capture a click event inside a shadowroot', () => { - var main_el = document.createElement('some-element') - var shadowRoot = main_el.attachShadow({ mode: 'open' }) - var button = document.createElement('a') + const main_el = document.createElement('some-element') + const shadowRoot = main_el.attachShadow({ mode: 'open' }) + const button = document.createElement('a') button.innerHTML = 'bla' shadowRoot.appendChild(button) - const e = { + const e = makeMouseEvent({ target: main_el, composedPath: () => [button, main_el], - type: 'click', - } + }) autocapture._captureEvent(e, lib) - expect(lib.capture.calledOnce).toBe(true) + expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) const props = getCapturedProps(lib.capture) expect(props['$event_type']).toBe('click') }) @@ -990,20 +1005,20 @@ describe('Autocapture system', () => { const a = document.createElement('a') const span = document.createElement('span') a.appendChild(span) - autocapture._captureEvent({ target: a, type: 'click' }, lib) - expect(lib.capture.calledOnce).toBe(true) - lib.capture.resetHistory() + autocapture._captureEvent(makeMouseEvent({ target: a }), lib) + expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) + ;(lib.capture as sinon.SinonSpy).resetHistory() - autocapture._captureEvent({ target: span, type: 'click' }, lib) - expect(lib.capture.calledOnce).toBe(true) - lib.capture.resetHistory() + autocapture._captureEvent(makeMouseEvent({ target: span }), lib) + expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) + ;(lib.capture as sinon.SinonSpy).resetHistory() a.className = 'test1 ph-no-capture test2' - autocapture._captureEvent({ target: a, type: 'click' }, lib) - expect(lib.capture.callCount).toBe(0) + autocapture._captureEvent(makeMouseEvent({ target: a }), lib) + expect((lib.capture as sinon.SinonSpy).callCount).toBe(0) - autocapture._captureEvent({ target: span, type: 'click' }, lib) - expect(lib.capture.callCount).toBe(0) + autocapture._captureEvent(makeMouseEvent({ target: span }), lib) + expect((lib.capture as sinon.SinonSpy).callCount).toBe(0) }) it('does not capture any element attributes if mask_all_element_attributes is set', () => { @@ -1013,21 +1028,20 @@ describe('Autocapture system', () => { ` - const newLib = { + const newLib = makePostHog({ ...lib, config: { ...lib.config, mask_all_element_attributes: true, }, - } + }) document.body.innerHTML = dom const button1 = document.getElementById('button1') - const e1 = { + const e1 = makeMouseEvent({ target: button1, - type: 'click', - } + }) autocapture._captureEvent(e1, newLib) const props1 = getCapturedProps(newLib.capture) @@ -1041,21 +1055,20 @@ describe('Autocapture system', () => { ` - const newLib = { + const newLib = makePostHog({ ...lib, config: { ...lib.config, mask_all_text: true, }, - } + }) document.body.innerHTML = dom const a = document.getElementById('a1') - const e1 = { + const e1 = makeMouseEvent({ target: a, - type: 'click', - } + }) autocapture._captureEvent(e1, newLib) const props1 = getCapturedProps(newLib.capture) @@ -1065,23 +1078,20 @@ describe('Autocapture system', () => { }) describe('_addDomEventHandlers', () => { - const lib = { + const lib = makePostHog({ capture: sinon.spy(), - get_distinct_id() { - return 'distinctid' - }, config: { mask_all_element_attributes: false, - }, - } + } as PostHogConfig, + }) - let navigateSpy + let navigateSpy: sinon.SinonSpy beforeEach(() => { document.title = 'test page' autocapture._addDomEventHandlers(lib) navigateSpy = sinon.spy(autocapture, '_navigate') - lib.capture.resetHistory() + ;(lib.capture as sinon.SinonSpy).resetHistory() }) afterAll(() => { @@ -1093,64 +1103,62 @@ describe('Autocapture system', () => { document.body.appendChild(button) simulateClick(button) simulateClick(button) - expect(true).toBe(lib.capture.calledTwice) - const captureArgs1 = lib.capture.args[0] - const captureArgs2 = lib.capture.args[1] + expect(true).toBe((lib.capture as sinon.SinonSpy).calledTwice) + const captureArgs1 = (lib.capture as sinon.SinonSpy).args[0] + const captureArgs2 = (lib.capture as sinon.SinonSpy).args[1] const eventType1 = captureArgs1[1]['$event_type'] const eventType2 = captureArgs2[1]['$event_type'] expect(eventType1).toBe('click') expect(eventType2).toBe('click') - lib.capture.resetHistory() + ;(lib.capture as sinon.SinonSpy).resetHistory() }) }) describe('afterDecideResponse()', () => { - given('subject', () => () => autocapture.afterDecideResponse(given.decideResponse, given.posthog)) - - given('persistence', () => ({ props: {}, register: jest.fn() })) - - given('posthog', () => ({ - config: { - api_host: 'https://test.com', - token: 'testtoken', - autocapture: true, - }, - token: 'testtoken', - capture: jest.fn(), - get_distinct_id: () => 'distinctid', - get_property: (property_key) => - property_key === AUTOCAPTURE_DISABLED_SERVER_SIDE ? given.$autocapture_disabled_server_side : undefined, - persistence: given.persistence, - })) - - given('decideResponse', () => ({ config: { enable_collect_everything: true } })) + let posthog: PostHog + let persistence: PostHogPersistence beforeEach(() => { document.title = 'test page' autocapture._initializedTokens = [] + persistence = { props: {}, register: jest.fn() } as unknown as PostHogPersistence + decideResponse = { config: { enable_collect_everything: true } } as DecideResponse + + posthog = makePostHog({ + config: { + api_host: 'https://test.com', + token: 'testtoken', + autocapture: true, + } as PostHogConfig, + capture: jest.fn(), + get_property: (property_key: string) => + property_key === AUTOCAPTURE_DISABLED_SERVER_SIDE ? $autocapture_disabled_server_side : undefined, + persistence: persistence, + }) + jest.spyOn(autocapture, '_addDomEventHandlers') }) it('should be enabled before the decide response', () => { // _setIsAutocaptureEnabled is called during init - autocapture._setIsAutocaptureEnabled(given.posthog) + autocapture._setIsAutocaptureEnabled(posthog) expect(autocapture._isAutocaptureEnabled).toBe(true) }) it('should be disabled before the decide response if opt out is in persistence', () => { - given('persistence', () => ({ props: { [AUTOCAPTURE_DISABLED_SERVER_SIDE]: true } })) + persistence.props[AUTOCAPTURE_DISABLED_SERVER_SIDE] = true // _setIsAutocaptureEnabled is called during init - autocapture._setIsAutocaptureEnabled(given.posthog) + autocapture._setIsAutocaptureEnabled(posthog) expect(autocapture._isAutocaptureEnabled).toBe(false) }) it('should be disabled before the decide response if client side opted out', () => { - given.posthog.config.autocapture = false + posthog.config.autocapture = false // _setIsAutocaptureEnabled is called during init - autocapture._setIsAutocaptureEnabled(given.posthog) + autocapture._setIsAutocaptureEnabled(posthog) expect(autocapture._isAutocaptureEnabled).toBe(false) }) @@ -1164,138 +1172,140 @@ describe('Autocapture system', () => { ])( 'when client side config is %p and remote opt out is %p - autocapture enabled should be %p', (clientSideOptIn, serverSideOptOut, expected) => { - given.posthog.config.autocapture = clientSideOptIn - given('decideResponse', () => ({ + posthog.config.autocapture = clientSideOptIn + decideResponse = { config: { enable_collect_everything: true }, autocapture_opt_out: serverSideOptOut, - })) - given.subject() + } as DecideResponse + autocapture.afterDecideResponse(decideResponse, posthog) expect(autocapture._isAutocaptureEnabled).toBe(expected) } ) it('should call _addDomEventHandlders if autocapture is true', () => { - given('$autocapture_disabled_server_side', () => false) - given.subject() + $autocapture_disabled_server_side = false + + autocapture.afterDecideResponse(decideResponse, posthog) expect(autocapture._addDomEventHandlers).toHaveBeenCalled() }) it('should not call _addDomEventHandlders if autocapture is disabled', () => { - given.posthog.config = { + posthog.config = { api_host: 'https://test.com', token: 'testtoken', autocapture: false, - } - given('$autocapture_disabled_server_side', () => true) - given.subject() + } as PostHogConfig + $autocapture_disabled_server_side = true + + autocapture.afterDecideResponse(decideResponse, posthog) + expect(autocapture._addDomEventHandlers).not.toHaveBeenCalled() }) it('should NOT call _addDomEventHandlders if the decide request fails', () => { - given('decideResponse', () => ({ status: 0, error: 'Bad HTTP status: 400 Bad Request' })) + decideResponse = { status: 0, error: 'Bad HTTP status: 400 Bad Request' } as unknown as DecideResponse + + autocapture.afterDecideResponse(decideResponse, posthog) - given.subject() expect(autocapture._addDomEventHandlers).not.toHaveBeenCalled() }) it('should NOT call _addDomEventHandlders when enable_collect_everything is "false"', () => { - given('decideResponse', () => ({ config: { enable_collect_everything: false } })) + decideResponse = { config: { enable_collect_everything: false } } as DecideResponse + + autocapture.afterDecideResponse(decideResponse, posthog) - given.subject() expect(autocapture._addDomEventHandlers).not.toHaveBeenCalled() }) it('should NOT call _addDomEventHandlders when the token has already been initialized', () => { - given('$autocapture_disabled_server_side', () => false) - autocapture.afterDecideResponse(given.decideResponse, given.posthog) + $autocapture_disabled_server_side = false + autocapture.afterDecideResponse(decideResponse, posthog) expect(autocapture._addDomEventHandlers).toHaveBeenCalledTimes(1) - autocapture.afterDecideResponse(given.decideResponse, given.posthog) + autocapture.afterDecideResponse(decideResponse, posthog) expect(autocapture._addDomEventHandlers).toHaveBeenCalledTimes(1) - given.posthog.config = { api_host: 'https://test.com', token: 'anotherproject', autocapture: true } - autocapture.afterDecideResponse(given.decideResponse, given.posthog) + posthog.config = { + api_host: 'https://test.com', + token: 'anotherproject', + autocapture: true, + } as PostHogConfig + autocapture.afterDecideResponse(decideResponse, posthog) expect(autocapture._addDomEventHandlers).toHaveBeenCalledTimes(2) }) }) describe('shouldCaptureDomEvent autocapture config', () => { it('only capture urls which match the url regex allowlist', () => { - var main_el = document.createElement('some-element') - var button = document.createElement('a') + const main_el = document.createElement('some-element') + const button = document.createElement('a') button.innerHTML = 'bla' main_el.appendChild(button) - const e = { + const e = makeMouseEvent({ target: main_el, composedPath: () => [button, main_el], - type: 'click', - } + }) const autocapture_config = { url_allowlist: ['https://posthog.com/test/*'], } - delete window.location - window.location = new URL('https://posthog.com/test/captured') + window!.location = new URL('https://posthog.com/test/captured') as unknown as Location expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) - delete window.location - window.location = new URL('https://posthog.com/docs/not-captured') + window!.location = new URL('https://posthog.com/docs/not-captured') as unknown as Location expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) }) it('an empty url regex allowlist does not match any url', () => { - var main_el = document.createElement('some-element') - var button = document.createElement('a') + const main_el = document.createElement('some-element') + const button = document.createElement('a') button.innerHTML = 'bla' main_el.appendChild(button) - const e = { + const e = makeMouseEvent({ target: main_el, composedPath: () => [button, main_el], - type: 'click', - } - const autocapture_config = { + }) + const autocapture_config: AutocaptureConfig = { url_allowlist: [], } - delete window.location - window.location = new URL('https://posthog.com/test/captured') + window!.location = new URL('https://posthog.com/test/captured') as unknown as Location expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) }) it('only capture event types which match the allowlist', () => { - var main_el = document.createElement('some-element') - var button = document.createElement('button') + const main_el = document.createElement('some-element') + const button = document.createElement('button') button.innerHTML = 'bla' main_el.appendChild(button) - const e = { + const e = makeMouseEvent({ target: main_el, composedPath: () => [button, main_el], - type: 'click', - } - const autocapture_config = { + }) + const autocapture_config: AutocaptureConfig = { dom_event_allowlist: ['click'], } expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) - const autocapture_config_change = { + const autocapture_config_change: AutocaptureConfig = { dom_event_allowlist: ['change'], } expect(shouldCaptureDomEvent(button, e, autocapture_config_change)).toBe(false) }) it('an empty event type allowlist matches no events', () => { - var main_el = document.createElement('some-element') - var button = document.createElement('button') + const main_el = document.createElement('some-element') + const button = document.createElement('button') button.innerHTML = 'bla' main_el.appendChild(button) - const e = { + const e = makeMouseEvent({ target: main_el, composedPath: () => [button, main_el], - type: 'click', - } + }) const autocapture_config = { dom_event_allowlist: [], } @@ -1303,54 +1313,51 @@ describe('Autocapture system', () => { }) it('only capture elements which match the allowlist', () => { - var main_el = document.createElement('some-element') - var button = document.createElement('button') + const main_el = document.createElement('some-element') + const button = document.createElement('button') button.innerHTML = 'bla' main_el.appendChild(button) - const e = { + const e = makeMouseEvent({ target: main_el, composedPath: () => [button, main_el], - type: 'click', - } - const autocapture_config = { + }) + const autocapture_config: AutocaptureConfig = { element_allowlist: ['button'], } expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) - const autocapture_config_change = { + const autocapture_config_change: AutocaptureConfig = { element_allowlist: ['a'], } expect(shouldCaptureDomEvent(button, e, autocapture_config_change)).toBe(false) }) it('an empty event allowlist means we capture no elements', () => { - var main_el = document.createElement('some-element') - var button = document.createElement('button') + const main_el = document.createElement('some-element') + const button = document.createElement('button') button.innerHTML = 'bla' main_el.appendChild(button) - const e = { + const e = makeMouseEvent({ target: main_el, composedPath: () => [button, main_el], - type: 'click', - } - const autocapture_config = { + }) + const autocapture_config: AutocaptureConfig = { element_allowlist: [], } expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) }) it('only capture elements which match the css allowlist', () => { - var main_el = document.createElement('some-element') - var button = document.createElement('button') + const main_el = document.createElement('some-element') + const button = document.createElement('button') button.setAttribute('data-track', 'yes') button.innerHTML = 'bla' main_el.appendChild(button) - const e = { + const e = makeMouseEvent({ target: main_el, composedPath: () => [button, main_el], - type: 'click', - } - const autocapture_config = { + }) + const autocapture_config: AutocaptureConfig = { css_selector_allowlist: ['[data-track="yes"]'], } expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) @@ -1362,17 +1369,16 @@ describe('Autocapture system', () => { }) it('an empty css selector list captures no elements', () => { - var main_el = document.createElement('some-element') - var button = document.createElement('button') + const main_el = document.createElement('some-element') + const button = document.createElement('button') button.setAttribute('data-track', 'yes') button.innerHTML = 'bla' main_el.appendChild(button) - const e = { + const e = makeMouseEvent({ target: main_el, composedPath: () => [button, main_el], - type: 'click', - } - const autocapture_config = { + }) + const autocapture_config: AutocaptureConfig = { css_selector_allowlist: [], } expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) diff --git a/src/__tests__/compression.js b/src/__tests__/compression.js deleted file mode 100644 index 333070435..000000000 --- a/src/__tests__/compression.js +++ /dev/null @@ -1,44 +0,0 @@ -import { decideCompression, compressData } from '../compression' - -describe('decideCompression()', () => { - given('subject', () => decideCompression(given.compressionSupport)) - given('compressionSupport', () => ({})) - - it('returns base64 by default', () => { - expect(given.subject).toEqual('base64') - }) - - it('returns gzip-js if all compressions supported', () => { - given('compressionSupport', () => ({ - 'gzip-js': true, - 'a different thing that is either deprecated or new': true, - })) - - expect(given.subject).toEqual('gzip-js') - }) - - it('returns base64 if only unexpected compression is received', () => { - given('compressionSupport', () => ({ 'the new compression that is not supported yet': true })) - - expect(given.subject).toEqual('base64') - }) -}) - -describe('compressData()', () => { - given('subject', () => compressData(given.compression, given.jsonData, given.options)) - - given('jsonData', () => JSON.stringify({ large_key: new Array(500).join('abc') })) - given('options', () => ({ method: 'POST' })) - - it('handles base64', () => { - given('compression', () => 'base64') - - expect(given.subject).toMatchSnapshot() - }) - - it('handles gzip-js', () => { - given('compression', () => 'gzip-js') - - expect(given.subject).toMatchSnapshot() - }) -}) diff --git a/src/__tests__/compression.test.ts b/src/__tests__/compression.test.ts new file mode 100644 index 000000000..d7d6c0469 --- /dev/null +++ b/src/__tests__/compression.test.ts @@ -0,0 +1,38 @@ +import { compressData, decideCompression } from '../compression' +import { Compression, XHROptions } from '../types' + +describe('decideCompression()', () => { + it('returns base64 by default', () => { + expect(decideCompression({})).toEqual('base64') + }) + + it('returns gzip-js if all compressions supported', () => { + expect( + decideCompression({ + 'gzip-js': true, + 'a different thing that is either deprecated or new': true, + } as unknown as Partial>) + ).toEqual('gzip-js') + }) + + it('returns base64 if only unexpected compression is received', () => { + expect( + decideCompression({ 'the new compression that is not supported yet': true } as unknown as Partial< + Record + >) + ).toEqual('base64') + }) +}) + +describe('compressData()', () => { + const jsonData = JSON.stringify({ large_key: new Array(500).join('abc') }) + const options: XHROptions = { method: 'POST' } + + it('handles base64', () => { + expect(compressData(Compression.Base64, jsonData, options)).toMatchSnapshot() + }) + + it('handles gzip-js', () => { + expect(compressData(Compression.GZipJS, jsonData, options)).toMatchSnapshot() + }) +}) diff --git a/src/__tests__/gdpr-utils.js b/src/__tests__/gdpr-utils.test.ts similarity index 92% rename from src/__tests__/gdpr-utils.js rename to src/__tests__/gdpr-utils.test.ts index dc06de8c7..7e1784f5f 100644 --- a/src/__tests__/gdpr-utils.js +++ b/src/__tests__/gdpr-utils.test.ts @@ -3,6 +3,8 @@ import sinon from 'sinon' import * as gdpr from '../gdpr-utils' import { _isNull } from '../utils/type-utils' +import { document, assignableWindow } from '../utils/globals' +import { GDPROptions } from '../types' const TOKENS = [ `test-token`, @@ -13,23 +15,28 @@ const DEFAULT_PERSISTENCE_PREFIX = `__ph_opt_in_out_` const CUSTOM_PERSISTENCE_PREFIX = `𝓶𝓶𝓶𝓬𝓸𝓸𝓴𝓲𝓮𝓼` function deleteAllCookies() { - var cookies = document.cookie.split(';') + const cookies = document.cookie.split(';') - for (var i = 0; i < cookies.length; i++) { - var cookie = cookies[i] - var eqPos = cookie.indexOf('=') - var name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i] + const eqPos = cookie.indexOf('=') + const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT' } } -function forPersistenceTypes(runTests) { +function forPersistenceTypes(runTests: any) { ;[`cookie`, `localStorage`, `localStorage+cookie`].forEach(function (persistenceType) { describe(persistenceType, runTests.bind(null, persistenceType)) }) } -function assertPersistenceValue(persistenceType, token, value, persistencePrefix = DEFAULT_PERSISTENCE_PREFIX) { +function assertPersistenceValue( + persistenceType: GDPROptions['persistenceType'], + token: string, + value: string | number | null, + persistencePrefix = DEFAULT_PERSISTENCE_PREFIX +) { if (persistenceType === `cookie`) { if (_isNull(value)) { expect(document.cookie).not.toContain(token) @@ -38,9 +45,9 @@ function assertPersistenceValue(persistenceType, token, value, persistencePrefix } } else { if (_isNull(value)) { - expect(window.localStorage.getItem(persistencePrefix + token)).toBeNull() + expect(assignableWindow.localStorage.getItem(persistencePrefix + token)).toBeNull() } else { - expect(window.localStorage.getItem(persistencePrefix + token)).toBe(`${value}`) + expect(assignableWindow.localStorage.getItem(persistencePrefix + token)).toBe(`${value}`) } } } @@ -51,12 +58,12 @@ describe(`GDPR utils`, () => { afterEach(() => { document.getElementsByTagName('html')[0].innerHTML = '' - window.localStorage.clear() + assignableWindow.localStorage.clear() deleteAllCookies() }) describe(`optIn`, () => { - forPersistenceTypes(function (persistenceType) { + forPersistenceTypes(function (persistenceType: GDPROptions['persistenceType']) { it(`should set a cookie marking the user as opted-in for a given token`, () => { TOKENS.forEach((token) => { gdpr.optIn(token, { persistenceType }) @@ -92,7 +99,7 @@ describe(`GDPR utils`, () => { it(`shouldn't capture an event if the user has opted out`, () => { TOKENS.forEach((token) => { - let capture = sinon.spy() + const capture = sinon.spy() gdpr.optOut(token, { persistenceType }) gdpr.optOut(token, { capture, persistenceType }) expect(capture.notCalled).toBe(true) @@ -101,7 +108,7 @@ describe(`GDPR utils`, () => { it(`should capture an event if the user has opted in`, () => { TOKENS.forEach((token) => { - let capture = sinon.spy() + const capture = sinon.spy() gdpr.optOut(token, { persistenceType }) gdpr.optIn(token, { persistenceType }) gdpr.optIn(token, { capture, persistenceType }) @@ -111,7 +118,7 @@ describe(`GDPR utils`, () => { it(`should capture an event if the user is switching opt from out to in`, () => { TOKENS.forEach((token) => { - let capture = sinon.spy() + const capture = sinon.spy() gdpr.optOut(token, { persistenceType }) gdpr.optIn(token, { capture, persistenceType }) expect(capture.calledOnce).toBe(true) @@ -141,7 +148,7 @@ describe(`GDPR utils`, () => { }) describe(`optOut`, () => { - forPersistenceTypes(function (persistenceType) { + forPersistenceTypes(function (persistenceType: GDPROptions['persistenceType']) { it(`should set a cookie marking the user as opted-out for a given token`, () => { TOKENS.forEach((token) => { gdpr.optOut(token, { persistenceType }) @@ -168,8 +175,8 @@ describe(`GDPR utils`, () => { it(`shouldn't capture an event if the user is switching opt from in to out`, () => { TOKENS.forEach((token) => { - let capture = sinon.spy() - gdpr.optIn(token) + const capture = sinon.spy() + gdpr.optIn(token, {}) gdpr.optOut(token, { capture, persistenceType }) expect(capture.calledOnce).toBe(false) }) @@ -198,7 +205,7 @@ describe(`GDPR utils`, () => { }) describe(`hasOptedIn`, () => { - forPersistenceTypes(function (persistenceType) { + forPersistenceTypes(function (persistenceType: GDPROptions['persistenceType']) { it(`should return 'false' if the user hasn't opted in for a given token`, () => { TOKENS.forEach((token) => { expect(gdpr.hasOptedIn(token, { persistenceType })).toBe(false) @@ -214,7 +221,7 @@ describe(`GDPR utils`, () => { it(`should return 'false' if the user opts in for any other token`, () => { const token = TOKENS[0] - gdpr.optIn(token) + gdpr.optIn(token, {}) TOKENS.filter((otherToken) => otherToken !== token).forEach((otherToken) => { expect(gdpr.hasOptedIn(otherToken, { persistenceType })).toBe(false) @@ -267,7 +274,7 @@ describe(`GDPR utils`, () => { expect( gdpr.hasOptedIn(token, { persistencePrefix: CUSTOM_PERSISTENCE_PREFIX, persistenceType }) ).toBe(true) - gdpr.optOut(token) + gdpr.optOut(token, {}) expect( gdpr.hasOptedIn(token, { persistencePrefix: CUSTOM_PERSISTENCE_PREFIX, persistenceType }) ).toBe(true) @@ -281,7 +288,7 @@ describe(`GDPR utils`, () => { }) describe(`hasOptedOut`, () => { - forPersistenceTypes(function (persistenceType) { + forPersistenceTypes(function (persistenceType: GDPROptions['persistenceType']) { it(`should return 'false' if the user hasn't opted out for a given token`, () => { TOKENS.forEach((token) => { expect(gdpr.hasOptedOut(token, { persistenceType })).toBe(false) @@ -364,7 +371,7 @@ describe(`GDPR utils`, () => { }) describe(`clearOptInOut`, () => { - forPersistenceTypes(function (persistenceType) { + forPersistenceTypes(function (persistenceType: GDPROptions['persistenceType']) { it(`should delete any opt cookies for a given token`, () => { ;[gdpr.optIn, gdpr.optOut].forEach((optFunc) => { TOKENS.forEach((token) => { @@ -450,7 +457,7 @@ describe(`GDPR utils`, () => { persistencePrefix: CUSTOM_PERSISTENCE_PREFIX, }) ).toBe(true) - gdpr.clearOptInOut(token) + gdpr.clearOptInOut(token, {}) expect( gdpr.hasOptedOut(token, { persistenceType, diff --git a/src/__tests__/loader.js b/src/__tests__/loader.test.ts similarity index 77% rename from src/__tests__/loader.js rename to src/__tests__/loader.test.ts index 34e0e56c0..ae244f3e1 100644 --- a/src/__tests__/loader.js +++ b/src/__tests__/loader.test.ts @@ -7,17 +7,18 @@ import posthog from '../loader-module' import sinon from 'sinon' +import { window } from '../utils/globals' describe(`Module-based loader in Node env`, () => { beforeEach(() => { jest.spyOn(posthog, '_send_request').mockReturnValue() - jest.spyOn(window.console, 'log').mockImplementation() + jest.spyOn(window!.console, 'log').mockImplementation() }) it('should load and capture the pageview event', () => { const sandbox = sinon.createSandbox() let loaded = false - posthog._originalCapture = posthog.capture + const _originalCapture = posthog.capture posthog.capture = sandbox.spy() posthog.init(`test-token`, { debug: true, @@ -28,14 +29,13 @@ describe(`Module-based loader in Node env`, () => { }, }) - expect(posthog.capture.calledOnce).toBe(true) - const captureArgs = posthog.capture.args[0] + sinon.assert.calledOnce(posthog.capture as sinon.SinonSpy) + const captureArgs = (posthog.capture as sinon.SinonSpy).args[0] const event = captureArgs[0] expect(event).toBe('$pageview') expect(loaded).toBe(true) - posthog.capture = posthog._originalCapture - delete posthog._originalCapture + posthog.capture = _originalCapture }) it(`supports identify()`, () => { diff --git a/src/__tests__/page-view.ts b/src/__tests__/page-view.test.ts similarity index 100% rename from src/__tests__/page-view.ts rename to src/__tests__/page-view.test.ts diff --git a/src/__tests__/request-queue.js b/src/__tests__/request-queue.js deleted file mode 100644 index 8586b7446..000000000 --- a/src/__tests__/request-queue.js +++ /dev/null @@ -1,119 +0,0 @@ -import { RequestQueue } from '../request-queue' - -const EPOCH = 1_600_000_000 - -describe('RequestQueue', () => { - given('queue', () => new RequestQueue(given.handlePollRequest)) - given('handlePollRequest', () => jest.fn()) - - beforeEach(() => { - jest.useFakeTimers() - - jest.spyOn(given.queue, 'getTime').mockReturnValue(EPOCH) - jest.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - it('handles poll after enqueueing requests', () => { - given.queue.enqueue('/e', { event: 'foo', timestamp: EPOCH - 3000 }, { transport: 'XHR' }) - given.queue.enqueue('/identify', { event: '$identify', timestamp: EPOCH - 2000 }) - given.queue.enqueue('/e', { event: 'bar', timestamp: EPOCH - 1000 }) - given.queue.enqueue('/e', { event: 'zeta', timestamp: EPOCH }, { _batchKey: 'sessionRecording' }) - - given.queue.poll() - - expect(given.handlePollRequest).toHaveBeenCalledTimes(0) - - jest.runOnlyPendingTimers() - - expect(given.handlePollRequest).toHaveBeenCalledTimes(3) - expect(given.handlePollRequest).toHaveBeenCalledWith( - '/e', - [ - { event: 'foo', offset: 3000 }, - { event: 'bar', offset: 1000 }, - ], - { transport: 'XHR' } - ) - expect(given.handlePollRequest).toHaveBeenCalledWith( - '/identify', - [{ event: '$identify', offset: 2000 }], - undefined - ) - expect(given.handlePollRequest).toHaveBeenCalledWith('/e', [{ event: 'zeta', offset: 0 }], { - _batchKey: 'sessionRecording', - }) - }) - - it('clears polling flag after 4 empty iterations', () => { - given.queue.enqueue('/e', { event: 'foo', timestamp: EPOCH - 3000 }) - - for (let i = 0; i < 5; i++) { - given.queue.poll() - jest.runOnlyPendingTimers() - - expect(given.queue.isPolling).toEqual(true) - } - - given.queue.poll() - jest.runOnlyPendingTimers() - - expect(given.queue.isPolling).toEqual(false) - }) - - it('handles unload', () => { - given.queue.enqueue('/s', { recording_payload: 'example' }) - given.queue.enqueue('/e', { event: 'foo', timestamp: 1_610_000_000 }) - given.queue.enqueue('/identify', { event: '$identify', timestamp: 1_620_000_000 }) - given.queue.enqueue('/e', { event: 'bar', timestamp: 1_630_000_000 }) - - given.queue.unload() - - expect(given.handlePollRequest).toHaveBeenCalledTimes(3) - expect(given.handlePollRequest).toHaveBeenNthCalledWith( - 1, - '/e', - [ - { event: 'foo', timestamp: 1_610_000_000 }, - { event: 'bar', timestamp: 1_630_000_000 }, - ], - { transport: 'sendBeacon' } - ) - expect(given.handlePollRequest).toHaveBeenNthCalledWith(2, '/s', [{ recording_payload: 'example' }], { - transport: 'sendBeacon', - }) - expect(given.handlePollRequest).toHaveBeenNthCalledWith( - 3, - '/identify', - [{ event: '$identify', timestamp: 1_620_000_000 }], - { transport: 'sendBeacon' } - ) - }) - - it('handles unload with batchKeys', () => { - given.queue.enqueue('/e', { event: 'foo', timestamp: 1_610_000_000 }, { transport: 'XHR' }) - given.queue.enqueue('/identify', { event: '$identify', timestamp: 1_620_000_000 }) - given.queue.enqueue('/e', { event: 'bar', timestamp: 1_630_000_000 }) - given.queue.enqueue('/e', { event: 'zeta', timestamp: 1_640_000_000 }, { _batchKey: 'sessionRecording' }) - - given.queue.unload() - - expect(given.handlePollRequest).toHaveBeenCalledTimes(3) - expect(given.handlePollRequest).toHaveBeenCalledWith( - '/e', - [ - { event: 'foo', timestamp: 1_610_000_000 }, - { event: 'bar', timestamp: 1_630_000_000 }, - ], - { transport: 'sendBeacon' } - ) - expect(given.handlePollRequest).toHaveBeenCalledWith( - '/identify', - [{ event: '$identify', timestamp: 1_620_000_000 }], - { transport: 'sendBeacon' } - ) - expect(given.handlePollRequest).toHaveBeenCalledWith('/e', [{ event: 'zeta', timestamp: 1_640_000_000 }], { - _batchKey: 'sessionRecording', - transport: 'sendBeacon', - }) - }) -}) diff --git a/src/__tests__/request-queue.test.ts b/src/__tests__/request-queue.test.ts new file mode 100644 index 000000000..44a0b69e4 --- /dev/null +++ b/src/__tests__/request-queue.test.ts @@ -0,0 +1,128 @@ +import { RequestQueue } from '../request-queue' +import { CaptureOptions, Properties, XHROptions } from '../types' + +const EPOCH = 1_600_000_000 + +describe('RequestQueue', () => { + let handlePollRequest: (url: string, data: Properties, options?: XHROptions) => void + let queue: RequestQueue + + beforeEach(() => { + handlePollRequest = jest.fn() + queue = new RequestQueue(handlePollRequest) + jest.useFakeTimers() + + jest.spyOn(queue, 'getTime').mockReturnValue(EPOCH) + jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('handles poll after enqueueing requests', () => { + queue.enqueue('/e', { event: 'foo', timestamp: EPOCH - 3000 }, { transport: 'XHR' }) + queue.enqueue('/identify', { event: '$identify', timestamp: EPOCH - 2000 }, {}) + queue.enqueue('/e', { event: 'bar', timestamp: EPOCH - 1000 }, {}) + queue.enqueue('/e', { event: 'zeta', timestamp: EPOCH }, { + _batchKey: 'sessionRecording', + } as CaptureOptions as XHROptions) + + queue.poll() + + expect(handlePollRequest).toHaveBeenCalledTimes(0) + + jest.runOnlyPendingTimers() + + expect(handlePollRequest).toHaveBeenCalledTimes(3) + expect(jest.mocked(handlePollRequest).mock.calls).toEqual([ + [ + '/e', + [ + { event: 'foo', offset: 3000 }, + { event: 'bar', offset: 1000 }, + ], + { transport: 'XHR' }, + ], + ['/identify', [{ event: '$identify', offset: 2000 }], {}], + [ + '/e', + [{ event: 'zeta', offset: 0 }], + { + _batchKey: 'sessionRecording', + }, + ], + ]) + }) + + it('clears polling flag after 4 empty iterations', () => { + queue.enqueue('/e', { event: 'foo', timestamp: EPOCH - 3000 }, {}) + + for (let i = 0; i < 5; i++) { + queue.poll() + jest.runOnlyPendingTimers() + + expect(queue.isPolling).toEqual(true) + } + + queue.poll() + jest.runOnlyPendingTimers() + + expect(queue.isPolling).toEqual(false) + }) + + it('handles unload', () => { + queue.enqueue('/s', { recording_payload: 'example' }, {}) + queue.enqueue('/e', { event: 'foo', timestamp: 1_610_000_000 }, {}) + queue.enqueue('/identify', { event: '$identify', timestamp: 1_620_000_000 }, {}) + queue.enqueue('/e', { event: 'bar', timestamp: 1_630_000_000 }, {}) + + queue.unload() + + expect(handlePollRequest).toHaveBeenCalledTimes(3) + expect(handlePollRequest).toHaveBeenNthCalledWith( + 1, + '/e', + [ + { event: 'foo', timestamp: 1_610_000_000 }, + { event: 'bar', timestamp: 1_630_000_000 }, + ], + { transport: 'sendBeacon' } + ) + expect(handlePollRequest).toHaveBeenNthCalledWith(2, '/s', [{ recording_payload: 'example' }], { + transport: 'sendBeacon', + }) + expect(handlePollRequest).toHaveBeenNthCalledWith( + 3, + '/identify', + [{ event: '$identify', timestamp: 1_620_000_000 }], + { transport: 'sendBeacon' } + ) + }) + + it('handles unload with batchKeys', () => { + queue.enqueue('/e', { event: 'foo', timestamp: 1_610_000_000 }, { transport: 'XHR' }) + queue.enqueue('/identify', { event: '$identify', timestamp: 1_620_000_000 }, {}) + queue.enqueue('/e', { event: 'bar', timestamp: 1_630_000_000 }, {}) + queue.enqueue('/e', { event: 'zeta', timestamp: 1_640_000_000 }, { + _batchKey: 'sessionRecording', + } as CaptureOptions as XHROptions) + + queue.unload() + + expect(handlePollRequest).toHaveBeenCalledTimes(3) + expect(handlePollRequest).toHaveBeenCalledWith( + '/e', + [ + { event: 'foo', timestamp: 1_610_000_000 }, + { event: 'bar', timestamp: 1_630_000_000 }, + ], + { transport: 'sendBeacon' } + ) + expect(handlePollRequest).toHaveBeenCalledWith( + '/identify', + [{ event: '$identify', timestamp: 1_620_000_000 }], + { transport: 'sendBeacon' } + ) + expect(handlePollRequest).toHaveBeenCalledWith('/e', [{ event: 'zeta', timestamp: 1_640_000_000 }], { + _batchKey: 'sessionRecording', + transport: 'sendBeacon', + }) + }) +}) diff --git a/src/__tests__/retry-queue.js b/src/__tests__/retry-queue.test.ts similarity index 71% rename from src/__tests__/retry-queue.js rename to src/__tests__/retry-queue.test.ts index 32a8a22d6..83356ffb3 100644 --- a/src/__tests__/retry-queue.js +++ b/src/__tests__/retry-queue.test.ts @@ -4,35 +4,36 @@ import { pickNextRetryDelay, RetryQueue } from '../retry-queue' import * as SendRequest from '../send-request' import { RateLimiter } from '../rate-limiter' import { SESSION_RECORDING_BATCH_KEY } from '../extensions/replay/sessionrecording' +import { assignableWindow } from '../utils/globals' +import { CaptureOptions } from '../types' const EPOCH = 1_600_000_000 -const defaultRequestOptions = { +const defaultRequestOptions: CaptureOptions = { method: 'POST', transport: 'XHR', } describe('RetryQueue', () => { - given('rateLimiter', () => new RateLimiter()) - given('retryQueue', () => new RetryQueue(given.onXHRError, given.rateLimiter)) - given('onXHRError', () => jest.fn().mockImplementation(console.error)) - - given('xhrStatus', () => 418) + const onXHRError = jest.fn().mockImplementation(console.error) + const rateLimiter = new RateLimiter() + let retryQueue: RetryQueue const xhrMockClass = () => ({ open: jest.fn(), send: jest.fn(), setRequestHeader: jest.fn(), - status: given.xhrStatus, + status: 418, }) beforeEach(() => { - window.XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass) - window.navigator.sendBeacon = jest.fn() + retryQueue = new RetryQueue(onXHRError, rateLimiter) + assignableWindow.XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass) + assignableWindow.navigator.sendBeacon = jest.fn() jest.useFakeTimers() - jest.spyOn(given.retryQueue, 'getTime').mockReturnValue(EPOCH) - jest.spyOn(window.console, 'warn').mockImplementation() - given.rateLimiter.limits = {} + jest.spyOn(retryQueue, 'getTime').mockReturnValue(EPOCH) + jest.spyOn(assignableWindow.console, 'warn').mockImplementation() + rateLimiter.limits = {} }) const fastForwardTimeAndRunTimer = () => { @@ -41,22 +42,22 @@ describe('RetryQueue', () => { } const enqueueRequests = () => { - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: 'foo', timestamp: EPOCH - 3000 }, options: defaultRequestOptions, }) - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: 'bar', timestamp: EPOCH - 2000 }, options: defaultRequestOptions, }) - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: 'baz', timestamp: EPOCH - 1000 }, options: defaultRequestOptions, }) - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: 'fizz', timestamp: EPOCH }, options: defaultRequestOptions, @@ -66,9 +67,9 @@ describe('RetryQueue', () => { it('processes retry requests', () => { enqueueRequests() - expect(given.retryQueue.queue.length).toEqual(4) + expect(retryQueue.queue.length).toEqual(4) - expect(given.retryQueue.queue).toEqual([ + expect(retryQueue.queue).toEqual([ { requestData: { url: '/e', @@ -103,161 +104,161 @@ describe('RetryQueue', () => { }, ]) - expect(window.XMLHttpRequest).toHaveBeenCalledTimes(0) + expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(0) fastForwardTimeAndRunTimer() // clears queue - expect(given.retryQueue.queue.length).toEqual(0) + expect(retryQueue.queue.length).toEqual(0) - expect(window.XMLHttpRequest).toHaveBeenCalledTimes(4) - expect(given.onXHRError).toHaveBeenCalledTimes(0) + expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(4) + expect(onXHRError).toHaveBeenCalledTimes(0) }) it('does not process event retry requests when events are rate limited', () => { - given.rateLimiter.limits = { + rateLimiter.limits = { events: new Date().getTime() + 10_000, } - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: 'baz', timestamp: EPOCH - 1000 }, options: defaultRequestOptions, }) - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: 'baz', timestamp: EPOCH - 500 }, options: defaultRequestOptions, }) - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/s', data: { event: 'fizz', timestamp: EPOCH }, options: { ...defaultRequestOptions, _batchKey: SESSION_RECORDING_BATCH_KEY }, }) - expect(given.retryQueue.queue.length).toEqual(3) - expect(window.XMLHttpRequest).toHaveBeenCalledTimes(0) + expect(retryQueue.queue.length).toEqual(3) + expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(0) fastForwardTimeAndRunTimer() // clears queue - expect(given.retryQueue.queue.length).toEqual(0) - expect(window.XMLHttpRequest).toHaveBeenCalledTimes(1) - expect(given.onXHRError).toHaveBeenCalledTimes(0) + expect(retryQueue.queue.length).toEqual(0) + expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(1) + expect(onXHRError).toHaveBeenCalledTimes(0) }) it('does not process recording retry requests when they are rate limited', () => { - given.rateLimiter.limits = { + rateLimiter.limits = { [SESSION_RECORDING_BATCH_KEY]: new Date().getTime() + 10_000, } - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: 'baz', timestamp: EPOCH - 1000 }, options: defaultRequestOptions, }) - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: 'baz', timestamp: EPOCH - 500 }, options: defaultRequestOptions, }) - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/s', data: { event: 'fizz', timestamp: EPOCH }, options: { ...defaultRequestOptions, _batchKey: SESSION_RECORDING_BATCH_KEY }, }) - expect(given.retryQueue.queue.length).toEqual(3) - expect(window.XMLHttpRequest).toHaveBeenCalledTimes(0) + expect(retryQueue.queue.length).toEqual(3) + expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(0) fastForwardTimeAndRunTimer() // clears queue - expect(given.retryQueue.queue.length).toEqual(0) - expect(window.XMLHttpRequest).toHaveBeenCalledTimes(2) - expect(given.onXHRError).toHaveBeenCalledTimes(0) + expect(retryQueue.queue.length).toEqual(0) + expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(2) + expect(onXHRError).toHaveBeenCalledTimes(0) }) it('tries to send requests via beacon on unload', () => { enqueueRequests() - given.retryQueue.poll() - given.retryQueue.unload() + retryQueue.poll() + retryQueue.unload() - expect(given.retryQueue.queue.length).toEqual(0) - expect(window.navigator.sendBeacon).toHaveBeenCalledTimes(4) + expect(retryQueue.queue.length).toEqual(0) + expect(assignableWindow.navigator.sendBeacon).toHaveBeenCalledTimes(4) }) it('does not try to send requests via beacon on unload when rate limited', () => { - given.rateLimiter.limits = { + rateLimiter.limits = { events: new Date().getTime() + 10_000, } enqueueRequests() - given.retryQueue.unload() + retryQueue.unload() - expect(given.retryQueue.queue.length).toEqual(0) - expect(window.navigator.sendBeacon).toHaveBeenCalledTimes(0) + expect(retryQueue.queue.length).toEqual(0) + expect(assignableWindow.navigator.sendBeacon).toHaveBeenCalledTimes(0) }) it('when you flush the queue onXHRError is passed to xhr', () => { const xhrSpy = jest.spyOn(SendRequest, 'xhr') enqueueRequests() - given.retryQueue.flush() + retryQueue.flush() fastForwardTimeAndRunTimer() - expect(xhrSpy).toHaveBeenCalledWith(expect.objectContaining({ onXHRError: given.onXHRError })) + expect(xhrSpy).toHaveBeenCalledWith(expect.objectContaining({ onXHRError: onXHRError })) }) it('enqueues requests when offline and flushes immediately when online again', () => { - given.retryQueue.areWeOnline = false - expect(given.retryQueue.areWeOnline).toEqual(false) + retryQueue.areWeOnline = false + expect(retryQueue.areWeOnline).toEqual(false) enqueueRequests() fastForwardTimeAndRunTimer() // requests aren't attempted when we're offline - expect(window.XMLHttpRequest).toHaveBeenCalledTimes(0) + expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(0) // doesn't log that it is offline from the retry queue - expect(given.onXHRError).toHaveBeenCalledTimes(0) + expect(onXHRError).toHaveBeenCalledTimes(0) // queue stays the same - expect(given.retryQueue.queue.length).toEqual(4) + expect(retryQueue.queue.length).toEqual(4) - given.retryQueue._handleWeAreNowOnline() + retryQueue._handleWeAreNowOnline() - expect(given.retryQueue.areWeOnline).toEqual(true) - expect(given.retryQueue.queue.length).toEqual(0) + expect(retryQueue.areWeOnline).toEqual(true) + expect(retryQueue.queue.length).toEqual(0) - expect(window.XMLHttpRequest).toHaveBeenCalledTimes(4) + expect(assignableWindow.XMLHttpRequest).toHaveBeenCalledTimes(4) }) it('retries using an exponential backoff mechanism', () => { const fixedDate = new Date('2021-05-31T00:00:00') jest.spyOn(global.Date, 'now').mockImplementation(() => fixedDate.getTime()) - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: '1retry', timestamp: EPOCH }, options: defaultRequestOptions, retriesPerformedSoFar: 1, }) - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: '5retries', timestamp: EPOCH }, options: defaultRequestOptions, retriesPerformedSoFar: 5, }) - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: '9retries', timestamp: EPOCH }, options: defaultRequestOptions, retriesPerformedSoFar: 9, }) - expect(given.retryQueue.queue).toEqual([ + expect(retryQueue.queue).toEqual([ { requestData: { url: '/e', @@ -289,14 +290,14 @@ describe('RetryQueue', () => { }) it('does not enqueue a request after 10 retries', () => { - given.retryQueue.enqueue({ + retryQueue.enqueue({ url: '/e', data: { event: 'maxretries', timestamp: EPOCH }, options: defaultRequestOptions, retriesPerformedSoFar: 10, }) - expect(given.retryQueue.queue.length).toEqual(0) + expect(retryQueue.queue.length).toEqual(0) }) describe('backoff calculation', () => { diff --git a/src/__tests__/surveys.js b/src/__tests__/surveys.test.ts similarity index 59% rename from src/__tests__/surveys.js rename to src/__tests__/surveys.test.ts index 7fee65890..301c934a1 100644 --- a/src/__tests__/surveys.js +++ b/src/__tests__/surveys.test.ts @@ -1,138 +1,175 @@ +/// + import { PostHogSurveys } from '../posthog-surveys' -import { SurveyType, SurveyQuestionType } from '../posthog-surveys-types' +import { SurveyType, SurveyQuestionType, Survey } from '../posthog-surveys-types' import { PostHogPersistence } from '../posthog-persistence' +import { PostHog } from '../posthog-core' +import { DecideResponse, PostHogConfig, Properties } from '../types' +import { window } from '../utils/globals' describe('surveys', () => { - given('config', () => ({ - token: 'testtoken', - api_host: 'https://app.posthog.com', - persistence: 'memory', - })) - given('instance', () => ({ - config: given.config, - _prepare_callback: (callback) => callback, - persistence: new PostHogPersistence(given.config), - register: (props) => given.instance.persistence.register(props), - unregister: (key) => given.instance.persistence.unregister(key), - get_property: (key) => given.instance.persistence.props[key], - _send_request: jest.fn().mockImplementation((url, data, headers, callback) => callback(given.surveysResponse)), + let config: PostHogConfig + let instance: PostHog + let surveys: PostHogSurveys + let surveysResponse: { status?: number; surveys?: Survey[] } + const originalWindowLocation = window!.location + + const decideResponse = { featureFlags: { - _send_request: jest - .fn() - .mockImplementation((url, data, headers, callback) => callback(given.decideResponse)), - isFeatureEnabled: jest - .fn() - .mockImplementation((featureFlag) => given.decideResponse.featureFlags[featureFlag]), + 'linked-flag-key': true, + 'survey-targeting-flag-key': true, + 'linked-flag-key2': true, + 'survey-targeting-flag-key2': false, }, - })) + } as unknown as DecideResponse - given('surveys', () => new PostHogSurveys(given.instance)) - - afterEach(() => { - given.instance.persistence.clear() - }) - - const firstSurveys = [ + const firstSurveys: Survey[] = [ { name: 'first survey', description: 'first survey description', type: SurveyType.Popover, questions: [{ type: SurveyQuestionType.Open, question: 'what is a bokoblin?' }], - }, + } as unknown as Survey, ] - const secondSurveys = [ + const secondSurveys: Survey[] = [ { name: 'first survey', description: 'first survey description', type: SurveyType.Popover, questions: [{ type: SurveyQuestionType.Open, question: 'what is a bokoblin?' }], - }, + } as unknown as Survey, { name: 'second survey', description: 'second survey description', type: SurveyType.Popover, questions: [{ type: SurveyQuestionType.Open, question: 'what is a moblin?' }], - }, + } as unknown as Survey, ] - given('surveysResponse', () => ({ surveys: firstSurveys })) + beforeEach(() => { + surveysResponse = { surveys: firstSurveys } + + config = { + token: 'testtoken', + api_host: 'https://app.posthog.com', + persistence: 'memory', + } as unknown as PostHogConfig + + instance = { + config: config, + _prepare_callback: (callback: any) => callback, + persistence: new PostHogPersistence(config), + register: (props: Properties) => instance.persistence?.register(props), + unregister: (key: string) => instance.persistence?.unregister(key), + get_property: (key: string) => instance.persistence?.props[key], + _send_request: jest.fn().mockImplementation((_url, _data, _headers, callback) => callback(surveysResponse)), + featureFlags: { + _send_request: jest + .fn() + .mockImplementation((_url, _data, _headers, callback) => callback(decideResponse)), + isFeatureEnabled: jest + .fn() + .mockImplementation((featureFlag) => decideResponse.featureFlags[featureFlag]), + }, + } as unknown as PostHog + + surveys = new PostHogSurveys(instance) + + Object.defineProperty(window, 'location', { + configurable: true, + enumerable: true, + writable: true, + // eslint-disable-next-line compat/compat + value: new URL('https://example.com'), + }) + }) + + afterEach(() => { + instance.persistence?.clear() + + Object.defineProperty(window, 'location', { + configurable: true, + enumerable: true, + value: originalWindowLocation, + }) + }) it('getSurveys gets a list of surveys if not present already', () => { - given.surveys.getSurveys((data) => { + surveys.getSurveys((data) => { expect(data).toEqual(firstSurveys) }) - expect(given.instance._send_request).toHaveBeenCalledWith( + expect(instance._send_request).toHaveBeenCalledWith( 'https://app.posthog.com/api/surveys/?token=testtoken', {}, { method: 'GET' }, expect.any(Function) ) - expect(given.instance._send_request).toHaveBeenCalledTimes(1) - expect(given.instance.persistence.props.$surveys).toEqual(firstSurveys) + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance.persistence?.props.$surveys).toEqual(firstSurveys) - given('surveysResponse', () => ({ surveys: secondSurveys })) - given.surveys.getSurveys((data) => { + surveysResponse = { surveys: secondSurveys } + surveys.getSurveys((data) => { expect(data).toEqual(firstSurveys) }) // request again, shouldn't call _send_request again, so 1 total call instead of 2 - expect(given.instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request).toHaveBeenCalledTimes(1) }) it('getSurveys force reloads when called with true', () => { - given.surveys.getSurveys((data) => { + surveys.getSurveys((data) => { expect(data).toEqual(firstSurveys) }) - expect(given.instance._send_request).toHaveBeenCalledWith( + expect(instance._send_request).toHaveBeenCalledWith( 'https://app.posthog.com/api/surveys/?token=testtoken', {}, { method: 'GET' }, expect.any(Function) ) - expect(given.instance._send_request).toHaveBeenCalledTimes(1) - expect(given.instance.persistence.props.$surveys).toEqual(firstSurveys) + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance.persistence?.props.$surveys).toEqual(firstSurveys) - given('surveysResponse', () => ({ surveys: secondSurveys })) + surveysResponse = { surveys: secondSurveys } - given.surveys.getSurveys((data) => { + surveys.getSurveys((data) => { expect(data).toEqual(secondSurveys) }, true) - expect(given.instance.persistence.props.$surveys).toEqual(secondSurveys) - expect(given.instance._send_request).toHaveBeenCalledTimes(2) + expect(instance.persistence?.props.$surveys).toEqual(secondSurveys) + expect(instance._send_request).toHaveBeenCalledTimes(2) }) it('getSurveys returns empty array if surveys are undefined', () => { - given('surveysResponse', () => ({ status: 0 })) - given.surveys.getSurveys((data) => { + surveysResponse = { status: 0 } + surveys.getSurveys((data) => { expect(data).toEqual([]) }) }) describe('getActiveMatchingSurveys', () => { - const draftSurvey = { + const draftSurvey: Survey = { name: 'draft survey', description: 'draft survey description', type: SurveyType.Popover, questions: [{ type: SurveyQuestionType.Open, question: 'what is a draft survey?' }], start_date: null, - } - const activeSurvey = { + } as unknown as Survey + const activeSurvey: Survey = { name: 'active survey', description: 'active survey description', type: SurveyType.Popover, questions: [{ type: SurveyQuestionType.Open, question: 'what is a active survey?' }], start_date: new Date().toISOString(), end_date: null, - } - const completedSurvey = { + } as unknown as Survey + const completedSurvey: Survey = { name: 'completed survey', description: 'completed survey description', type: SurveyType.Popover, questions: [{ type: SurveyQuestionType.Open, question: 'what is a completed survey?' }], start_date: new Date('09/10/2022').toISOString(), end_date: new Date('10/10/2022').toISOString(), - } - const surveyWithUrl = { + } as unknown as Survey + const surveyWithUrl: Survey = { name: 'survey with url', description: 'survey with url description', type: SurveyType.Popover, @@ -140,8 +177,8 @@ describe('surveys', () => { conditions: { url: 'posthog.com' }, start_date: new Date().toISOString(), end_date: null, - } - const surveyWithRegexUrl = { + } as unknown as Survey + const surveyWithRegexUrl: Survey = { name: 'survey with regex url', description: 'survey with regex url description', type: SurveyType.Popover, @@ -149,8 +186,8 @@ describe('surveys', () => { conditions: { url: 'regex-url', urlMatchType: 'regex' }, start_date: new Date().toISOString(), end_date: null, - } - const surveyWithParamRegexUrl = { + } as unknown as Survey + const surveyWithParamRegexUrl: Survey = { name: 'survey with param regex url', description: 'survey with param regex url description', type: SurveyType.Popover, @@ -158,8 +195,8 @@ describe('surveys', () => { conditions: { url: '(\\?|\\&)(name.*)\\=([^&]+)', urlMatchType: 'regex' }, start_date: new Date().toISOString(), end_date: null, - } - const surveyWithWildcardSubdomainUrl = { + } as unknown as Survey + const surveyWithWildcardSubdomainUrl: Survey = { name: 'survey with wildcard subdomain url', description: 'survey with wildcard subdomain url description', type: SurveyType.Popover, @@ -167,8 +204,8 @@ describe('surveys', () => { conditions: { url: '(.*.)?subdomain.com', urlMatchType: 'regex' }, start_date: new Date().toISOString(), end_date: null, - } - const surveyWithWildcardRouteUrl = { + } as unknown as Survey + const surveyWithWildcardRouteUrl: Survey = { name: 'survey with wildcard route url', description: 'survey with wildcard route url description', type: SurveyType.Popover, @@ -176,8 +213,8 @@ describe('surveys', () => { conditions: { url: 'wildcard.com/(.*.)', urlMatchType: 'regex' }, start_date: new Date().toISOString(), end_date: null, - } - const surveyWithExactUrlMatch = { + } as unknown as Survey + const surveyWithExactUrlMatch: Survey = { name: 'survey with wildcard route url', description: 'survey with wildcard route url description', type: SurveyType.Popover, @@ -185,8 +222,8 @@ describe('surveys', () => { conditions: { url: 'https://example.com/exact', urlMatchType: 'exact' }, start_date: new Date().toISOString(), end_date: null, - } - const surveyWithSelector = { + } as unknown as Survey + const surveyWithSelector: Survey = { name: 'survey with selector', description: 'survey with selector description', type: SurveyType.Popover, @@ -194,8 +231,8 @@ describe('surveys', () => { conditions: { selector: '.test-selector' }, start_date: new Date().toISOString(), end_date: null, - } - const surveyWithUrlAndSelector = { + } as unknown as Survey + const surveyWithUrlAndSelector: Survey = { name: 'survey with url and selector', description: 'survey with url and selector description', type: SurveyType.Popover, @@ -203,8 +240,8 @@ describe('surveys', () => { conditions: { url: 'posthogapp.com', selector: '#foo' }, start_date: new Date().toISOString(), end_date: null, - } - const surveyWithFlags = { + } as unknown as Survey + const surveyWithFlags: Survey = { name: 'survey with flags', description: 'survey with flags description', type: SurveyType.Popover, @@ -213,8 +250,8 @@ describe('surveys', () => { targeting_flag_key: 'survey-targeting-flag-key', start_date: new Date().toISOString(), end_date: null, - } - const surveyWithUnmatchedFlags = { + } as unknown as Survey + const surveyWithUnmatchedFlags: Survey = { name: 'survey with flags2', description: 'survey with flags description', type: SurveyType.Popover, @@ -223,8 +260,8 @@ describe('surveys', () => { targeting_flag_key: 'survey-targeting-flag-key2', start_date: new Date().toISOString(), end_date: null, - } - const surveyWithEverything = { + } as unknown as Survey + const surveyWithEverything: Survey = { name: 'survey with everything', description: 'survey with everything description', type: SurveyType.Popover, @@ -234,48 +271,51 @@ describe('surveys', () => { conditions: { url: 'posthogapp.com', selector: '.test-selector' }, linked_flag_key: 'linked-flag-key', targeting_flag_key: 'survey-targeting-flag-key', - } + } as unknown as Survey it('returns surveys that are active', () => { - given('surveysResponse', () => ({ surveys: [draftSurvey, activeSurvey, completedSurvey] })) + surveysResponse = { surveys: [draftSurvey, activeSurvey, completedSurvey] } - given.surveys.getActiveMatchingSurveys((data) => { + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([activeSurvey]) }) }) it('returns surveys based on url and selector matching', () => { - given('surveysResponse', () => ({ + surveysResponse = { surveys: [surveyWithUrl, surveyWithSelector, surveyWithUrlAndSelector], - })) - const originalWindowLocation = window.location - delete window.location + } // eslint-disable-next-line compat/compat - window.location = new URL('https://posthog.com') - given.surveys.getActiveMatchingSurveys((data) => { + window!.location = new URL('https://posthog.com') as unknown as Location + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([surveyWithUrl]) }) - window.location = originalWindowLocation + window!.location = originalWindowLocation document.body.appendChild(document.createElement('div')).className = 'test-selector' - given.surveys.getActiveMatchingSurveys((data) => { + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([surveyWithSelector]) }) - document.body.removeChild(document.querySelector('.test-selector')) + const testSelectorEl = document!.querySelector('.test-selector') + if (testSelectorEl) { + document.body.removeChild(testSelectorEl) + } // eslint-disable-next-line compat/compat - window.location = new URL('https://posthogapp.com') + window!.location = new URL('https://posthogapp.com') as unknown as Location document.body.appendChild(document.createElement('div')).id = 'foo' - given.surveys.getActiveMatchingSurveys((data) => { + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([surveyWithUrlAndSelector]) }) - window.location = originalWindowLocation - document.body.removeChild(document.querySelector('#foo')) + const child = document.querySelector('#foo') + if (child) { + document.body.removeChild(child) + } }) it('returns surveys based on url with urlMatchType settings', () => { - given('surveysResponse', () => ({ + surveysResponse = { surveys: [ surveyWithRegexUrl, surveyWithParamRegexUrl, @@ -283,78 +323,67 @@ describe('surveys', () => { surveyWithWildcardSubdomainUrl, surveyWithExactUrlMatch, ], - })) + } - const originalWindowLocation = window.location - delete window.location + const originalWindowLocation = window!.location // eslint-disable-next-line compat/compat - window.location = new URL('https://regex-url.com/test') - given.surveys.getActiveMatchingSurveys((data) => { + window!.location = new URL('https://regex-url.com/test') as unknown as Location + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([surveyWithRegexUrl]) }) - window.location = originalWindowLocation + window!.location = originalWindowLocation // eslint-disable-next-line compat/compat - window.location = new URL('https://example.com?name=something') - given.surveys.getActiveMatchingSurveys((data) => { + window!.location = new URL('https://example.com?name=something') as unknown as Location + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([surveyWithParamRegexUrl]) }) - window.location = originalWindowLocation + window!.location = originalWindowLocation // eslint-disable-next-line compat/compat - window.location = new URL('https://app.subdomain.com') - given.surveys.getActiveMatchingSurveys((data) => { + window!.location = new URL('https://app.subdomain.com') as unknown as Location + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([surveyWithWildcardSubdomainUrl]) }) - window.location = originalWindowLocation + window!.location = originalWindowLocation // eslint-disable-next-line compat/compat - window.location = new URL('https://wildcard.com/something/other') - given.surveys.getActiveMatchingSurveys((data) => { + window!.location = new URL('https://wildcard.com/something/other') as unknown as Location + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([surveyWithWildcardRouteUrl]) }) - window.location = originalWindowLocation + window!.location = originalWindowLocation // eslint-disable-next-line compat/compat - window.location = new URL('https://example.com/exact') - given.surveys.getActiveMatchingSurveys((data) => { + window!.location = new URL('https://example.com/exact') as unknown as Location + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([surveyWithExactUrlMatch]) }) - window.location = originalWindowLocation + window!.location = originalWindowLocation }) - given('decideResponse', () => ({ - featureFlags: { - 'linked-flag-key': true, - 'survey-targeting-flag-key': true, - 'linked-flag-key2': true, - 'survey-targeting-flag-key2': false, - }, - })) - it('returns surveys that match linked and targeting feature flags', () => { - given('surveysResponse', () => ({ surveys: [activeSurvey, surveyWithFlags, surveyWithEverything] })) - given.surveys.getActiveMatchingSurveys((data) => { + surveysResponse = { surveys: [activeSurvey, surveyWithFlags, surveyWithEverything] } + surveys.getActiveMatchingSurveys((data) => { // active survey is returned because it has no flags aka there are no restrictions on flag enabled for it expect(data).toEqual([activeSurvey, surveyWithFlags]) }) }) it('does not return surveys that have flag keys but no matching flags', () => { - given('surveysResponse', () => ({ surveys: [surveyWithFlags, surveyWithUnmatchedFlags] })) - given.surveys.getActiveMatchingSurveys((data) => { + surveysResponse = { surveys: [surveyWithFlags, surveyWithUnmatchedFlags] } + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([surveyWithFlags]) }) }) it('returns surveys that inclusively matches any of the above', () => { - window.location.delete // eslint-disable-next-line compat/compat - window.location = new URL('https://posthogapp.com') + window!.location = new URL('https://posthogapp.com') as unknown as Location document.body.appendChild(document.createElement('div')).className = 'test-selector' - given('surveysResponse', () => ({ surveys: [activeSurvey, surveyWithSelector, surveyWithEverything] })) + surveysResponse = { surveys: [activeSurvey, surveyWithSelector, surveyWithEverything] } // activeSurvey returns because there are no restrictions on conditions or flags on it - given.surveys.getActiveMatchingSurveys((data) => { + surveys.getActiveMatchingSurveys((data) => { expect(data).toEqual([activeSurvey, surveyWithSelector, surveyWithEverything]) }) }) diff --git a/src/__tests__/test-uuid.js b/src/__tests__/test-uuid.test.ts similarity index 100% rename from src/__tests__/test-uuid.js rename to src/__tests__/test-uuid.test.ts diff --git a/src/__tests__/utils.js b/src/__tests__/utils.js deleted file mode 100644 index 94c8c4224..000000000 --- a/src/__tests__/utils.js +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Test that basic SDK usage (init, capture, etc) does not - * blow up in non-browser (node.js) envs. These are not - * tests of server-side capturing functionality (which is - * currently not supported in the browser lib). - */ - -import { - _copyAndTruncateStrings, - _isBlockedUA, - DEFAULT_BLOCKED_UA_STRS, - loadScript, - isCrossDomainCookie, -} from '../utils' -import { _info } from '../utils/event-utils' - -function userAgentFor(botString) { - const randOne = (Math.random() + 1).toString(36).substring(7) - const randTwo = (Math.random() + 1).toString(36).substring(7) - return `Mozilla/5.0 (compatible; ${botString}/${randOne}; +http://a.com/bot/${randTwo})` -} - -describe('_.copyAndTruncateStrings', () => { - given('subject', () => _copyAndTruncateStrings(given.target, given.maxStringLength)) - - given('target', () => ({ - key: 'value', - [5]: 'looongvalue', - nested: { - keeeey: ['vaaaaaalue', 1, 99999999999.4], - }, - })) - given('maxStringLength', () => 5) - - it('truncates objects', () => { - expect(given.subject).toEqual({ - key: 'value', - [5]: 'looon', - nested: { - keeeey: ['vaaaa', 1, 99999999999.4], - }, - }) - }) - - it('makes a copy', () => { - const copy = given.subject - - given.target.foo = 'bar' - - expect(copy).not.toEqual(given.target) - }) - - it('does not truncate when passed null', () => { - given('maxStringLength', () => null) - - expect(given.subject).toEqual(given.subject) - }) - - it('handles recursive objects', () => { - given('target', () => { - const object = { key: 'vaaaaalue', values: ['fooobar'] } - object.values.push(object) - object.ref = object - return object - }) - - expect(given.subject).toEqual({ key: 'vaaaa', values: ['fooob', undefined] }) - }) - - it('does not truncate the apm raw performance property', () => { - const original = { - $performance_raw: 'longer_than_the_maximum', - } - given('target', () => original) - - expect(given.subject).toEqual(original) - }) - - it('handles frozen objects', () => { - const original = Object.freeze({ key: 'vaaaaalue' }) - given('target', () => original) - - expect(given.subject).toEqual({ key: 'vaaaa' }) - }) -}) - -describe('_.info', () => { - given('subject', () => _info) - - it('deviceType', () => { - const deviceTypes = { - // iPad - 'Mozilla/5.0 (iPad; CPU OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5355d Safari/8536.25': - 'Tablet', - // Samsung tablet - 'Mozilla/5.0 (Linux; Android 7.1.1; SM-T555 Build/NMF26X; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.96 Safari/537.36': - 'Tablet', - // Windows Chrome - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36': - 'Desktop', - // Mac Safari - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 Safari/7046A194A': - 'Desktop', - // iPhone - 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.4 Mobile/15E148 Safari/604.1': - 'Mobile', - // LG Android - 'Mozilla/5.0 (Linux; Android 6.0; LG-H631 Build/MRA58K) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/38.0.2125.102 Mobile Safari/537.36': - 'Mobile', - } - - for (const [userAgent, deviceType] of Object.entries(deviceTypes)) { - expect(given.subject.deviceType(userAgent)).toEqual(deviceType) - } - }) - - it('osVersion', () => { - const osVersions = { - // Windows Phone - 'Mozilla/5.0 (Mobile; Windows Phone 8.1; Android 4.0; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; Lumia 635; BOOST) like iPhone OS 7_0_3 Mac OS X AppleWebKit/537 (KHTML, like Gecko) Mobile Safari/537': - { os_name: 'Windows Phone', os_version: '' }, - 'Mozilla/5.0 (Windows NT 6.3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/38.0.2125.122 Safari/537.36': { - os_name: 'Windows', - os_version: '6.3', - }, - 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_2 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) CriOS/44.0.2403.67 Mobile/12D508 Safari/600.1.4': - { - os_name: 'iOS', - os_version: '8.2.0', - }, - 'Mozilla/5.0 (iPad; CPU OS 8_4 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H143 Safari/600.1.4': - { - os_name: 'iOS', - os_version: '8.4.0', - }, - 'Mozilla/5.0 (Linux; Android 4.4.2; Lenovo A7600-F Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.133 Safari/537.36': - { - os_name: 'Android', - os_version: '4.4.2', - }, - 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9300; es) AppleWebKit/534.8+ (KHTML, like Gecko) Version/6.0.0.480 Mobile Safari/534.8+': - { - os_name: 'BlackBerry', - os_version: '', - }, - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.130 Safari/537.36': - { - os_name: 'Mac OS X', - os_version: '10.9.5', - }, - 'Opera/9.80 (Linux armv7l; InettvBrowser/2.2 (00014A;SonyDTV140;0001;0001) KDL40W600B; CC/MEX) Presto/2.12.407 Version/12.50': - { - os_name: 'Linux', - os_version: '', - }, - 'Mozilla/5.0 (X11; CrOS armv7l 6680.81.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36': - { - os_name: 'Chrome OS', - os_version: '', - }, - } - - for (const [userAgent, osInfo] of Object.entries(osVersions)) { - expect(given.subject.os(userAgent)).toEqual(osInfo) - } - }) - - it('properties', () => { - const properties = given.subject.properties() - - expect(properties['$lib']).toEqual('web') - expect(properties['$device_type']).toEqual('Desktop') - }) -}) - -describe('loadScript', () => { - beforeEach(() => { - document.getElementsByTagName('html')[0].innerHTML = '' - }) - - it('should insert the given script before the one already on the page', () => { - document.body.appendChild(document.createElement('script')) - const callback = jest.fn() - loadScript('https://fake_url', callback) - const scripts = document.getElementsByTagName('script') - const new_script = scripts[0] - - expect(scripts.length).toBe(2) - expect(new_script.type).toBe('text/javascript') - expect(new_script.src).toBe('https://fake_url/') - new_script.onload('test') - expect(callback).toHaveBeenCalledWith(undefined, 'test') - }) - - it("should add the script to the page when there aren't any preexisting scripts on the page", () => { - const callback = jest.fn() - loadScript('https://fake_url', callback) - const scripts = document.getElementsByTagName('script') - const new_script = scripts[0] - - expect(scripts.length).toBe(1) - expect(new_script.type).toBe('text/javascript') - expect(new_script.src).toBe('https://fake_url/') - }) - - it('should respond with an error if one happens', () => { - const callback = jest.fn() - loadScript('https://fake_url', callback) - const scripts = document.getElementsByTagName('script') - const new_script = scripts[0] - - new_script.onerror('uh-oh') - expect(callback).toHaveBeenCalledWith('uh-oh') - }) - - describe('user agent blocking', () => { - it.each(DEFAULT_BLOCKED_UA_STRS.concat('testington'))( - 'blocks a bot based on the user agent %s', - (botString) => { - const randomisedUserAgent = userAgentFor(botString) - - expect(_isBlockedUA(randomisedUserAgent, ['testington'])).toBe(true) - } - ) - - it('should block googlebot desktop', () => { - expect( - _isBlockedUA( - 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36', - [] - ) - ).toBe(true) - }) - - it('should block openai bot', () => { - expect( - _isBlockedUA( - 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.0; +https://openai.com/gptbot)', - [] - ) - ).toBe(true) - }) - }) - - describe('check for cross domain cookies', () => { - it.each([ - [false, 'https://test.herokuapp.com'], - [false, 'test.herokuapp.com'], - [false, 'herokuapp.com'], - [false, undefined], - // ensure it isn't matching herokuapp anywhere in the domain - [true, 'https://test.herokuapp.com.impersonator.io'], - [true, 'mysite-herokuapp.com'], - [true, 'https://bbc.co.uk'], - [true, 'bbc.co.uk'], - [true, 'www.bbc.co.uk'], - ])('should return %s when hostname is %s', (expectedResult, hostname) => { - expect(isCrossDomainCookie({ hostname })).toEqual(expectedResult) - }) - }) -}) diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts new file mode 100644 index 000000000..a3646b11d --- /dev/null +++ b/src/__tests__/utils.test.ts @@ -0,0 +1,279 @@ +/// + +/* + * Test that basic SDK usage (init, capture, etc) does not + * blow up in non-browser (node.js) envs. These are not + * tests of server-side capturing functionality (which is + * currently not supported in the browser lib). + */ + +import { + _copyAndTruncateStrings, + _isBlockedUA, + DEFAULT_BLOCKED_UA_STRS, + loadScript, + isCrossDomainCookie, + _base64Encode, +} from '../utils' +import { _info } from '../utils/event-utils' +import { document } from '../utils/globals' + +function userAgentFor(botString: string) { + const randOne = (Math.random() + 1).toString(36).substring(7) + const randTwo = (Math.random() + 1).toString(36).substring(7) + return `Mozilla/5.0 (compatible; ${botString}/${randOne}; +http://a.com/bot/${randTwo})` +} + +describe('utils', () => { + describe('_.copyAndTruncateStrings', () => { + let target: Record + + beforeEach(() => { + target = { + key: 'value', + [5]: 'looongvalue', + nested: { + keeeey: ['vaaaaaalue', 1, 99999999999.4], + }, + } + }) + + it('truncates objects', () => { + expect(_copyAndTruncateStrings(target, 5)).toEqual({ + key: 'value', + [5]: 'looon', + nested: { + keeeey: ['vaaaa', 1, 99999999999.4], + }, + }) + }) + + it('makes a copy', () => { + const copy = _copyAndTruncateStrings(target, 5) + + target.foo = 'bar' + + expect(copy).not.toEqual(target) + }) + + it('does not truncate when passed null', () => { + expect(_copyAndTruncateStrings(target, null)).toEqual(target) + }) + + it('handles recursive objects', () => { + const recursiveObject: Record = { key: 'vaaaaalue', values: ['fooobar'] } + recursiveObject.values.push(recursiveObject) + recursiveObject.ref = recursiveObject + + expect(_copyAndTruncateStrings(recursiveObject, 5)).toEqual({ key: 'vaaaa', values: ['fooob', undefined] }) + }) + + it('handles frozen objects', () => { + const original = Object.freeze({ key: 'vaaaaalue' }) + expect(_copyAndTruncateStrings(original, 5)).toEqual({ key: 'vaaaa' }) + }) + }) + + describe('_.info', () => { + it('deviceType', () => { + const deviceTypes = { + // iPad + 'Mozilla/5.0 (iPad; CPU OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5355d Safari/8536.25': + 'Tablet', + // Samsung tablet + 'Mozilla/5.0 (Linux; Android 7.1.1; SM-T555 Build/NMF26X; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.96 Safari/537.36': + 'Tablet', + // Windows Chrome + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36': + 'Desktop', + // Mac Safari + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 Safari/7046A194A': + 'Desktop', + // iPhone + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.4 Mobile/15E148 Safari/604.1': + 'Mobile', + // LG Android + 'Mozilla/5.0 (Linux; Android 6.0; LG-H631 Build/MRA58K) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/38.0.2125.102 Mobile Safari/537.36': + 'Mobile', + } + + for (const [userAgent, deviceType] of Object.entries(deviceTypes)) { + expect(_info.deviceType(userAgent)).toEqual(deviceType) + } + }) + + it('osVersion', () => { + const osVersions = { + // Windows Phone + 'Mozilla/5.0 (Mobile; Windows Phone 8.1; Android 4.0; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; Lumia 635; BOOST) like iPhone OS 7_0_3 Mac OS X AppleWebKit/537 (KHTML, like Gecko) Mobile Safari/537': + { os_name: 'Windows Phone', os_version: '' }, + 'Mozilla/5.0 (Windows NT 6.3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/38.0.2125.122 Safari/537.36': + { + os_name: 'Windows', + os_version: '6.3', + }, + 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_2 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) CriOS/44.0.2403.67 Mobile/12D508 Safari/600.1.4': + { + os_name: 'iOS', + os_version: '8.2.0', + }, + 'Mozilla/5.0 (iPad; CPU OS 8_4 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H143 Safari/600.1.4': + { + os_name: 'iOS', + os_version: '8.4.0', + }, + 'Mozilla/5.0 (Linux; Android 4.4.2; Lenovo A7600-F Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.133 Safari/537.36': + { + os_name: 'Android', + os_version: '4.4.2', + }, + 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9300; es) AppleWebKit/534.8+ (KHTML, like Gecko) Version/6.0.0.480 Mobile Safari/534.8+': + { + os_name: 'BlackBerry', + os_version: '', + }, + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.130 Safari/537.36': + { + os_name: 'Mac OS X', + os_version: '10.9.5', + }, + 'Opera/9.80 (Linux armv7l; InettvBrowser/2.2 (00014A;SonyDTV140;0001;0001) KDL40W600B; CC/MEX) Presto/2.12.407 Version/12.50': + { + os_name: 'Linux', + os_version: '', + }, + 'Mozilla/5.0 (X11; CrOS armv7l 6680.81.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36': + { + os_name: 'Chrome OS', + os_version: '', + }, + } + + for (const [userAgent, osInfo] of Object.entries(osVersions)) { + expect(_info.os(userAgent)).toEqual(osInfo) + } + }) + + it('properties', () => { + const properties = _info.properties() + + expect(properties['$lib']).toEqual('web') + expect(properties['$device_type']).toEqual('Desktop') + }) + }) + + describe('loadScript', () => { + beforeEach(() => { + document!.getElementsByTagName('html')![0].innerHTML = '' + }) + + it('should insert the given script before the one already on the page', () => { + document!.body.appendChild(document!.createElement('script')) + const callback = jest.fn() + loadScript('https://fake_url', callback) + const scripts = document!.getElementsByTagName('script') + const new_script = scripts[0] + + expect(scripts.length).toBe(2) + expect(new_script.type).toBe('text/javascript') + expect(new_script.src).toBe('https://fake_url/') + const event = new Event('test') + new_script.onload!(event) + expect(callback).toHaveBeenCalledWith(undefined, event) + }) + + it("should add the script to the page when there aren't any preexisting scripts on the page", () => { + const callback = jest.fn() + loadScript('https://fake_url', callback) + const scripts = document!.getElementsByTagName('script') + + expect(scripts?.length).toBe(1) + expect(scripts![0].type).toBe('text/javascript') + expect(scripts![0].src).toBe('https://fake_url/') + }) + + it('should respond with an error if one happens', () => { + const callback = jest.fn() + loadScript('https://fake_url', callback) + const scripts = document!.getElementsByTagName('script') + const new_script = scripts[0] + + new_script.onerror!('uh-oh') + expect(callback).toHaveBeenCalledWith('uh-oh') + }) + + describe('user agent blocking', () => { + it.each(DEFAULT_BLOCKED_UA_STRS.concat('testington'))( + 'blocks a bot based on the user agent %s', + (botString) => { + const randomisedUserAgent = userAgentFor(botString) + + expect(_isBlockedUA(randomisedUserAgent, ['testington'])).toBe(true) + } + ) + + it('should block googlebot desktop', () => { + expect( + _isBlockedUA( + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36', + [] + ) + ).toBe(true) + }) + + it('should block openai bot', () => { + expect( + _isBlockedUA( + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.0; +https://openai.com/gptbot)', + [] + ) + ).toBe(true) + }) + }) + + describe('check for cross domain cookies', () => { + it.each([ + [false, 'https://test.herokuapp.com'], + [false, 'test.herokuapp.com'], + [false, 'herokuapp.com'], + [false, undefined], + // ensure it isn't matching herokuapp anywhere in the domain + [true, 'https://test.herokuapp.com.impersonator.io'], + [true, 'mysite-herokuapp.com'], + [true, 'https://bbc.co.uk'], + [true, 'bbc.co.uk'], + [true, 'www.bbc.co.uk'], + ])('should return %s when hostname is %s', (expectedResult, hostname) => { + expect(isCrossDomainCookie({ hostname } as unknown as Location)).toEqual(expectedResult) + }) + }) + }) + + describe('base64Encode', () => { + it('should return null when input is null', () => { + expect(_base64Encode(null)).toBe(null) + }) + + it('should return undefined when input is undefined', () => { + expect(_base64Encode(undefined)).toBe(undefined) + }) + + it('should return base64 encoded string when input is a string', () => { + const input = 'Hello, World!' + const expectedOutput = 'SGVsbG8sIFdvcmxkIQ==' // Base64 encoded string of 'Hello, World!' + expect(_base64Encode(input)).toBe(expectedOutput) + }) + + it('should handle special characters correctly', () => { + const input = '✓ à la mode' + const expectedOutput = '4pyTIMOgIGxhIG1vZGU=' // Base64 encoded string of '✓ à la mode' + expect(_base64Encode(input)).toBe(expectedOutput) + }) + + it('should handle empty string correctly', () => { + const input = '' + const expectedOutput = '' // Base64 encoded string of an empty string is an empty string + expect(_base64Encode(input)).toBe(expectedOutput) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index e09365bfa..b198ed119 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3698,6 +3698,18 @@ dependencies: "@types/node" "*" +"@types/sinon@^17.0.1": + version "17.0.1" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.1.tgz#f17816577ee61d462cb7bfcea6ff64fb05063256" + integrity sha512-Q2Go6TJetYn5Za1+RJA1Aik61Oa2FS8SuJ0juIqUuJ5dZR4wvhKfmSdIqWtQ3P6gljKWjW0/R7FZkA4oXVL6OA== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "8.1.5" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" + integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== + "@types/sinonjs__fake-timers@8.1.1": version "8.1.1" resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz#b49c2c70150141a15e0fa7e79cf1f92a72934ce3" From 4ee20ea1073cc3e216262325b92a928b1e57024c Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Mon, 20 Nov 2023 08:52:45 +0000 Subject: [PATCH 3/8] chore: deflake a test (#904) --- cypress/e2e/capture.cy.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/capture.cy.js b/cypress/e2e/capture.cy.js index f5411cfc0..1087803fe 100644 --- a/cypress/e2e/capture.cy.js +++ b/cypress/e2e/capture.cy.js @@ -235,7 +235,8 @@ describe('Event capture', () => { it('captures $snapshot events', () => { start() - + // de-flake the test + cy.wait(100) cy.phCaptures().should('include', '$snapshot') }) From 8823f9ef3756cbccf6d0454d0e3f35caec18c0b6 Mon Sep 17 00:00:00 2001 From: Robbie Date: Mon, 20 Nov 2023 13:15:18 +0000 Subject: [PATCH 4/8] Move blocked UAs to own file (#905) --- src/__tests__/utils.test.ts | 10 ++---- src/posthog-core.ts | 2 +- src/utils/blocked-uas.ts | 60 ++++++++++++++++++++++++++++++++++++ src/utils/index.ts | 61 ------------------------------------- 4 files changed, 63 insertions(+), 70 deletions(-) create mode 100644 src/utils/blocked-uas.ts diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index a3646b11d..9648eaf8a 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -7,16 +7,10 @@ * currently not supported in the browser lib). */ -import { - _copyAndTruncateStrings, - _isBlockedUA, - DEFAULT_BLOCKED_UA_STRS, - loadScript, - isCrossDomainCookie, - _base64Encode, -} from '../utils' +import { _copyAndTruncateStrings, loadScript, isCrossDomainCookie, _base64Encode } from '../utils' import { _info } from '../utils/event-utils' import { document } from '../utils/globals' +import { _isBlockedUA, DEFAULT_BLOCKED_UA_STRS } from '../utils/blocked-uas' function userAgentFor(botString: string) { const randOne = (Math.random() + 1).toString(36).substring(7) diff --git a/src/posthog-core.ts b/src/posthog-core.ts index cbc1e0fc1..0b4551c85 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -4,7 +4,6 @@ import { _each, _eachArray, _extend, - _isBlockedUA, _register_event, _safewrap_class, isCrossDomainCookie, @@ -58,6 +57,7 @@ import { _info } from './utils/event-utils' import { logger } from './utils/logger' import { document, userAgent } from './utils/globals' import { SessionPropsManager } from './session-props' +import { _isBlockedUA } from './utils/blocked-uas' /* SIMPLE STYLE GUIDE: diff --git a/src/utils/blocked-uas.ts b/src/utils/blocked-uas.ts new file mode 100644 index 000000000..fe82e22c1 --- /dev/null +++ b/src/utils/blocked-uas.ts @@ -0,0 +1,60 @@ +export const DEFAULT_BLOCKED_UA_STRS = [ + 'ahrefsbot', + 'applebot', + 'baiduspider', + 'bingbot', + 'bingpreview', + 'bot.htm', + 'bot.php', + 'crawler', + 'duckduckbot', + 'facebookexternal', + 'facebookcatalog', + 'gptbot', + 'hubspot', + 'linkedinbot', + 'mj12bot', + 'petalbot', + 'pinterest', + 'prerender', + 'rogerbot', + 'screaming frog', + 'semrushbot', + 'sitebulb', + 'twitterbot', + 'yahoo! slurp', + 'yandexbot', + + // a whole bunch of goog-specific crawlers + // https://developers.google.com/search/docs/advanced/crawling/overview-google-crawlers + 'adsbot-google', + 'apis-google', + 'duplexweb-google', + 'feedfetcher-google', + 'google favicon', + 'google web preview', + 'google-read-aloud', + 'googlebot', + 'googleweblight', + 'mediapartners-google', + 'storebot-google', +] + +// _.isBlockedUA() +// This is to block various web spiders from executing our JS and +// sending false capturing data +export const _isBlockedUA = function (ua: string, customBlockedUserAgents: string[]): boolean { + if (!ua) { + return false + } + const uaLower = ua.toLowerCase() + return DEFAULT_BLOCKED_UA_STRS.concat(customBlockedUserAgents || []).some((blockedUA) => { + const blockedUaLower = blockedUA.toLowerCase() + if (uaLower.includes) { + return uaLower.includes(blockedUaLower) + } else { + // IE 11 :/ + return uaLower.indexOf(blockedUaLower) !== -1 + } + }) +} diff --git a/src/utils/index.ts b/src/utils/index.ts index eff109934..7c987e413 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -363,67 +363,6 @@ export const _utf8Encode = function (string: string): string { return utftext } -export const DEFAULT_BLOCKED_UA_STRS = [ - 'ahrefsbot', - 'applebot', - 'baiduspider', - 'bingbot', - 'bingpreview', - 'bot.htm', - 'bot.php', - 'crawler', - 'duckduckbot', - 'facebookexternal', - 'facebookcatalog', - 'gptbot', - 'hubspot', - 'linkedinbot', - 'mj12bot', - 'petalbot', - 'pinterest', - 'prerender', - 'rogerbot', - 'screaming frog', - 'semrushbot', - 'sitebulb', - 'twitterbot', - 'yahoo! slurp', - 'yandexbot', - - // a whole bunch of goog-specific crawlers - // https://developers.google.com/search/docs/advanced/crawling/overview-google-crawlers - 'adsbot-google', - 'apis-google', - 'duplexweb-google', - 'feedfetcher-google', - 'google favicon', - 'google web preview', - 'google-read-aloud', - 'googlebot', - 'googleweblight', - 'mediapartners-google', - 'storebot-google', -] - -// _.isBlockedUA() -// This is to block various web spiders from executing our JS and -// sending false capturing data -export const _isBlockedUA = function (ua: string, customBlockedUserAgents: string[]): boolean { - if (!ua) { - return false - } - const uaLower = ua.toLowerCase() - return DEFAULT_BLOCKED_UA_STRS.concat(customBlockedUserAgents || []).some((blockedUA) => { - const blockedUaLower = blockedUA.toLowerCase() - if (uaLower.includes) { - return uaLower.includes(blockedUaLower) - } else { - // IE 11 :/ - return uaLower.indexOf(blockedUaLower) !== -1 - } - }) -} - export const _register_event = (function () { // written by Dean Edwards, 2005 // with input from Tino Zijdel - crisp@xs4all.nl From 1d8eac68c54c76f651a1c02603543059c2116bb4 Mon Sep 17 00:00:00 2001 From: Tiina Turban Date: Mon, 20 Nov 2023 19:37:06 +0100 Subject: [PATCH 5/8] feat: Create elements chain string as we store it (#823) * feat: Create elements chain string as we store it * Rename back-end Element interface to be PHElement so it doesn't conflict when run in the browser * Add tests for populated and unpopulated arrays * Change all tests that use $elements to use $elements_chain instead * Esacape already escaped double quotes per https://github.com/PostHog/posthog-js/security/code-scanning/16 * Remove `Object.entries` and `Object.fromEntries` as they are not supported in IE11 * Fix tests I broke * `startsWith` -> `indexOf === 0` for IE11 * Hide change behind flag returned in decide response * Align parsing of decide response * Update tests --------- Co-authored-by: David Murphy Co-authored-by: Dave Murphy --- src/__tests__/autocapture-utils.test.ts | 16 ++++ src/__tests__/autocapture.test.ts | 23 ++++++ src/__tests__/posthog-core.js | 7 ++ src/autocapture-utils.ts | 102 +++++++++++++++++++++++- src/autocapture.ts | 11 ++- src/posthog-core.ts | 6 ++ src/types.ts | 1 + 7 files changed, 160 insertions(+), 6 deletions(-) diff --git a/src/__tests__/autocapture-utils.test.ts b/src/__tests__/autocapture-utils.test.ts index 162c7dbf2..456c24570 100644 --- a/src/__tests__/autocapture-utils.test.ts +++ b/src/__tests__/autocapture-utils.test.ts @@ -11,6 +11,7 @@ import { isAngularStyleAttr, getNestedSpanText, getDirectAndNestedSpanText, + getElementsChainString, } from '../autocapture-utils' import { document } from '../utils/globals' import { makeMouseEvent } from './autocapture.test' @@ -381,4 +382,19 @@ describe(`Autocapture utility functions`, () => { expect(getNestedSpanText(parent)).toBe('test test2') }) }) + + describe('getElementsChainString', () => { + it('should return an empty string with no elements', () => { + const elementChain = getElementsChainString([]) + + expect(elementChain).toEqual('') + }) + it('should process elements correctly', () => { + const elementChain = getElementsChainString([ + { tag_name: 'div', nth_child: 1, nth_of_type: 2, $el_text: 'text' }, + ]) + + expect(elementChain).toEqual('div:text="text"nth-child="1"nth-of-type="2"') + }) + }) }) diff --git a/src/__tests__/autocapture.test.ts b/src/__tests__/autocapture.test.ts index 07b21e818..7a83db600 100644 --- a/src/__tests__/autocapture.test.ts +++ b/src/__tests__/autocapture.test.ts @@ -1075,6 +1075,29 @@ describe('Autocapture system', () => { expect(props1['$elements'][0]).not.toHaveProperty('$el_text') }) + + it('returns elementsChain instead of elements when set', () => { + const elTarget = document.createElement('a') + elTarget.setAttribute('href', 'http://test.com') + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + + const e = { + target: elTarget, + type: 'click', + } + + const newLib = { + ...lib, + elementsChainAsString: true, + } + + autocapture._captureEvent(e, newLib) + const props1 = getCapturedProps(newLib.capture) + + expect(props1['$elements_chain']).toBeDefined() + expect(props1['$elements']).toBeUndefined() + }) }) describe('_addDomEventHandlers', () => { diff --git a/src/__tests__/posthog-core.js b/src/__tests__/posthog-core.js index 73ef340ee..99f977506 100644 --- a/src/__tests__/posthog-core.js +++ b/src/__tests__/posthog-core.js @@ -276,6 +276,13 @@ describe('posthog core', () => { expect(given.lib.analyticsDefaultEndpoint).toEqual('/i/v0/e/') }) + + it('enables elementsChainAsString if given', () => { + given('decideResponse', () => ({ elementsChainAsString: true })) + given.subject() + + expect(given.lib.elementsChainAsString).toBe(true) + }) }) describe('_calculate_event_properties()', () => { diff --git a/src/autocapture-utils.ts b/src/autocapture-utils.ts index 5f2d652cd..5e3072e5b 100644 --- a/src/autocapture-utils.ts +++ b/src/autocapture-utils.ts @@ -3,10 +3,11 @@ * @param {Element} el - element to get the className of * @returns {string} the element's class */ -import { AutocaptureConfig } from 'types' -import { _each, _includes, _trim } from './utils' -import { _isNull, _isString, _isUndefined } from './utils/type-utils' +import { AutocaptureConfig, Properties } from 'types' +import { _each, _entries, _includes, _trim } from './utils' + +import { _isArray, _isNull, _isString, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' import { window } from './utils/globals' @@ -346,3 +347,98 @@ export function getNestedSpanText(target: Element): string { } return text } + +/* +Back in the day storing events in Postgres we use Elements for autocapture events. +Now we're using elements_chain. We used to do this parsing/processing during ingestion. +This code is just copied over from ingestion, but we should optimize it +to create elements_chain string directly. +*/ +export function getElementsChainString(elements: Properties[]): string { + return elementsToString(extractElements(elements)) +} + +// This interface is called 'Element' in plugin-scaffold https://github.com/PostHog/plugin-scaffold/blob/b07d3b879796ecc7e22deb71bf627694ba05386b/src/types.ts#L200 +// However 'Element' is a DOM Element when run in the browser, so we have to rename it +interface PHElement { + text?: string + tag_name?: string + href?: string + attr_id?: string + attr_class?: string[] + nth_child?: number + nth_of_type?: number + attributes?: Record + event_id?: number + order?: number + group_id?: number +} + +function escapeQuotes(input: string): string { + return input.replace(/"|\\"/g, '\\"') +} + +function elementsToString(elements: PHElement[]): string { + const ret = elements.map((element) => { + let el_string = '' + if (element.tag_name) { + el_string += element.tag_name + } + if (element.attr_class) { + element.attr_class.sort() + for (const single_class of element.attr_class) { + el_string += `.${single_class.replace(/"/g, '')}` + } + } + const attributes: Record = { + ...(element.text ? { text: element.text } : {}), + 'nth-child': element.nth_child ?? 0, + 'nth-of-type': element.nth_of_type ?? 0, + ...(element.href ? { href: element.href } : {}), + ...(element.attr_id ? { attr_id: element.attr_id } : {}), + ...element.attributes, + } + const sortedAttributes: Record = {} + _entries(attributes) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach( + ([key, value]) => (sortedAttributes[escapeQuotes(key.toString())] = escapeQuotes(value.toString())) + ) + el_string += ':' + el_string += _entries(attributes) + .map(([key, value]) => `${key}="${value}"`) + .join('') + return el_string + }) + return ret.join(';') +} + +function extractElements(elements: Properties[]): PHElement[] { + return elements.map((el) => { + const response = { + text: el['$el_text']?.slice(0, 400), + tag_name: el['tag_name'], + href: el['attr__href']?.slice(0, 2048), + attr_class: extractAttrClass(el), + attr_id: el['attr__id'], + nth_child: el['nth_child'], + nth_of_type: el['nth_of_type'], + attributes: {} as { [id: string]: any }, + } + _entries(el) + .filter(([key]) => key.indexOf('attr__') === 0) + .forEach(([key, value]) => (response.attributes[key] = value)) + return response + }) +} + +function extractAttrClass(el: Properties): PHElement['attr_class'] { + const attr_class = el['attr__class'] + if (!attr_class) { + return undefined + } else if (_isArray(attr_class)) { + return attr_class + } else { + return attr_class.split(' ') + } +} diff --git a/src/autocapture.ts b/src/autocapture.ts index 86faa9be1..448a46654 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -13,6 +13,7 @@ import { isAngularStyleAttr, isDocumentFragment, getDirectAndNestedSpanText, + getElementsChainString, } from './autocapture-utils' import RageClick from './extensions/rageclick' import { AutocaptureConfig, AutoCaptureCustomProperty, DecideResponse, Properties } from './types' @@ -255,9 +256,13 @@ const autocapture = { const props = _extend( this._getDefaultProperties(e.type), - { - $elements: elementsJson, - }, + instance.elementsChainAsString + ? { + $elements_chain: getElementsChainString(elementsJson), + } + : { + $elements: elementsJson, + }, elementsJson[0]?.['$el_text'] ? { $el_text: elementsJson[0]?.['$el_text'] } : {}, this._getCustomProperties(targetElementList), autocaptureAugmentProperties diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 0b4551c85..e48d9cc13 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -287,6 +287,7 @@ export class PostHog { __autocapture: boolean | AutocaptureConfig | undefined decideEndpointWasHit: boolean analyticsDefaultEndpoint: string + elementsChainAsString: boolean SentryIntegration: typeof SentryIntegration segmentIntegration: () => any @@ -310,6 +311,7 @@ export class PostHog { this.__autocapture = undefined this._jsc = function () {} as JSC this.analyticsDefaultEndpoint = '/e/' + this.elementsChainAsString = false this.featureFlags = new PostHogFeatureFlags(this) this.toolbar = new Toolbar(this) @@ -536,6 +538,10 @@ export class PostHog { if (response.analytics?.endpoint) { this.analyticsDefaultEndpoint = response.analytics.endpoint } + + if (response.elementsChainAsString) { + this.elementsChainAsString = response.elementsChainAsString + } } _loaded(): void { diff --git a/src/types.ts b/src/types.ts index a413967a7..85a0651cc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -239,6 +239,7 @@ export interface DecideResponse { analytics?: { endpoint?: string } + elementsChainAsString?: boolean // this is currently in development and may have breaking changes without a major version bump autocaptureExceptions?: | boolean From 9f6730bc5eaa428147698aca9187d8d014f72c93 Mon Sep 17 00:00:00 2001 From: davemurphysf Date: Mon, 20 Nov 2023 18:37:59 +0000 Subject: [PATCH 6/8] chore: Bump version to 1.92.0 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab106683a..1434a5eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 1.92.0 - 2023-11-20 + +- feat: Create elements chain string as we store it (#823) +- Move blocked UAs to own file (#905) +- chore: deflake a test (#904) +- chore: convert more tests to TS (#903) +- latest cypress action version (#900) + ## 1.91.1 - 2023-11-15 - fix(surveys): button text field fix (#899) diff --git a/package.json b/package.json index 6694c8c71..4d35188ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.91.1", + "version": "1.92.0", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com", From 5b9e04bad677d57c5d0895770e0c79894ed22abe Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Tue, 21 Nov 2023 17:33:16 +0000 Subject: [PATCH 7/8] feat: payload capture - move timing into copied plugin (#902) As discussed https://posthog.slack.com/archives/C03PB072FMJ/p1700077249746539 first draft (while looking after poorly #4) of removing our own timings plugin in favour of (a copy of) the rrweb plugin --- .../extensions/replay/config.test.ts | 153 ++++++++++- .../extensions/replay/web-performance.test.ts | 161 ----------- src/__tests__/posthog-core.js | 5 + src/decide.ts | 1 - src/extensions/replay/config.ts | 108 +++++++- src/extensions/replay/sessionrecording.ts | 27 +- src/extensions/replay/web-performance.ts | 260 ------------------ src/loader-recorder-v2.ts | 178 +++++++----- src/posthog-core.ts | 19 +- src/types.ts | 40 ++- src/utils/event-utils.ts | 6 +- src/utils/request-utils.ts | 17 ++ src/utils/type-utils.ts | 9 +- 13 files changed, 454 insertions(+), 530 deletions(-) delete mode 100644 src/__tests__/extensions/replay/web-performance.test.ts delete mode 100644 src/extensions/replay/web-performance.ts diff --git a/src/__tests__/extensions/replay/config.test.ts b/src/__tests__/extensions/replay/config.test.ts index 77ced9b8e..fad3dc856 100644 --- a/src/__tests__/extensions/replay/config.test.ts +++ b/src/__tests__/extensions/replay/config.test.ts @@ -31,29 +31,35 @@ describe('config', () => { it('should remove the Authorization header from requests even if no other config is set', () => { const networkOptions = buildNetworkRequestOptions(defaultConfig(), {}) const cleaned = networkOptions.maskRequestFn!({ - url: 'something', + name: 'something', requestHeaders: { Authorization: 'Bearer 123', 'content-type': 'application/json', }, }) - expect(cleaned?.requestHeaders).toEqual({ - 'content-type': 'application/json', + expect(cleaned).toEqual({ + name: 'something', + requestHeaders: { + 'content-type': 'application/json', + }, }) }) it('should cope with no headers when even if no other config is set', () => { const networkOptions = buildNetworkRequestOptions(defaultConfig(), {}) const cleaned = networkOptions.maskRequestFn!({ - url: 'something', + name: 'something', + requestHeaders: undefined, + }) + expect(cleaned).toEqual({ + name: 'something', requestHeaders: undefined, }) - expect(cleaned?.requestHeaders).toBeUndefined() }) it('should remove the Authorization header from requests even when a mask request fn is set', () => { const posthogConfig = defaultConfig() - posthogConfig.session_recording.maskNetworkRequestFn = (data) => { + posthogConfig.session_recording.maskCapturedNetworkRequestFn = (data) => { return { ...data, requestHeaders: { @@ -65,28 +71,151 @@ describe('config', () => { const networkOptions = buildNetworkRequestOptions(posthogConfig, {}) const cleaned = networkOptions.maskRequestFn!({ - url: 'something', + name: 'something', requestHeaders: { Authorization: 'Bearer 123', 'content-type': 'application/json', }, }) - expect(cleaned?.requestHeaders).toEqual({ - 'content-type': 'edited', + expect(cleaned).toEqual({ + name: 'something', + requestHeaders: { + 'content-type': 'edited', + }, + }) + }) + + it('uses the deprecated mask fn when set', () => { + const posthogConfig = defaultConfig() + posthogConfig.session_recording.maskNetworkRequestFn = (data) => { + return { + ...data, + url: 'edited', // deprecated fn only edits the url + } + } + const networkOptions = buildNetworkRequestOptions(posthogConfig, {}) + + const cleaned = networkOptions.maskRequestFn!({ + name: 'something', + requestHeaders: { + Authorization: 'Bearer 123', + 'content-type': 'application/json', + }, + }) + expect(cleaned).toEqual({ + name: 'edited', + requestHeaders: { + 'content-type': 'application/json', + }, }) }) it('case insensitively removes headers on the deny list', () => { const networkOptions = buildNetworkRequestOptions(defaultConfig(), {}) const cleaned = networkOptions.maskRequestFn!({ - url: 'something', + name: 'something', requestHeaders: { AuThOrIzAtIoN: 'Bearer 123', 'content-type': 'application/json', }, }) - expect(cleaned?.requestHeaders).toEqual({ - 'content-type': 'application/json', + expect(cleaned).toEqual({ + name: 'something', + requestHeaders: { + 'content-type': 'application/json', + }, + }) + }) + + it.each([ + [ + { + name: 'https://app.posthog.com/api/feature_flag/', + }, + { + name: 'https://app.posthog.com/api/feature_flag/', + }, + ], + [ + { + name: 'https://app.posthog.com/s/', + }, + undefined, + ], + [ + { + name: 'https://app.posthog.com/e/', + }, + undefined, + ], + [ + { + name: 'https://app.posthog.com/i/vo/e/', + }, + undefined, + ], + ])('ignores ingestion paths', (capturedRequest, expected) => { + const networkOptions = buildNetworkRequestOptions(defaultConfig(), {}) + const x = networkOptions.maskRequestFn!(capturedRequest) + expect(x).toEqual(expected) + }) + + it('redacts large request body', () => { + const networkOptions = buildNetworkRequestOptions(defaultConfig(), {}) + const cleaned = networkOptions.maskRequestFn!({ + name: 'something', + requestHeaders: { + 'content-type': 'application/json', + 'content-length': '1000001', + }, + requestBody: 'something very large', + }) + expect(cleaned).toEqual({ + name: 'something', + requestHeaders: { + 'content-type': 'application/json', + 'content-length': '1000001', + }, + requestBody: 'Request body too large to record', + }) + }) + + it('redacts large response body', () => { + const networkOptions = buildNetworkRequestOptions(defaultConfig(), {}) + const cleaned = networkOptions.maskRequestFn!({ + name: 'something', + responseHeaders: { + 'content-type': 'application/json', + 'content-length': '1000001', + }, + responseBody: 'something very large', + }) + expect(cleaned).toEqual({ + name: 'something', + responseHeaders: { + 'content-type': 'application/json', + 'content-length': '1000001', + }, + responseBody: 'Response body too large to record', + }) + }) + + it('cannot redact when there is no content length header', () => { + const networkOptions = buildNetworkRequestOptions(defaultConfig(), {}) + const largeString = 'a'.repeat(1000001) + const cleaned = networkOptions.maskRequestFn!({ + name: 'something', + requestHeaders: { + 'content-type': 'application/json', + }, + requestBody: largeString, + }) + expect(cleaned).toEqual({ + name: 'something', + requestHeaders: { + 'content-type': 'application/json', + }, + requestBody: largeString, }) }) }) diff --git a/src/__tests__/extensions/replay/web-performance.test.ts b/src/__tests__/extensions/replay/web-performance.test.ts deleted file mode 100644 index 630e354fb..000000000 --- a/src/__tests__/extensions/replay/web-performance.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/// - -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable compat/compat */ - -import { WebPerformanceObserver } from '../../../extensions/replay/web-performance' -import { PostHog } from '../../../posthog-core' -import { NetworkRequest, PostHogConfig } from '../../../types' - -const createMockPerformanceEntry = (overrides: Partial = {}): PerformanceEntry => { - const entry = { - name: 'http://example.com/api/1', - duration: 100, - entryType: 'fetch', - startTime: Date.now() - 1000, - ...overrides, - toJSON: () => { - return { - ...entry, - toJSON: undefined, - } - }, - } - - return entry -} - -describe('WebPerformance', () => { - let webPerformance: WebPerformanceObserver - let mockPostHogInstance: any - const mockConfig: Partial = { - api_host: 'https://app.posthog.com', - session_recording: { - maskNetworkRequestFn: (networkRequest: NetworkRequest) => networkRequest, - }, - } - - beforeEach(() => { - mockPostHogInstance = { - config: mockConfig, - sessionRecording: { - onRRwebEmit: jest.fn(), - }, - } - webPerformance = new WebPerformanceObserver(mockPostHogInstance as PostHog) - jest.clearAllMocks() - jest.useFakeTimers() - jest.setSystemTime(new Date('2023-01-01')) - performance.now = jest.fn(() => Date.now()) - }) - - describe('when the browser does not support performance observer', () => { - const OriginalPerformanceObserver = window.PerformanceObserver - - beforeAll(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window.PerformanceObserver = undefined - }) - - afterAll(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window.PerformanceObserver = OriginalPerformanceObserver - }) - - it('should not start the observer', () => { - const webPerformance = new WebPerformanceObserver(mockPostHogInstance as PostHog) - webPerformance.startObserving() - expect(webPerformance.isObserving()).toBe(false) - }) - }) - - describe('_capturePerformanceEvent', () => { - it('should capture and save a standard perf event', () => { - webPerformance._capturePerformanceEvent( - createMockPerformanceEntry({ - name: 'http://example.com/api/1', - }) - ) - - expect(mockPostHogInstance.sessionRecording.onRRwebEmit).toHaveBeenCalledTimes(1) - expect(mockPostHogInstance.sessionRecording.onRRwebEmit).toHaveBeenCalledWith({ - data: { - payload: { - '0': 'fetch', - '1': 0, - '2': 'http://example.com/api/1', - '3': 1672531199000, - '39': 100, - '40': 1672531199000, - }, - plugin: 'posthog/network@1', - }, - timestamp: 1672531199000, - type: 6, - }) - }) - - it('should ignore posthog network events', () => { - webPerformance._capturePerformanceEvent( - createMockPerformanceEntry({ - name: 'https://app.posthog.com/s/', - }) - ) - - expect(mockPostHogInstance.sessionRecording.onRRwebEmit).toHaveBeenCalledTimes(0) - }) - - it('should ignore events with maskNetworkRequestFn returning null', () => { - mockConfig.session_recording!.maskNetworkRequestFn = (event) => { - if (event.url.includes('ignore')) { - return null - } - return event - } - ;[ - 'https://example.com/ignore/', - 'https://example.com/capture/', - 'https://ignore.example.com/capture/', - ].forEach((url) => { - webPerformance._capturePerformanceEvent( - createMockPerformanceEntry({ - name: url, - }) - ) - }) - expect(mockPostHogInstance.sessionRecording.onRRwebEmit).toHaveBeenCalledTimes(1) - }) - - it('should allow modifying of the content via maskNetworkRequestFn', () => { - mockConfig.session_recording!.maskNetworkRequestFn = (event) => { - event.url = event.url.replace('example', 'replaced') - return event - } - - webPerformance._capturePerformanceEvent( - createMockPerformanceEntry({ - name: 'https://example.com/capture/', - }) - ) - - expect(mockPostHogInstance.sessionRecording.onRRwebEmit).toHaveBeenCalledTimes(1) - expect(mockPostHogInstance.sessionRecording.onRRwebEmit).toHaveBeenCalledWith({ - data: { - payload: { - '0': 'fetch', - '1': 0, - '2': 'https://replaced.com/capture/', - '3': 1672531199000, - '39': 100, - '40': 1672531199000, - }, - plugin: 'posthog/network@1', - }, - timestamp: 1672531199000, - type: 6, - }) - }) - }) -}) diff --git a/src/__tests__/posthog-core.js b/src/__tests__/posthog-core.js index 99f977506..f9262e0ab 100644 --- a/src/__tests__/posthog-core.js +++ b/src/__tests__/posthog-core.js @@ -1124,4 +1124,9 @@ describe('posthog core', () => { ) }) }) + + test('deprecated web performance observer still exposes _forceAllowLocalhost', () => { + expect(given.lib.webPerformance._forceAllowLocalhost).toBe(false) + expect(() => given.lib.webPerformance._forceAllowLocalhost).not.toThrow() + }) }) diff --git a/src/decide.ts b/src/decide.ts index e8b05ae21..f26e93712 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -62,7 +62,6 @@ export class Decide { this.instance.toolbar.afterDecideResponse(response) this.instance.sessionRecording?.afterDecideResponse(response) autocapture.afterDecideResponse(response, this.instance) - this.instance.webPerformance?.afterDecideResponse(response) this.instance._afterDecideResponse(response) if (!this.instance.config.advanced_disable_feature_flags_on_first_load) { diff --git a/src/extensions/replay/config.ts b/src/extensions/replay/config.ts index d0e573130..554a028dd 100644 --- a/src/extensions/replay/config.ts +++ b/src/extensions/replay/config.ts @@ -1,5 +1,7 @@ -import { NetworkRecordOptions, NetworkRequest, PostHogConfig } from '../../types' +import { CapturedNetworkRequest, NetworkRecordOptions, PostHogConfig, Body } from '../../types' import { _isFunction } from '../../utils/type-utils' +import { convertToURL } from '../../utils/request-utils' +import { logger } from '../../utils/logger' export const defaultNetworkOptions: NetworkRecordOptions = { initiatorTypes: [ @@ -25,10 +27,21 @@ export const defaultNetworkOptions: NetworkRecordOptions = { 'video', 'xmlhttprequest', ], - maskRequestFn: (data: NetworkRequest) => data, + maskRequestFn: (data: CapturedNetworkRequest) => data, recordHeaders: false, recordBody: false, recordInitialRequests: false, + recordPerformance: false, + performanceEntryTypeToObserve: [ + // 'event', // This is too noisy as it covers all browser events + 'first-input', + // 'mark', // Mark is used too liberally. We would need to filter for specific marks + // 'measure', // Measure is used too liberally. We would need to filter for specific measures + 'navigation', + 'paint', + 'resource', + ], + payloadSizeLimitBytes: 1000000, } const HEADER_DENYLIST = [ @@ -47,37 +60,112 @@ const HEADER_DENYLIST = [ 'x-xsrf-token', ] -const removeAuthorizationHeader = (data: NetworkRequest): NetworkRequest => { +// we always remove headers on the deny list because we never want to capture this sensitive data +const removeAuthorizationHeader = (data: CapturedNetworkRequest): CapturedNetworkRequest => { Object.keys(data.requestHeaders ?? {}).forEach((header) => { if (HEADER_DENYLIST.includes(header.toLowerCase())) delete data.requestHeaders?.[header] }) return data } +const POSTHOG_PATHS_TO_IGNORE = ['/s/', '/e/', '/i/vo/e/'] +// want to ignore posthog paths when capturing requests, or we can get trapped in a loop +// because calls to PostHog would be reported using a call to PostHog which would be reported.... +const ignorePostHogPaths = (data: CapturedNetworkRequest): CapturedNetworkRequest | undefined => { + const url = convertToURL(data.name) + if (url && url.pathname && POSTHOG_PATHS_TO_IGNORE.includes(url.pathname)) { + return undefined + } + return data +} + +function redactPayload( + payload: Body, + headers: Record | undefined, + limit: number, + description: string +): Body { + const requestContentLength = headers?.['content-length'] + // in the interests of bundle size and the complexity of estimating payload size + // we only check the content-length header if it's present + // this might mean we can't always limit the payload, but that's better than + // having lots of code shipped to every browser that will rarely run + if (requestContentLength && parseInt(requestContentLength) > limit) { + return `${description} body too large to record` + } + return payload +} + +// people can have arbitrarily large payloads on their site, but we don't want to ingest them +const limitPayloadSize = ( + options: NetworkRecordOptions +): ((data: CapturedNetworkRequest | undefined) => CapturedNetworkRequest | undefined) => { + // the smallest of 1MB or the specified limit if there is one + const limit = Math.min(1000000, options.payloadSizeLimitBytes ?? 1000000) + + return (data) => { + if (data?.requestBody) { + data.requestBody = redactPayload(data.requestBody, data.requestHeaders, limit, 'Request') + } + + if (data?.responseBody) { + data.responseBody = redactPayload(data.responseBody, data.responseHeaders, limit, 'Response') + } + + return data + } +} + /** * whether a maskRequestFn is provided or not, - * we ensure that we remove the Authorization header from requests + * we ensure that we remove the denied header from requests * we _never_ want to record that header by accident * if someone complains then we'll add an opt-in to let them override it */ export const buildNetworkRequestOptions = ( instanceConfig: PostHogConfig, - remoteNetworkOptions: Pick + remoteNetworkOptions: Pick ): NetworkRecordOptions => { const config = instanceConfig.session_recording as NetworkRecordOptions // client can always disable despite remote options const canRecordHeaders = config.recordHeaders === false ? false : remoteNetworkOptions.recordHeaders const canRecordBody = config.recordBody === false ? false : remoteNetworkOptions.recordBody + const canRecordPerformance = config.recordPerformance === false ? false : remoteNetworkOptions.recordPerformance + + const payloadLimiter = limitPayloadSize(config) + + const enforcedCleaningFn: NetworkRecordOptions['maskRequestFn'] = (d: CapturedNetworkRequest) => + payloadLimiter(ignorePostHogPaths(removeAuthorizationHeader(d))) + + const hasDeprecatedMaskFunction = _isFunction(instanceConfig.session_recording.maskNetworkRequestFn) + + if (hasDeprecatedMaskFunction && _isFunction(instanceConfig.session_recording.maskCapturedNetworkRequestFn)) { + logger.warn( + 'Both `maskNetworkRequestFn` and `maskCapturedNetworkRequestFn` are defined. `maskNetworkRequestFn` will be ignored.' + ) + } + + if (hasDeprecatedMaskFunction) { + instanceConfig.session_recording.maskCapturedNetworkRequestFn = (data: CapturedNetworkRequest) => { + const cleanedURL = instanceConfig.session_recording.maskNetworkRequestFn!({ url: data.name }) + return { + ...data, + name: cleanedURL?.url, + } as CapturedNetworkRequest + } + } - config.maskRequestFn = _isFunction(instanceConfig.session_recording.maskNetworkRequestFn) + config.maskRequestFn = _isFunction(instanceConfig.session_recording.maskCapturedNetworkRequestFn) ? (data) => { - const cleanedRequest = removeAuthorizationHeader(data) - return instanceConfig.session_recording.maskNetworkRequestFn?.(cleanedRequest) ?? undefined + const cleanedRequest = enforcedCleaningFn(data) + return cleanedRequest + ? instanceConfig.session_recording.maskCapturedNetworkRequestFn?.(cleanedRequest) ?? undefined + : undefined } : undefined if (!config.maskRequestFn) { - config.maskRequestFn = removeAuthorizationHeader + config.maskRequestFn = enforcedCleaningFn } return { @@ -85,5 +173,7 @@ export const buildNetworkRequestOptions = ( ...config, recordHeaders: canRecordHeaders, recordBody: canRecordBody, + recordPerformance: canRecordPerformance, + recordInitialRequests: canRecordPerformance, } } diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 9f4fd8cfd..5718cb1fa 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -24,6 +24,7 @@ import { _isBoolean, _isFunction, _isNull, _isNumber, _isObject, _isString, _isU import { logger } from '../../utils/logger' import { assignableWindow, window } from '../../utils/globals' import { buildNetworkRequestOptions } from './config' +import { isLocalhost } from '../../utils/request-utils' const BASE_ENDPOINT = '/s/' @@ -102,6 +103,9 @@ export class SessionRecording { private _sampleRate: number | null = null private _minimumDuration: number | null = null + // Util to help developers working on this feature manually override + _forceAllowLocalhostNetworkCapture = false + public get started(): boolean { // TODO could we use status instead of _captureStarted? return this._captureStarted @@ -148,7 +152,11 @@ export class SessionRecording { return recordingVersion_client_side || recordingVersion_server_side || 'v1' } - private get networkPayloadCapture(): Pick | undefined { + // network payload capture config has three parts + // each can be configured server side or client side + private get networkPayloadCapture(): + | Pick + | undefined { const networkPayloadCapture_server_side = this.instance.get_property(SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE) const networkPayloadCapture_client_side = { recordHeaders: this.instance.config.session_recording?.recordHeaders, @@ -158,7 +166,12 @@ export class SessionRecording { networkPayloadCapture_client_side?.recordHeaders || networkPayloadCapture_server_side?.recordHeaders const bodyEnabled = networkPayloadCapture_client_side?.recordBody || networkPayloadCapture_server_side?.recordBody - return headersEnabled || bodyEnabled ? { recordHeaders: headersEnabled, recordBody: bodyEnabled } : undefined + const performanceEnabled = + this.instance.config.capture_performance || networkPayloadCapture_server_side?.capturePerformance + + return headersEnabled || bodyEnabled || performanceEnabled + ? { recordHeaders: headersEnabled, recordBody: bodyEnabled, recordPerformance: performanceEnabled } + : undefined } /** @@ -265,7 +278,10 @@ export class SessionRecording { [SESSION_RECORDING_ENABLED_SERVER_SIDE]: !!response['sessionRecording'], [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: response.sessionRecording?.consoleLogRecordingEnabled, [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: response.sessionRecording?.recorderVersion, - [SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE]: response.sessionRecording?.networkPayloadCapture, + [SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE]: { + capturePerformance: response.capturePerformance, + ...response.sessionRecording?.networkPayloadCapture, + }, }) } @@ -485,6 +501,11 @@ export class SessionRecording { plugins.push(assignableWindow.rrwebConsoleRecord.getRecordConsolePlugin()) } if (this.networkPayloadCapture && _isFunction(assignableWindow.getRecordNetworkPlugin)) { + if (isLocalhost() && !this._forceAllowLocalhostNetworkCapture) { + logger.info('[SessionReplay-NetworkCapture] not started because we are on localhost.') + return + } + plugins.push( assignableWindow.getRecordNetworkPlugin( buildNetworkRequestOptions(this.instance.config, this.networkPayloadCapture) diff --git a/src/extensions/replay/web-performance.ts b/src/extensions/replay/web-performance.ts deleted file mode 100644 index c77a07b62..000000000 --- a/src/extensions/replay/web-performance.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { PostHog } from '../../posthog-core' -import { DecideResponse, NetworkRequest } from '../../types' -import { isLocalhost } from '../../utils/request-utils' - -import { _isUndefined } from '../../utils/type-utils' -import { logger } from '../../utils/logger' -import { window } from '../../utils/globals' - -const PERFORMANCE_EVENTS_MAPPING: { [key: string]: number } = { - // BASE_PERFORMANCE_EVENT_COLUMNS - entryType: 0, - timeOrigin: 1, - name: 2, - - // RESOURCE_EVENT_COLUMNS - startTime: 3, - redirectStart: 4, - redirectEnd: 5, - workerStart: 6, - fetchStart: 7, - domainLookupStart: 8, - domainLookupEnd: 9, - connectStart: 10, - secureConnectionStart: 11, - connectEnd: 12, - requestStart: 13, - responseStart: 14, - responseEnd: 15, - decodedBodySize: 16, - encodedBodySize: 17, - initiatorType: 18, - nextHopProtocol: 19, - renderBlockingStatus: 20, - responseStatus: 21, - transferSize: 22, - - // LARGEST_CONTENTFUL_PAINT_EVENT_COLUMNS - element: 23, - renderTime: 24, - loadTime: 25, - size: 26, - id: 27, - url: 28, - - // NAVIGATION_EVENT_COLUMNS - domComplete: 29, - domContentLoadedEvent: 30, - domInteractive: 31, - loadEventEnd: 32, - loadEventStart: 33, - redirectCount: 34, - navigationType: 35, - unloadEventEnd: 36, - unloadEventStart: 37, - - // Added after v1 - duration: 39, - timestamp: 40, - - // NOTE: CURRENTLY UNSUPPORTED - // EVENT_TIMING_EVENT_COLUMNS - // processingStart: null, - // processingEnd: null, - - // MARK_AND_MEASURE_EVENT_COLUMNS - // detail: null, -} - -const ENTRY_TYPES_TO_OBSERVE = [ - // 'event', // This is too noisy as it covers all browser events - 'first-input', - // 'mark', // Mark is used too liberally. We would need to filter for specific marks - // 'measure', // Measure is used too liberally. We would need to filter for specific measures - 'navigation', - 'paint', - 'resource', -] - -const PERFORMANCE_INGESTION_ENDPOINT = '/e/' -// Don't monitor posthog paths because then events cause performance events which are events and the snake eats its tail 😱 -const POSTHOG_PATHS_TO_IGNORE = ['/s/', PERFORMANCE_INGESTION_ENDPOINT] - -export class WebPerformanceObserver { - instance: PostHog - remoteEnabled: boolean | undefined - observer: PerformanceObserver | undefined - - // Util to help developers working on this feature manually override - _forceAllowLocalhost = false - - constructor(instance: PostHog) { - this.instance = instance - } - - startObservingIfEnabled() { - if (this.isEnabled()) { - this.startObserving() - } else { - this.stopObserving() - } - } - - startObserving() { - if (this.observer) { - return - } - - if (_isUndefined(window?.PerformanceObserver?.supportedEntryTypes)) { - logger.info( - '[PerformanceObserver] not started because PerformanceObserver is not supported by this browser.' - ) - return - } - - if (isLocalhost() && !this._forceAllowLocalhost) { - logger.info('[PerformanceObserver] not started because we are on localhost.') - return - } - - try { - // compat checked above with early return - // eslint-disable-next-line compat/compat - this.observer = new PerformanceObserver((list) => { - list.getEntries().forEach((entry) => { - this._capturePerformanceEvent(entry) - }) - }) - - // compat checked above with early return - // eslint-disable-next-line compat/compat - const entryTypes = PerformanceObserver.supportedEntryTypes.filter((x) => ENTRY_TYPES_TO_OBSERVE.includes(x)) - - entryTypes.forEach((entryType) => { - this.observer?.observe({ type: entryType, buffered: true }) - }) - } catch (e) { - logger.error('PostHog failed to start performance observer', e) - this.stopObserving() - } - } - - stopObserving() { - if (this.observer) { - this.observer.disconnect() - this.observer = undefined - } - } - - isObserving() { - return !!this.observer - } - - isEnabled() { - return this.instance.config.capture_performance ?? this.remoteEnabled ?? false - } - - afterDecideResponse(response: DecideResponse) { - this.remoteEnabled = response.capturePerformance || false - if (this.isEnabled()) { - this.startObserving() - } - } - - _capturePerformanceEvent(event: PerformanceEntry) { - // NOTE: We don't want to capture our own request events. - - if (event.name.indexOf(this.instance.config.api_host) === 0) { - const path = event.name.replace(this.instance.config.api_host, '') - - if (POSTHOG_PATHS_TO_IGNORE.find((x) => path.indexOf(x) === 0)) { - return - } - } - - // NOTE: This is minimal atm but will include more options when we move to the - // built-in rrweb network recorder - let networkRequest: NetworkRequest | null | undefined = { - url: event.name, - } - - const userSessionRecordingOptions = this.instance.config.session_recording - - if (userSessionRecordingOptions.maskNetworkRequestFn) { - networkRequest = userSessionRecordingOptions.maskNetworkRequestFn(networkRequest) - } - - if (!networkRequest) { - return - } - - const eventJson = event.toJSON() - eventJson.name = networkRequest.url - const properties: { [key: number]: any } = {} - // kudos to sentry javascript sdk for excellent background on why to use Date.now() here - // https://github.com/getsentry/sentry-javascript/blob/e856e40b6e71a73252e788cd42b5260f81c9c88e/packages/utils/src/time.ts#L70 - // can't start observer if performance.now() is not available - // eslint-disable-next-line compat/compat - const timeOrigin = Math.floor(Date.now() - performance.now()) - properties[PERFORMANCE_EVENTS_MAPPING['timeOrigin']] = timeOrigin - // clickhouse can't ingest timestamps that are floats - // (in this case representing fractions of a millisecond we don't care about anyway) - properties[PERFORMANCE_EVENTS_MAPPING['timestamp']] = Math.floor(timeOrigin + event.startTime) - for (const key in PERFORMANCE_EVENTS_MAPPING) { - if (!_isUndefined(eventJson[key])) { - properties[PERFORMANCE_EVENTS_MAPPING[key]] = eventJson[key] - } - } - - this.capturePerformanceEvent(properties) - - if (exposesServerTiming(event)) { - for (const timing of event.serverTiming || []) { - this.capturePerformanceEvent({ - [PERFORMANCE_EVENTS_MAPPING['timeOrigin']]: timeOrigin, - [PERFORMANCE_EVENTS_MAPPING['timestamp']]: Math.floor(timeOrigin + event.startTime), - [PERFORMANCE_EVENTS_MAPPING['name']]: timing.name, - [PERFORMANCE_EVENTS_MAPPING['duration']]: timing.duration, - // the spec has a closed list of possible types - // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry/entryType - // but, we need to know this was a server timing so that we know to - // match it to the appropriate navigation or resource timing - // that matching will have to be on timestamp and $current_url - [PERFORMANCE_EVENTS_MAPPING['entryType']]: 'serverTiming', - }) - } - } - } - - /** - * :TRICKY: Make sure we batch these requests, and don't truncate the strings. - */ - private capturePerformanceEvent(properties: { [key: number]: any }) { - const timestamp = properties[PERFORMANCE_EVENTS_MAPPING['timestamp']] - - this.instance.sessionRecording?.onRRwebEmit({ - type: 6, // EventType.Plugin, - data: { - plugin: 'posthog/network@1', - payload: properties, - }, - timestamp, - }) - - // this.instance.capture('$performance_event', properties, { - // transport: 'XHR', - // method: 'POST', - // endpoint: PERFORMANCE_INGESTION_ENDPOINT, - // _noTruncate: true, - // _batchKey: 'performanceEvent', - // }) - } -} - -/** - * Check if this PerformanceEntry is either a PerformanceResourceTiming or a PerformanceNavigationTiming - * NB PerformanceNavigationTiming extends PerformanceResourceTiming - * Here we don't care which interface it implements as both expose `serverTimings` - */ -const exposesServerTiming = (event: PerformanceEntry): event is PerformanceResourceTiming => - event.entryType === 'navigation' || event.entryType === 'resource' diff --git a/src/loader-recorder-v2.ts b/src/loader-recorder-v2.ts index 4d99c3c40..9de94dcb6 100644 --- a/src/loader-recorder-v2.ts +++ b/src/loader-recorder-v2.ts @@ -11,23 +11,20 @@ import { getRecordConsolePlugin } from 'rrweb/es/rrweb/packages/rrweb/src/plugin // rrweb/network@1 code starts // most of what is below here will be removed when rrweb release their code for this // see https://github.com/rrweb-io/rrweb/pull/1105 - /// - // NB adopted from https://github.com/rrweb-io/rrweb/pull/1105 which looks like it will be accepted into rrweb // however, in the PR, it throws when the performance observer data is not available // and assumes it is running in a browser with the Request API (i.e. not IE11) // copying here so that we can use it before rrweb adopt it - import type { IWindow, listenerHandler, RecordPlugin } from '@rrweb/types' -import { InitiatorType, NetworkRecordOptions, NetworkRequest, Headers } from './types' -import { _isBoolean, _isFunction, _isArray, _isUndefined, _isNull } from './utils/type-utils' +import { CapturedNetworkRequest, Headers, InitiatorType, NetworkRecordOptions } from './types' +import { _isArray, _isBoolean, _isFunction, _isNull, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' import { window } from './utils/globals' import { defaultNetworkOptions } from './extensions/replay/config' export type NetworkData = { - requests: NetworkRequest[] + requests: CapturedNetworkRequest[] isInitial?: boolean } @@ -97,6 +94,12 @@ export function findLast(array: Array, predicate: (value: T) => boolean): } function initPerformanceObserver(cb: networkCallback, win: IWindow, options: Required) { + // if we are only observing timings then we could have a single observer for all types, with buffer true, + // but we are going to filter by initiatorType _if we are wrapping fetch and xhr as the wrapped functions + // will deal with those. + // so we have a block which captures requests from before fetch/xhr is wrapped + // these are marked `isInitial` so playback can display them differently if needed + // they will never have method/status/headers/body because they are pre-wrapping that provides that if (options.recordInitialRequests) { const initialPerformanceEntries = win.performance .getEntries() @@ -106,38 +109,41 @@ function initPerformanceObserver(cb: networkCallback, win: IWindow, options: Req (isResourceTiming(entry) && options.initiatorTypes.includes(entry.initiatorType as InitiatorType)) ) cb({ - requests: initialPerformanceEntries.map((entry) => ({ - url: entry.name, - initiatorType: entry.initiatorType as InitiatorType, - status: 'responseStatus' in entry ? entry.responseStatus : undefined, - startTime: Math.round(entry.startTime), - endTime: Math.round(entry.responseEnd), - })), + requests: initialPerformanceEntries.flatMap((entry) => + prepareRequest(entry, undefined, undefined, {}, true) + ), isInitial: true, }) } const observer = new win.PerformanceObserver((entries) => { - const performanceEntries = entries - .getEntries() - .filter( - (entry): entry is ObservedPerformanceEntry => - isNavigationTiming(entry) || - (isResourceTiming(entry) && - options.initiatorTypes.includes(entry.initiatorType as InitiatorType) && - entry.initiatorType !== 'xmlhttprequest' && - entry.initiatorType !== 'fetch') - ) + // if recordBody or recordHeaders is true then we don't want to record fetch or xhr here + // as the wrapped functions will do that. Otherwise, this filter becomes a noop + // because we do want to record them here + const wrappedInitiatorFilter = (entry: ObservedPerformanceEntry) => + options.recordBody || options.recordHeaders + ? entry.initiatorType !== 'xmlhttprequest' && entry.initiatorType !== 'fetch' + : true + + const performanceEntries = entries.getEntries().filter( + (entry): entry is ObservedPerformanceEntry => + isNavigationTiming(entry) || + (isResourceTiming(entry) && + options.initiatorTypes.includes(entry.initiatorType as InitiatorType) && + // TODO if we are _only_ capturing timing we don't want to filter initiator here + wrappedInitiatorFilter(entry)) + ) + cb({ - requests: performanceEntries.map((entry) => ({ - url: entry.name, - initiatorType: entry.initiatorType as InitiatorType, - status: 'responseStatus' in entry ? entry.responseStatus : undefined, - startTime: Math.round(entry.startTime), - endTime: Math.round(entry.responseEnd), - })), + requests: performanceEntries.flatMap((entry) => prepareRequest(entry, undefined, undefined, {})), }) }) - observer.observe({ entryTypes: ['navigation', 'resource'] }) + // compat checked earlier + // eslint-disable-next-line compat/compat + const entryTypes = PerformanceObserver.supportedEntryTypes.filter((x) => + options.performanceEntryTypeToObserve.includes(x) + ) + // initial records are gathered above, so we don't need to observe and buffer each type separately + observer.observe({ entryTypes }) return () => { observer.disconnect() } @@ -224,7 +230,7 @@ function initXhrObserver(cb: networkCallback, win: IWindow, options: Required = {} + const networkRequest: Partial = {} let after: number | undefined let before: number | undefined const requestHeaders: Headers = {} @@ -280,19 +286,8 @@ function initXhrObserver(cb: networkCallback, win: IWindow, options: Required { // @@ -307,6 +302,69 @@ function initXhrObserver(cb: networkCallback, win: IWindow, options: Required + event.entryType === 'navigation' || event.entryType === 'resource' + +function prepareRequest( + entry: PerformanceResourceTiming, + method: string | undefined, + status: number | undefined, + networkRequest: Partial, + isInitial?: boolean +): CapturedNetworkRequest[] { + // kudos to sentry javascript sdk for excellent background on why to use Date.now() here + // https://github.com/getsentry/sentry-javascript/blob/e856e40b6e71a73252e788cd42b5260f81c9c88e/packages/utils/src/time.ts#L70 + // can't start observer if performance.now() is not available + // eslint-disable-next-line compat/compat + const timeOrigin = Math.floor(Date.now() - performance.now()) + // clickhouse can't ingest timestamps that are floats + // (in this case representing fractions of a millisecond we don't care about anyway) + const timestamp = Math.floor(timeOrigin + entry.startTime) + + const requests: CapturedNetworkRequest[] = [ + { + ...entry.toJSON(), + startTime: Math.round(entry.startTime), + endTime: Math.round(entry.responseEnd), + timeOrigin, + timestamp, + method: method, + initiatorType: entry.initiatorType as InitiatorType, + status, + requestHeaders: networkRequest.requestHeaders, + requestBody: networkRequest.requestBody, + responseHeaders: networkRequest.responseHeaders, + responseBody: networkRequest.responseBody, + isInitial, + }, + ] + + if (exposesServerTiming(entry)) { + for (const timing of entry.serverTiming || []) { + requests.push({ + timeOrigin, + timestamp, + startTime: Math.round(entry.startTime), + name: timing.name, + duration: timing.duration, + // the spec has a closed list of possible types + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry/entryType + // but, we need to know this was a server timing so that we know to + // match it to the appropriate navigation or resource timing + // that matching will have to be on timestamp and $current_url + entryType: 'serverTiming', + }) + } + } + + return requests +} + function initFetchObserver( cb: networkCallback, win: IWindow, @@ -328,7 +386,7 @@ function initFetchObserver( // eslint-disable-next-line compat/compat const req = new Request(url, init) let res: Response | undefined - const networkRequest: Partial = {} + const networkRequest: Partial = {} let after: number | undefined let before: number | undefined try { @@ -376,19 +434,8 @@ function initFetchObserver( if (_isNull(entry)) { return } - const request: NetworkRequest = { - url: entry.name, - method: req.method, - initiatorType: entry.initiatorType as InitiatorType, - status: res?.status, - startTime: Math.round(entry.startTime), - endTime: Math.round(entry.responseEnd), - requestHeaders: networkRequest.requestHeaders, - requestBody: networkRequest.requestBody, - responseHeaders: networkRequest.responseHeaders, - responseBody: networkRequest.responseBody, - } - cb({ requests: [request] }) + const requests = prepareRequest(entry, req.method, res?.status, networkRequest) + cb({ requests }) }) .catch(() => { // @@ -416,7 +463,7 @@ function initNetworkObserver( ) as Required const cb: networkCallback = (data) => { - const requests: NetworkRequest[] = [] + const requests: CapturedNetworkRequest[] = [] data.requests.forEach((request) => { const maskedRequest = networkOptions.maskRequestFn(request) if (maskedRequest) { @@ -429,8 +476,15 @@ function initNetworkObserver( } } const performanceObserver = initPerformanceObserver(cb, win, networkOptions) - const xhrObserver = initXhrObserver(cb, win, networkOptions) - const fetchObserver = initFetchObserver(cb, win, networkOptions) + + // only wrap fetch and xhr if headers or body are being recorded + let xhrObserver: listenerHandler = () => {} + let fetchObserver: listenerHandler = () => {} + if (networkOptions.recordHeaders || networkOptions.recordBody) { + xhrObserver = initXhrObserver(cb, win, networkOptions) + fetchObserver = initFetchObserver(cb, win, networkOptions) + } + return () => { performanceObserver() xhrObserver() diff --git a/src/posthog-core.ts b/src/posthog-core.ts index e48d9cc13..0de7d319a 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -14,7 +14,6 @@ import { PostHogFeatureFlags } from './posthog-featureflags' import { PostHogPersistence } from './posthog-persistence' import { ALIAS_ID_KEY, FLAG_CALL_REPORTED, PEOPLE_DISTINCT_ID_KEY } from './constants' import { SessionRecording } from './extensions/replay/sessionrecording' -import { WebPerformanceObserver } from './extensions/replay/web-performance' import { Decide } from './decide' import { Toolbar } from './extensions/toolbar' import { clearOptInOut, hasOptedIn, hasOptedOut, optIn, optOut, userOptedOut } from './gdpr-utils' @@ -212,9 +211,6 @@ const create_phlib = function ( instance.sessionRecording = new SessionRecording(instance) instance.sessionRecording.startRecordingIfEnabled() - instance.webPerformance = new WebPerformanceObserver(instance) - instance.webPerformance.startObservingIfEnabled() - if (instance.config.__preview_measure_pageview_stats) { instance.pageViewManager.startMeasuringScrollPosition() } @@ -253,6 +249,19 @@ const create_phlib = function ( return instance } +class DeprecatedWebPerformanceObserver { + get _forceAllowLocalhost(): boolean { + return this.__forceAllowLocalhost + } + set _forceAllowLocalhost(value: boolean) { + logger.error( + 'WebPerformanceObserver is deprecated and has no impact on network capture. Use `_forceAllowLocalhostNetworkCapture` on `posthog.sessionRecording`' + ) + this.__forceAllowLocalhost = value + } + private __forceAllowLocalhost: boolean = false +} + /** * PostHog Library Object * @constructor @@ -277,7 +286,7 @@ export class PostHog { _requestQueue?: RequestQueue _retryQueue?: RetryQueue sessionRecording?: SessionRecording - webPerformance?: WebPerformanceObserver + webPerformance = new DeprecatedWebPerformanceObserver() _triggered_notifs: any compression: Partial> diff --git a/src/types.ts b/src/types.ts index 85a0651cc..e682c145e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -159,9 +159,10 @@ export interface SessionRecordingOptions { inlineStylesheet?: boolean recorderVersion?: 'v1' | 'v2' recordCrossOriginIframes?: boolean - /** Modify the network request before it is captured. Returning null stops it being captured */ - // TODO this has to work for both capture mechanisms? 😱 + /** @deprecated - use maskCapturedNetworkRequestFn instead */ maskNetworkRequestFn?: ((data: NetworkRequest) => NetworkRequest | null | undefined) | null + /** Modify the network request before it is captured. Returning null or undefined stops it being captured */ + maskCapturedNetworkRequestFn?: ((data: CapturedNetworkRequest) => CapturedNetworkRequest | null | undefined) | null // properties below here are ALPHA, don't rely on them, they may change without notice // TODO which of these do we actually expose? // if this isn't provided a default will be used @@ -371,6 +372,11 @@ export type Body = | ArrayBufferView | ArrayBuffer | FormData + // rrweb uses URLSearchParams and ReadableStream + // as part of the union for this type + // because they don't support IE11 + // but, we do 🫠 + // what's going to happen here in IE11? | URLSearchParams | ReadableStream | null @@ -404,26 +410,46 @@ export type InitiatorType = export type NetworkRecordOptions = { initiatorTypes?: InitiatorType[] - maskRequestFn?: (data: NetworkRequest) => NetworkRequest | undefined + maskRequestFn?: (data: CapturedNetworkRequest) => CapturedNetworkRequest | undefined recordHeaders?: boolean | { request: boolean; response: boolean } recordBody?: boolean | string[] | { request: boolean | string[]; response: boolean | string[] } recordInitialRequests?: boolean + // whether to record PerformanceEntry events for network requests + recordPerformance?: boolean + // the PerformanceObserver will only observe these entry types + performanceEntryTypeToObserve: string[] + // the maximum size of the request/response body to record + // NB this will be at most 1MB even if set larger + payloadSizeLimitBytes: number } -// extending this to match the rrweb NetworkRequest type -// it is different in that the rrweb type will have initator type, starttime, and endtime -// as required properties. but we don't want to require them here -// because we've previously exposed this type as only having `url` +/** @deprecated - use CapturedNetworkRequest instead */ export type NetworkRequest = { url: string +} + +// In rrweb this is called NetworkRequest, but we already exposed that as having only URL +// we also want to vary from the rrweb NetworkRequest because we want to include +// all PerformanceEntry properties too. +// that has 4 required properties +// readonly duration: DOMHighResTimeStamp; +// readonly entryType: string; +// readonly name: string; +// readonly startTime: DOMHighResTimeStamp; +// NB: properties below here are ALPHA, don't rely on them, they may change without notice +export type CapturedNetworkRequest = Omit & { // properties below here are ALPHA, don't rely on them, they may change without notice method?: string initiatorType?: InitiatorType status?: number + timeOrigin?: number + timestamp?: number startTime?: number endTime?: number requestHeaders?: Headers requestBody?: Body responseHeaders?: Headers responseBody?: Body + // was this captured before fetch/xhr could have been wrapped + isInitial?: boolean } diff --git a/src/utils/event-utils.ts b/src/utils/event-utils.ts index 6e202a678..f0d13e678 100644 --- a/src/utils/event-utils.ts +++ b/src/utils/event-utils.ts @@ -1,4 +1,4 @@ -import { _getQueryParam } from './request-utils' +import { _getQueryParam, convertToURL } from './request-utils' import { _isNull, _isUndefined } from './type-utils' import { Properties } from '../types' import Config from '../config' @@ -256,9 +256,7 @@ export const _info = { if (!document?.referrer) { return '$direct' } - const parser = document.createElement('a') // Unfortunately we cannot use new URL due to IE11 - parser.href = document.referrer - return parser.host + return convertToURL(document.referrer)?.host || '$direct' }, properties: function (): Properties { diff --git a/src/utils/request-utils.ts b/src/utils/request-utils.ts index d5193e14b..a097001d6 100644 --- a/src/utils/request-utils.ts +++ b/src/utils/request-utils.ts @@ -2,9 +2,26 @@ import { _each, _isValidRegex } from './' import { _isArray, _isUndefined } from './type-utils' import { logger } from './logger' +import { document } from './globals' const localDomains = ['localhost', '127.0.0.1'] +/** + * IE11 doesn't support `new URL` + * so we can create an anchor element and use that to parse the URL + * there's a lot of overlap between HTMLHyperlinkElementUtils and URL + * meaning useful properties like `pathname` are available on both + */ +export const convertToURL = (url: string): HTMLAnchorElement | null => { + const location = document?.createElement('a') + if (_isUndefined(location)) { + return null + } + + location.href = url + return location +} + export const _isUrlMatchingRegex = function (url: string, pattern: string): boolean { if (!_isValidRegex(pattern)) return false return new RegExp(pattern).test(url) diff --git a/src/utils/type-utils.ts b/src/utils/type-utils.ts index 76110a843..0a5dcbb64 100644 --- a/src/utils/type-utils.ts +++ b/src/utils/type-utils.ts @@ -16,12 +16,8 @@ export const _isUint8Array = function (x: unknown): x is Uint8Array { // fails on only one very rare and deliberate custom object: // let bomb = { toString : undefined, valueOf: function(o) { return "function BOMBA!"; }}; export const _isFunction = function (f: any): f is (...args: any[]) => any { - try { - // eslint-disable-next-line posthog-js/no-direct-function-check - return /^\s*\bfunction\b/.test(f) - } catch (x) { - return false - } + // eslint-disable-next-line posthog-js/no-direct-function-check + return typeof f === 'function' } // Underscore Addons export const _isObject = function (x: unknown): x is Record { @@ -46,6 +42,7 @@ export const _isString = function (x: unknown): x is string { // eslint-disable-next-line posthog-js/no-direct-string-check return toString.call(x) == '[object String]' } + export const _isNull = function (x: unknown): x is null { // eslint-disable-next-line posthog-js/no-direct-null-check return x === null From bde00d805e38a86c496facbd83f46b2b16e7d41b Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 21 Nov 2023 17:34:35 +0000 Subject: [PATCH 8/8] chore: Bump version to 1.92.1 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1434a5eb6..0a85179a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.92.1 - 2023-11-21 + +- feat: payload capture - move timing into copied plugin (#902) + ## 1.92.0 - 2023-11-20 - feat: Create elements chain string as we store it (#823) diff --git a/package.json b/package.json index 4d35188ae..a19998722 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.92.0", + "version": "1.92.1", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com",