diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..394d0afcb --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,33 @@ +name: Playwright Tests + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 8.x.x + - uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'pnpm' + - run: pnpm install + - run: pnpm build + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + - name: Run Playwright tests + run: pnpm exec playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index f1cb33f9a..e77b41280 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,8 @@ yarn-error.log stats.html bundle-stats*.html .eslintcache -cypress/downloads/downloads.html \ No newline at end of file +cypress/downloads/downloads.html +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md index b22c97bb4..87adfa4de 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ and then `pnpm` commands as usual ## Testing -Unit tests: run `pnpm test`. -Cypress: run `pnpm start` to have a test server running and separately `pnpm cypress` to launch Cypress test engine. +* Unit tests: run `pnpm test`. +* Cypress: run `pnpm start` to have a test server running and separately `pnpm cypress` to launch Cypress test engine. +* Playwright: run e.g. `pnpm exec playwright test --ui --project webkit --project firefox` to run with UI and in webkit and firefox ### Running TestCafe E2E tests with BrowserStack @@ -56,7 +57,7 @@ You can use the create react app setup in `playground/nextjs` to test posthog-js ### Tiers of testing 1. Unit tests - this verifies the behavior of the library in bite-sized chunks. Keep this coverage close to 100%, test corner cases and internal behavior here -2. Cypress tests - integrates with a real chrome browser and is capable of testing timing, browser requests, etc. Useful for testing high-level library behavior, ordering and verifying requests. We shouldn't aim for 100% coverage here as it's impossible to test all possible combinations. +2. Browser tests - run in real browsers and so capable of testing timing, browser requests, etc. Useful for testing high-level library behavior, ordering and verifying requests. We shouldn't aim for 100% coverage here as it's impossible to test all possible combinations. 3. TestCafe E2E tests - integrates with a real posthog instance sends data to it. Hardest to write and maintain - keep these very high level ## Developing together with another project diff --git a/cypress/e2e/opting-out.cy.ts b/cypress/e2e/opting-out.cy.ts index e7bada65a..34ee0f159 100644 --- a/cypress/e2e/opting-out.cy.ts +++ b/cypress/e2e/opting-out.cy.ts @@ -1,19 +1,8 @@ -import { assertWhetherPostHogRequestsWereCalled, pollPhCaptures } from '../support/assertions' +import { assertWhetherPostHogRequestsWereCalled } from '../support/assertions' import { start } from '../support/setup' -function assertThatRecordingStarted() { - cy.phCaptures({ full: true }).then((captures) => { - expect(captures.map((c) => c.event)).to.deep.equal(['$snapshot']) - - expect(captures[0]['properties']['$snapshot_data']).to.have.length.above(2) - // a meta and then a full snapshot - expect(captures[0]['properties']['$snapshot_data'][0].type).to.equal(4) // meta - expect(captures[0]['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot - }) -} - describe('opting out', () => { - describe('session recording', () => { + describe('when starting disabled in some way', () => { beforeEach(() => { cy.intercept('POST', '/decide/*', { editorParams: {}, @@ -65,301 +54,6 @@ describe('opting out', () => { }) }) - it('does not capture recordings when config disables session recording', () => { - cy.posthogInit({ disable_session_recording: true }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': false, - '@decide': true, - '@session-recording': false, - }) - - cy.get('[data-cy-input]') - .type('hello posthog!') - .then(() => { - cy.phCaptures().then((captures) => { - expect(captures || []).to.deep.equal(['$pageview']) - }) - }) - }) - - it('can start recording after starting opted out', () => { - cy.posthogInit({ opt_out_capturing_by_default: true }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': false, - '@decide': true, - '@session-recording': false, - }) - - cy.posthog().invoke('opt_in_capturing') - // TODO: should we require this call? - cy.posthog().invoke('startSessionRecording') - - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$opt_in', '$pageview']) - }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': true, - '@decide': true, - // no call to session-recording yet - }) - - cy.resetPhCaptures() - - cy.get('[data-cy-input]').type('hello posthog!') - - pollPhCaptures('$snapshot').then(assertThatRecordingStarted) - }) - - it('can start recording when starting disabled', () => { - cy.posthogInit({ disable_session_recording: true }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': false, - '@decide': true, - '@session-recording': false, - }) - - cy.get('[data-cy-input]') - .type('hello posthog!') - .then(() => { - cy.phCaptures().then((captures) => { - expect(captures || []).to.deep.equal(['$pageview']) - }) - }) - - cy.resetPhCaptures() - cy.posthog().invoke('startSessionRecording') - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': true, - '@decide': true, - // no call to session-recording yet - }) - - cy.get('[data-cy-input]') - .type('hello posthog!') - .then(() => { - pollPhCaptures('$snapshot').then(assertThatRecordingStarted) - }) - }) - - it('can override sampling when starting session recording', () => { - cy.intercept('POST', '/decide/*', { - autocapture_opt_out: true, - editorParams: {}, - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - // will never record a session with rate of 0 - sampleRate: '0', - }, - }).as('decide') - - cy.posthogInit({ - opt_out_capturing_by_default: true, - }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': false, - '@decide': true, - '@session-recording': false, - }) - - cy.posthog().invoke('opt_in_capturing') - - cy.posthog().invoke('startSessionRecording', { sampling: true }) - - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$opt_in', '$pageview']) - }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': true, - '@decide': true, - // no call to session-recording yet - }) - - cy.resetPhCaptures() - - cy.get('[data-cy-input]').type('hello posthog!') - - pollPhCaptures('$snapshot').then(assertThatRecordingStarted) - }) - - it('can override linked_flags when starting session recording', () => { - cy.intercept('POST', '/decide/*', { - autocapture_opt_out: true, - editorParams: {}, - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - // a flag that doesn't exist, can never be recorded - linkedFlag: 'i am a flag that does not exist', - }, - }).as('decide') - - cy.posthogInit({ - opt_out_capturing_by_default: true, - }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': false, - '@decide': true, - '@session-recording': false, - }) - - cy.posthog().invoke('opt_in_capturing') - - cy.posthog().invoke('startSessionRecording') - - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$opt_in', '$pageview']) - }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': true, - '@decide': true, - // no call to session-recording yet - }) - - cy.resetPhCaptures() - - cy.get('[data-cy-input]') - .type('hello posthog!') - .then(() => { - cy.phCaptures().then((captures) => { - // no session recording events yet - expect(captures || []).to.deep.equal([]) - }) - }) - - cy.posthog().invoke('startSessionRecording', { linked_flag: true }) - - cy.get('[data-cy-input]').type('hello posthog!') - - pollPhCaptures('$snapshot').then(assertThatRecordingStarted) - }) - - it('respects sampling when overriding linked_flags when starting session recording', () => { - cy.intercept('POST', '/decide/*', { - autocapture_opt_out: true, - editorParams: {}, - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - // a flag that doesn't exist, can never be recorded - linkedFlag: 'i am a flag that does not exist', - // will never record a session with rate of 0 - sampleRate: '0', - }, - }).as('decide') - - cy.posthogInit({ - opt_out_capturing_by_default: true, - }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': false, - '@decide': true, - '@session-recording': false, - }) - - cy.posthog().invoke('opt_in_capturing') - - cy.posthog().invoke('startSessionRecording') - - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$opt_in', '$pageview']) - }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': true, - '@decide': true, - // no call to session-recording yet - }) - - cy.resetPhCaptures() - - cy.get('[data-cy-input]') - .type('hello posthog!') - .then(() => { - cy.phCaptures().then((captures) => { - // no session recording events yet - expect(captures || []).to.deep.equal([]) - }) - }) - - cy.posthog().invoke('startSessionRecording', { linked_flag: true }) - - cy.get('[data-cy-input]') - .type('hello posthog!') - .then(() => { - cy.phCaptures().then((captures) => { - // no session recording events yet - expect((captures || []).length).to.equal(0) - }) - }) - }) - - it('can override all ingestion controls when starting session recording', () => { - cy.intercept('POST', '/decide/*', { - autocapture_opt_out: true, - editorParams: {}, - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - // a flag that doesn't exist, can never be recorded - linkedFlag: 'i am a flag that does not exist', - // will never record a session with rate of 0 - sampleRate: '0', - }, - }).as('decide') - - cy.posthogInit({ - opt_out_capturing_by_default: true, - }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': false, - '@decide': true, - '@session-recording': false, - }) - - cy.posthog().invoke('opt_in_capturing') - - cy.posthog().invoke('startSessionRecording') - - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$opt_in', '$pageview']) - }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': true, - '@decide': true, - // no call to session-recording yet - }) - - cy.resetPhCaptures() - - cy.get('[data-cy-input]') - .type('hello posthog!') - .then(() => { - cy.phCaptures().then((captures) => { - // no session recording events yet - expect(captures || []).to.deep.equal([]) - }) - }) - - cy.posthog().invoke('startSessionRecording', true) - - cy.get('[data-cy-input]').type('hello posthog!') - - pollPhCaptures('$snapshot').then(assertThatRecordingStarted) - }) - it('sends a $pageview event when opting in', () => { cy.intercept('POST', '/decide/*', { autocapture_opt_out: true, diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts deleted file mode 100644 index 35886ff73..000000000 --- a/cypress/e2e/session-recording.cy.ts +++ /dev/null @@ -1,764 +0,0 @@ -/// - -import { isNull } from '../../src/utils/type-utils' -import { start } from '../support/setup' -import { assertWhetherPostHogRequestsWereCalled, pollPhCaptures } from '../support/assertions' - -interface RRWebCustomEvent { - type: number - data: { payload: Record; tag: string } -} - -function ensureRecordingIsStopped() { - cy.resetPhCaptures() - - cy.get('[data-cy-input]') - .type('hello posthog!') - .wait(250) - .then(() => { - cy.phCaptures({ full: true }).then((captures) => { - // should be no captured data - expect(captures.map((c) => c.event)).to.deep.equal([]) - }) - }) -} - -function expectPageViewCustomEvent(snapshot: RRWebCustomEvent) { - expect(snapshot.type).to.equal(5) - expect(snapshot.data.tag).to.equal('$pageview') -} - -function expectCustomEvent(snapshot: RRWebCustomEvent, tag: string) { - expect(snapshot.type).to.equal(5) - expect(snapshot.data.tag).to.equal(tag) -} - -function expectRemoteConfigCustomEvent(snapshot: RRWebCustomEvent) { - expectCustomEvent(snapshot, '$remote_config_received') -} - -function expectPostHogConfigCustomEvent(snapshot: RRWebCustomEvent) { - expectCustomEvent(snapshot, '$posthog_config') -} - -function expectSessionOptionsCustomEvent(snapshot: RRWebCustomEvent) { - expectCustomEvent(snapshot, '$session_options') -} - -function sortByTag(snapshots: RRWebCustomEvent[]) { - return snapshots.sort((a, b) => a.data.tag?.localeCompare(b.data.tag)) -} - -function ensureActivitySendsSnapshots(expectedCustomTags: string[] = []) { - cy.resetPhCaptures() - - cy.get('[data-cy-input]') - .type('hello posthog!') - .wait('@session-recording') - .then(() => { - cy.phCaptures({ full: true }).then((captures) => { - const capturedSnapshot = captures.find((e) => e.event === '$snapshot') - expect(capturedSnapshot).not.to.be.undefined - - const capturedSnapshotData = capturedSnapshot['properties']['$snapshot_data'] - expect(capturedSnapshotData).to.have.length.above(14).and.below(40) - - // first a meta and then a full snapshot - expect(capturedSnapshotData.shift().type).to.equal(4) - expect(capturedSnapshotData.shift().type).to.equal(2) - - // now the list should be all custom events until it is incremental - // and then only incremental snapshots - const customEvents = [] - let seenIncremental = false - for (const snapshot of capturedSnapshotData) { - if (snapshot.type === 5) { - expect(seenIncremental).to.be.false - customEvents.push(snapshot) - } else if (snapshot.type === 3) { - seenIncremental = true - } else { - throw new Error(`Unexpected snapshot type: ${snapshot.type}`) - } - } - const customEventTags = customEvents.map((s) => s.data.tag) - cy.log('checked custom event tags', { customEventTags, expectedCustomTags }) - expect(customEventTags).to.eql(expectedCustomTags) - }) - }) -} - -function wrapFetchInCypress({ - originalFetch, - badlyBehaved = false, -}: { - originalFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise - badlyBehaved?: boolean -}) { - return async function (requestOrURL: URL | RequestInfo, init?: RequestInit | undefined) { - // eslint-disable-next-line compat/compat - const req = new Request(requestOrURL, init) - - const hasBody = typeof requestOrURL !== 'string' && 'body' in requestOrURL - if (hasBody) { - // we read the body to (maybe) exhaust it - badlyBehaved ? await requestOrURL.text() : await requestOrURL.clone().text() - } - - const res = badlyBehaved ? await originalFetch(requestOrURL, init) : await originalFetch(req) - - // we read the body to (maybe) exhaust it - badlyBehaved ? await res.text() : await res.clone().text() - - return res - } -} - -describe('Session recording', () => { - describe('array.full.js', () => { - it('captures session events', () => { - start({ - options: { - session_recording: {}, - }, - decideResponseOverrides: { - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - }, - capturePerformance: true, - autocapture_opt_out: true, - }, - }) - - cy.get('[data-cy-input]').type('hello world! ') - cy.wait(500) - cy.get('[data-cy-input]') - .type('hello posthog!') - .wait('@session-recording') - .then(() => { - cy.posthog().invoke('capture', 'test_registered_property') - cy.phCaptures({ full: true }).then((captures) => { - expect(captures.map((c) => c.event)).to.deep.equal([ - '$pageview', - '$snapshot', - 'test_registered_property', - ]) - - expect(captures[1]['properties']['$snapshot_data']).to.have.length.above(33).and.below(40) - // a meta and then a full snapshot - expect(captures[1]['properties']['$snapshot_data'][0].type).to.equal(4) // meta - expect(captures[1]['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot - expect(captures[1]['properties']['$snapshot_data'][2].type).to.equal(5) // custom event with remote config - expect(captures[1]['properties']['$snapshot_data'][3].type).to.equal(5) // custom event with options - expect(captures[1]['properties']['$snapshot_data'][4].type).to.equal(5) // custom event with posthog config - // Making a set from the rest should all be 3 - incremental snapshots - const incrementalSnapshots = captures[1]['properties']['$snapshot_data'].slice(5) - expect(Array.from(new Set(incrementalSnapshots.map((s) => s.type)))).to.deep.eq([3]) - - expect(captures[2]['properties']['$session_recording_start_reason']).to.equal( - 'recording_initialized' - ) - }) - }) - }) - }) - ;[true, false].forEach((isBadlyBehavedWrapper) => { - describe(`network capture - when fetch wrapper ${ - isBadlyBehavedWrapper ? 'is' : 'is not' - } badly behaved`, () => { - let originalFetch: typeof fetch | null = null - - beforeEach(() => { - // wrap fetch to log the body of the request - // this simulates various libraries that require - // being able to read the request - // and possibly alter it - // see: https://github.com/PostHog/posthog/issues/24471 - // for the catastrophic but hard to detect impact of - // interfering with that with our wrapper - // we wrap before PostHog and... - cy.window().then((win) => { - originalFetch = win.fetch - win.fetch = wrapFetchInCypress({ originalFetch, badlyBehaved: isBadlyBehavedWrapper }) - }) - - start({ - decideResponseOverrides: { - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - networkPayloadCapture: { recordBody: true }, - }, - capturePerformance: true, - autocapture_opt_out: true, - }, - url: './playground/cypress', - options: { - loaded: (ph) => { - ph.sessionRecording._forceAllowLocalhostNetworkCapture = true - }, - - session_recording: {}, - }, - }) - - cy.wait('@recorder-script') - - cy.intercept({ url: 'https://example.com', times: 1 }, (req) => { - req.reply({ - statusCode: 200, - headers: { 'Content-Type': 'application/json' }, - body: { - message: 'This is a JSON response', - }, - }) - }).as('example.com') - - // we wrap after PostHog - cy.window().then((win) => { - originalFetch = win.fetch - win.fetch = wrapFetchInCypress({ originalFetch, badlyBehaved: isBadlyBehavedWrapper }) - }) - }) - - afterEach(() => { - if (originalFetch) { - cy.window().then((win) => { - win.fetch = originalFetch - originalFetch = null - }) - } - }) - - it('it sends network payloads', () => { - cy.get('[data-cy-network-call-button]').click() - cy.wait('@example.com') - cy.wait('@session-recording') - cy.phCaptures({ full: true }).then((captures) => { - const snapshots = captures.filter((c) => c.event === '$snapshot') - - const capturedRequests: Record[] = [] - for (const snapshot of snapshots) { - for (const snapshotData of snapshot.properties['$snapshot_data']) { - if (snapshotData.type === 6) { - for (const req of snapshotData.data.payload.requests) { - capturedRequests.push(req) - } - } - } - } - - const expectedCaptureds: [RegExp, string][] = [ - [/http:\/\/localhost:\d+\/playground\/cypress\//, 'navigation'], - [/http:\/\/localhost:\d+\/static\/array.js/, 'script'], - [ - /http:\/\/localhost:\d+\/decide\/\?v=3&ip=1&_=\d+&ver=1\.\d\d\d\.\d+&compression=base64/, - 'fetch', - ], - [/http:\/\/localhost:\d+\/static\/recorder.js\?v=1\.\d\d\d\.\d+/, 'script'], - [/https:\/\/example.com/, 'fetch'], - ] - - // yay, includes expected network data - expect(capturedRequests.length).to.equal(expectedCaptureds.length) - expectedCaptureds.forEach(([url, initiatorType], index) => { - expect(capturedRequests[index].name).to.match(url) - expect(capturedRequests[index].initiatorType).to.equal(initiatorType) - }) - - // the HTML file that cypress is operating on (playground/cypress/index.html) - // when the button for this test is click makes a post to https://example.com - const capturedFetchRequest = capturedRequests.find((cr) => cr.name === 'https://example.com/') - expect(capturedFetchRequest).to.not.be.undefined - - expect(capturedFetchRequest.fetchStart).to.be.greaterThan(0) // proxy for including network timing info - - expect(capturedFetchRequest.initiatorType).to.eql('fetch') - expect(capturedFetchRequest.isInitial).to.be.undefined - expect(capturedFetchRequest.requestBody).to.eq('i am the fetch body') - - expect(capturedFetchRequest.responseBody).to.eq( - JSON.stringify({ - message: 'This is a JSON response', - }) - ) - }) - }) - - it('it captures XHR/fetch methods correctly', () => { - cy.get('[data-cy-xhr-call-button]').click() - cy.wait('@example.com') - cy.wait('@session-recording') - cy.phCaptures({ full: true }).then((captures) => { - const snapshots = captures.filter((c) => c.event === '$snapshot') - - const capturedRequests: Record[] = [] - for (const snapshot of snapshots) { - for (const snapshotData of snapshot.properties['$snapshot_data']) { - if (snapshotData.type === 6) { - for (const req of snapshotData.data.payload.requests) { - capturedRequests.push(req) - } - } - } - } - - const expectedCaptureds: [RegExp, string][] = [ - [/http:\/\/localhost:\d+\/playground\/cypress\//, 'navigation'], - [/http:\/\/localhost:\d+\/static\/array.js/, 'script'], - [ - /http:\/\/localhost:\d+\/decide\/\?v=3&ip=1&_=\d+&ver=1\.\d\d\d\.\d+&compression=base64/, - 'fetch', - ], - [/http:\/\/localhost:\d+\/static\/recorder.js\?v=1\.\d\d\d\.\d+/, 'script'], - [/https:\/\/example.com/, 'xmlhttprequest'], - ] - - // yay, includes expected network data - expect(capturedRequests.length).to.equal(expectedCaptureds.length) - expectedCaptureds.forEach(([url, initiatorType], index) => { - const capturedRequest = capturedRequests[index] - - expect(capturedRequest.name).to.match(url) - expect(capturedRequest.initiatorType).to.equal(initiatorType) - }) - - // the HTML file that cypress is operating on (playground/cypress/index.html) - // when the button for this test is click makes a post to https://example.com - const capturedFetchRequest = capturedRequests.find((cr) => cr.name === 'https://example.com/') - expect(capturedFetchRequest).to.not.be.undefined - - expect(capturedFetchRequest.fetchStart).to.be.greaterThan(0) // proxy for including network timing info - - expect(capturedFetchRequest.initiatorType).to.eql('xmlhttprequest') - expect(capturedFetchRequest.method).to.eql('POST') - expect(capturedFetchRequest.isInitial).to.be.undefined - expect(capturedFetchRequest.requestBody).to.eq('i am the xhr body') - - expect(capturedFetchRequest.responseBody).to.eq( - JSON.stringify({ - message: 'This is a JSON response', - }) - ) - }) - }) - }) - }) - - describe('array.js', () => { - beforeEach(() => { - start({ - options: { - session_recording: {}, - }, - decideResponseOverrides: { - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - }, - capturePerformance: true, - autocapture_opt_out: true, - }, - url: './playground/cypress', - }) - cy.wait('@recorder-script') - }) - - it('captures session events', () => { - cy.phCaptures({ full: true }).then((captures) => { - // should be a pageview at the beginning - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview']) - }) - cy.resetPhCaptures() - - let startingSessionId: string | null = null - cy.posthog().then((ph) => { - startingSessionId = ph.get_session_id() - }) - - cy.get('[data-cy-input]').type('hello world! ') - cy.wait(500) - ensureActivitySendsSnapshots(['$remote_config_received', '$session_options', '$posthog_config']) - cy.posthog().then((ph) => { - ph.stopSessionRecording() - }) - - ensureRecordingIsStopped() - - // restarting recording - cy.posthog().then((ph) => { - ph.startSessionRecording() - }) - ensureActivitySendsSnapshots(['$session_options', '$posthog_config']) - - // the session id is not rotated by stopping and starting the recording - cy.posthog().then((ph) => { - const secondSessionId = ph.get_session_id() - expect(startingSessionId).not.to.be.null - expect(secondSessionId).not.to.be.null - expect(secondSessionId).to.equal(startingSessionId) - }) - }) - - it('captures snapshots when the mouse moves', () => { - let sessionId: string | null = null - - // cypress time handling can confuse when to run full snapshot, let's force that to happen... - cy.get('[data-cy-input]').type('hello world! ') - cy.wait('@session-recording').then(() => { - cy.phCaptures({ full: true }).then((captures) => { - captures.forEach((c) => { - if (isNull(sessionId)) { - sessionId = c.properties['$session_id'] - } - // all captures should be from one session - expect(c.properties['$session_id']).to.equal(sessionId) - }) - expect(sessionId).not.to.be.null - }) - }) - // and then reset - cy.resetPhCaptures() - - cy.get('body') - .trigger('mousemove', { clientX: 200, clientY: 300 }) - .trigger('mousemove', { clientX: 210, clientY: 300 }) - .trigger('mousemove', { clientX: 220, clientY: 300 }) - .trigger('mousemove', { clientX: 240, clientY: 300 }) - - cy.wait('@session-recording').then(() => { - cy.phCaptures({ full: true }).then((captures) => { - // should be a $snapshot for the current session - expect(captures.map((c) => c.event)).to.deep.equal(['$snapshot']) - expect(captures[0].properties['$session_id']).to.equal(sessionId) - - expect(captures[0]['properties']['$snapshot_data']).to.have.length.above(0) - - /** - * the snapshots will look a little like: - * [ - * {"type":3,"data":{"source":6,"positions":[{"x":58,"y":18,"id":15,"timeOffset":0}]},"timestamp":1699814887222}, - * {"type":3,"data":{"source":6,"positions":[{"x":58,"y":18,"id":15,"timeOffset":-430}]},"timestamp":1699814887722} - * ] - */ - - const xPositions = [] - for (let i = 0; i < captures[0]['properties']['$snapshot_data'].length; i++) { - expect(captures[0]['properties']['$snapshot_data'][i].type).to.equal(3) - expect(captures[0]['properties']['$snapshot_data'][i].data.source).to.equal( - 6, - JSON.stringify(captures[0]['properties']['$snapshot_data'][i]) - ) - xPositions.push(captures[0]['properties']['$snapshot_data'][i].data.positions[0].x) - } - - // even though we trigger 4 events, only 2 snapshots should be captured - // This is because rrweb doesn't try to capture _every_ mouse move - expect(xPositions).to.have.length(2) - expect(xPositions[0]).to.equal(200) - // smoothing varies if this value picks up 220 or 240 - // all we _really_ care about is that it's greater than the previous value - expect(xPositions[1]).to.be.above(xPositions[0]) - }) - }) - }) - - it('continues capturing to the same session when the page reloads', () => { - let sessionId: string | null = null - - cy.get('[data-cy-input]').type('hello world! ') - cy.wait('@session-recording').then(() => { - cy.phCaptures({ full: true }).then((captures) => { - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$snapshot']) - - captures.forEach((c) => { - if (isNull(sessionId)) { - sessionId = c.properties['$session_id'] - } - // all captures should be from one session - expect(c.properties['$session_id']).to.equal(sessionId) - }) - expect(sessionId).not.to.be.null - }) - }) - // and then reset - cy.resetPhCaptures() - // and refresh the page - cy.reload() - cy.posthogInit({ - session_recording: {}, - }) - cy.wait('@decide') - cy.wait('@recorder-script') - - cy.get('body') - .trigger('mousemove', { clientX: 200, clientY: 300 }) - .trigger('mousemove', { clientX: 210, clientY: 300 }) - .trigger('mousemove', { clientX: 220, clientY: 300 }) - .trigger('mousemove', { clientX: 240, clientY: 300 }) - - cy.wait('@session-recording').then(() => { - cy.phCaptures({ full: true }).then((captures) => { - // should be a $snapshot for the current session - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$snapshot']) - - expect(captures[0].properties['$session_id']).to.equal(sessionId) - - const capturedSnapshot = captures[1] - expect(capturedSnapshot.properties['$session_id']).to.equal(sessionId) - - expect(capturedSnapshot['properties']['$snapshot_data']).to.have.length.above(0) - - /** - * the snapshots will look a little like: - * [ - * {"type":3,"data":{"source":6,"positions":[{"x":58,"y":18,"id":15,"timeOffset":0}]},"timestamp":1699814887222}, - * {"type":3,"data":{"source":6,"positions":[{"x":58,"y":18,"id":15,"timeOffset":-430}]},"timestamp":1699814887722} - * ] - */ - - // page reloaded so we will start with a full snapshot - // a meta and then a full snapshot - expect(capturedSnapshot['properties']['$snapshot_data'][0].type).to.equal(4) // meta - expect(capturedSnapshot['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot - - // these custom events should always be in the same order, but computers - // we don't care if they are present and in a changing order - const customEvents = sortByTag([ - capturedSnapshot['properties']['$snapshot_data'][2], - capturedSnapshot['properties']['$snapshot_data'][3], - capturedSnapshot['properties']['$snapshot_data'][4], - capturedSnapshot['properties']['$snapshot_data'][5], - ]) - - expectPageViewCustomEvent(customEvents[0]) - expectPostHogConfigCustomEvent(customEvents[1]) - expectRemoteConfigCustomEvent(customEvents[2]) - expectSessionOptionsCustomEvent(customEvents[3]) - - const xPositions = [] - for (let i = 6; i < capturedSnapshot['properties']['$snapshot_data'].length; i++) { - expect(capturedSnapshot['properties']['$snapshot_data'][i].type).to.equal(3) - expect(capturedSnapshot['properties']['$snapshot_data'][i].data.source).to.equal( - 6, - JSON.stringify(capturedSnapshot['properties']['$snapshot_data'][i]) - ) - xPositions.push(capturedSnapshot['properties']['$snapshot_data'][i].data.positions[0].x) - } - - // even though we trigger 4 events, only 2 snapshots should be captured - // This is because rrweb doesn't try to capture _every_ mouse move - expect(xPositions).to.have.length(2) - expect(xPositions[0]).to.equal(200) - // smoothing varies if this value picks up 220 or 240 - // all we _really_ care about is that it's greater than the previous value - expect(xPositions[1]).to.be.above(xPositions[0]) - }) - }) - }) - - it('rotates sessions after 24 hours', () => { - let firstSessionId: string | null = null - - // first we start a session and give it some activity - cy.get('[data-cy-input]').type('hello world! ') - cy.wait(500) - cy.get('[data-cy-input]') - .type('hello posthog!') - .wait('@session-recording') - .then(() => { - cy.posthog().invoke('capture', 'test_registered_property') - cy.phCaptures({ full: true }).then((captures) => { - expect(captures.map((c) => c.event)).to.deep.equal([ - '$pageview', - '$snapshot', - 'test_registered_property', - ]) - - expect(captures[1]['properties']['$session_id']).to.be.a('string') - firstSessionId = captures[1]['properties']['$session_id'] - - expect(captures[2]['properties']['$session_recording_start_reason']).to.equal( - 'recording_initialized' - ) - }) - }) - - // then we reset the captures and move the session back in time - cy.resetPhCaptures() - - cy.posthog().then((ph) => { - const activityTs = ph.sessionManager['_sessionActivityTimestamp'] - const startTs = ph.sessionManager['_sessionStartTimestamp'] - const timeout = ph.sessionManager['_sessionTimeoutMs'] - - // move the session values back, - // so that the next event appears to be greater than timeout since those values - ph.sessionManager['_sessionActivityTimestamp'] = activityTs - timeout - 1000 - ph.sessionManager['_sessionStartTimestamp'] = startTs - timeout - 1000 - }) - - // then we expect that user activity will rotate the session - cy.get('[data-cy-input]') - .type('hello posthog!') - .wait('@session-recording', { timeout: 10000 }) - .then(() => { - cy.posthog().invoke('capture', 'test_registered_property') - cy.phCaptures({ full: true }).then((captures) => { - const capturedSnapshot = captures[0] - expect(capturedSnapshot.event).to.equal('$snapshot') - - expect(capturedSnapshot['properties']['$session_id']).to.be.a('string') - expect(capturedSnapshot['properties']['$session_id']).not.to.eq(firstSessionId) - - expect(capturedSnapshot['properties']['$snapshot_data']).to.have.length.above(0) - expect(capturedSnapshot['properties']['$snapshot_data'][0].type).to.equal(4) // meta - expect(capturedSnapshot['properties']['$snapshot_data'][1].type).to.equal(2) // full_snapshot - - expect(captures[1].event).to.equal('test_registered_property') - expect(captures[1]['properties']['$session_recording_start_reason']).to.equal( - 'session_id_changed' - ) - }) - }) - }) - - it('starts a new recording after calling reset', () => { - cy.phCaptures({ full: true }).then((captures) => { - expect(captures[0].event).to.eq('$pageview') - }) - cy.resetPhCaptures() - - let startingSessionId: string | null = null - cy.posthog().then((ph) => { - startingSessionId = ph.get_session_id() - }) - - ensureActivitySendsSnapshots(['$remote_config_received', '$session_options', '$posthog_config']) - - cy.posthog().then((ph) => { - cy.log('resetting posthog') - ph.reset() - }) - - ensureActivitySendsSnapshots(['$session_options', '$posthog_config', '$session_id_change']) - - // the session id is rotated after reset is called - cy.posthog().then((ph) => { - const secondSessionId = ph.get_session_id() - expect(startingSessionId).not.to.be.null - expect(secondSessionId).not.to.be.null - expect(secondSessionId).not.to.equal(startingSessionId) - }) - }) - }) - - describe('with sampling', () => { - beforeEach(() => { - start({ - options: { - session_recording: {}, - }, - decideResponseOverrides: { - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - sampleRate: '0', - }, - capturePerformance: true, - autocapture_opt_out: true, - }, - url: './playground/cypress', - }) - cy.wait('@recorder-script') - }) - - it('does not capture when sampling is set to 0', () => { - cy.get('[data-cy-input]').type('hello world! ') - cy.wait(500) - cy.get('[data-cy-input]') - .type('hello posthog!') - .wait(200) // can't wait on call to session recording, it's not going to happen - .then(() => { - cy.phCaptures({ full: true }).then((captures) => { - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview']) - }) - }) - }) - - it('can override sampling when starting session recording', () => { - cy.intercept('POST', '/decide/*', { - autocapture_opt_out: true, - editorParams: {}, - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - // will never record a session with rate of 0 - sampleRate: '0', - }, - }).as('decide') - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': true, - '@decide': true, - '@session-recording': false, - }) - - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$pageview']) - }) - - cy.posthog().invoke('startSessionRecording', { sampling: true }) - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': true, - '@decide': true, - // no call to session-recording yet - }) - - cy.posthog().invoke('capture', 'test_registered_property') - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$pageview', 'test_registered_property']) - expect(captures[1]['properties']['$session_recording_start_reason']).to.equal('sampling_overridden') - }) - - cy.resetPhCaptures() - - cy.get('[data-cy-input]').type('hello posthog!') - - pollPhCaptures('$snapshot').then(() => { - cy.phCaptures({ full: true }).then((captures) => { - expect(captures.map((c) => c.event)).to.deep.equal(['$snapshot']) - }) - }) - - // sampling override survives a page refresh - cy.log('refreshing page') - cy.resetPhCaptures() - cy.reload(true).then(() => { - start({ - decideResponseOverrides: { - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - sampleRate: '0', - }, - capturePerformance: true, - autocapture_opt_out: true, - }, - url: './playground/cypress', - }) - cy.wait('@recorder-script') - - cy.get('[data-cy-input]').type('hello posthog!') - - pollPhCaptures('$snapshot').then(() => { - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$pageview', '$snapshot']) - }) - }) - }) - }) - }) -}) diff --git a/cypress/support/setup.ts b/cypress/support/setup.ts index ae9326a78..9d1947594 100644 --- a/cypress/support/setup.ts +++ b/cypress/support/setup.ts @@ -1,4 +1,4 @@ -import { DecideResponse, PostHogConfig } from '../../src/types' +import { Compression, DecideResponse, PostHogConfig } from '../../src/types' import { EventEmitter } from 'events' @@ -26,11 +26,16 @@ export const start = ({ // we don't see the error in production, so it's fine to increase the limit here EventEmitter.prototype.setMaxListeners(100) - const decideResponse = { + const decideResponse: DecideResponse = { editorParams: {}, - featureFlags: ['session-recording-player'], - supportedCompression: ['gzip-js'], - excludedDomains: [], + featureFlags: { 'session-recording-player': true }, + featureFlagPayloads: {}, + errorsWhileComputingFlags: false, + toolbarParams: {}, + toolbarVersion: 'toolbar', + isAuthenticated: false, + siteApps: [], + supportedCompression: [Compression.GZipJS], autocaptureExceptions: false, ...decideResponseOverrides, } diff --git a/package.json b/package.json index 8e0ddd768..22bb02f62 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@babel/preset-typescript": "^7.18.6", "@cypress/skip-test": "^2.6.1", "@jest/globals": "^27.5.1", + "@playwright/test": "^1.49.1", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", diff --git a/playground/cypress/index.html b/playground/cypress/index.html index b078af68e..e3340f48e 100644 --- a/playground/cypress/index.html +++ b/playground/cypress/index.html @@ -15,8 +15,8 @@ Send custom event -