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
-