From d0a4d72cc348f44a233546cfc2fd9df2a6603dce Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 27 Dec 2024 05:33:35 +0000 Subject: [PATCH 01/28] chore: playwright tests --- .github/workflows/playwright.yml | 32 +++++ .gitignore | 6 +- cypress/support/setup.ts | 15 ++- package.json | 1 + playwright.config.ts | 79 ++++++++++++ playwright/session-recording.spec.ts | 136 ++++++++++++++++++++ playwright/utils/posthog-js-assets-mocks.ts | 65 ++++++++++ playwright/utils/setup.ts | 124 ++++++++++++++++++ pnpm-lock.yaml | 35 +++++ 9 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 playwright.config.ts create mode 100644 playwright/session-recording.spec.ts create mode 100644 playwright/utils/posthog-js-assets-mocks.ts create mode 100644 playwright/utils/setup.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..c7ba0d03c --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,32 @@ +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 + - 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/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/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..66fdff398 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,79 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './playwright', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 2 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}) diff --git a/playwright/session-recording.spec.ts b/playwright/session-recording.spec.ts new file mode 100644 index 000000000..d35e904de --- /dev/null +++ b/playwright/session-recording.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from './utils/posthog-js-assets-mocks' +import { captures, fullCaptures, resetCaptures, start, WindowWithPostHog } from './utils/setup' + +test.describe('Session recording', () => { + test.describe('array.full.js', () => { + test('captures session events', async ({ page, context }) => { + await start( + { + options: { + session_recording: {}, + }, + decideResponseOverrides: { + isAuthenticated: false, + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, + autocapture_opt_out: true, + }, + }, + page, + context + ) + + await page.locator('[data-cy-input]').fill('hello world! ') + await page.waitForTimeout(500) + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').fill('hello posthog!') + await responsePromise + + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.capture('test_registered_property') + }) + + expect(captures).toEqual(['$pageview', '$snapshot', 'test_registered_property']) + + // don't care about network payloads here + const snapshotData = fullCaptures[1]['properties']['$snapshot_data'].filter((s: any) => s.type !== 6) + + // a meta and then a full snapshot + expect(snapshotData[0].type).toEqual(4) // meta + expect(snapshotData[1].type).toEqual(2) // full_snapshot + expect(snapshotData[2].type).toEqual(5) // custom event with remote config + expect(snapshotData[3].type).toEqual(5) // custom event with options + expect(snapshotData[4].type).toEqual(5) // custom event with posthog config + // Making a set from the rest should all be 3 - incremental snapshots + const incrementalSnapshots = snapshotData.slice(5) + expect(Array.from(new Set(incrementalSnapshots.map((s: any) => s.type)))).toStrictEqual([3]) + + expect(fullCaptures[2]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') + }) + }) + + test.fixme('network capture', () => {}) + + test.describe('array.js', () => { + test.fixme('captures session events', () => {}) + test.fixme('captures snapshots when the mouse moves', () => {}) + test.fixme('continues capturing to the same session when the page reloads', () => {}) + test.fixme('starts a new recording after calling reset', () => {}) + test('rotates sessions after 24 hours', async ({ page, context }) => { + await start( + { + options: { + session_recording: {}, + }, + decideResponseOverrides: { + isAuthenticated: false, + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, + autocapture_opt_out: true, + }, + url: './playground/cypress/index.html', + }, + page, + context + ) + + await page.locator('[data-cy-input]').fill('hello world! ') + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').fill('hello posthog!') + await responsePromise + + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.capture('test_registered_property') + }) + + expect(captures).toEqual(['$pageview', '$snapshot', 'test_registered_property']) + + const firstSessionId = fullCaptures[1]['properties']['$session_id'] + expect(typeof firstSessionId).toEqual('string') + expect(firstSessionId.trim().length).toBeGreaterThan(10) + expect(fullCaptures[2]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') + + resetCaptures() + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + 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 + // @ts-expect-error can ignore that TS thinks these things might be null + ph.sessionManager['_sessionActivityTimestamp'] = activityTs - timeout - 1000 + // @ts-expect-error can ignore that TS thinks these things might be null + ph.sessionManager['_sessionStartTimestamp'] = startTs - timeout - 1000 + }) + + const anotherResponsePromise = page.waitForResponse('**/ses/*') + // using fill here means the session id doesn't rotate, must need some kind of user interaction + await page.locator('[data-cy-input]').type('hello posthog!') + await anotherResponsePromise + + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.capture('test_registered_property') + }) + + expect(captures).toEqual(['$snapshot', 'test_registered_property']) + + expect(fullCaptures[0]['properties']['$session_id']).not.toEqual(firstSessionId) + expect(fullCaptures[0]['properties']['$snapshot_data'][0].type).toEqual(4) // meta + expect(fullCaptures[0]['properties']['$snapshot_data'][1].type).toEqual(2) // full_snapshot + + expect(fullCaptures[1]['properties']['$session_id']).not.toEqual(firstSessionId) + expect(fullCaptures[1]['properties']['$session_recording_start_reason']).toEqual('session_id_changed') + }) + }) + + test.describe.fixme('with sampling', () => {}) +}) diff --git a/playwright/utils/posthog-js-assets-mocks.ts b/playwright/utils/posthog-js-assets-mocks.ts new file mode 100644 index 000000000..6c6b28b24 --- /dev/null +++ b/playwright/utils/posthog-js-assets-mocks.ts @@ -0,0 +1,65 @@ +import * as fs from 'fs' +import { test as base } from '@playwright/test' +import path from 'path' + +const lazyLoadedJSFiles = [ + 'array', + 'array.full', + 'recorder', + 'surveys', + 'exception-autocapture', + 'tracing-headers', + 'web-vitals', + 'dead-clicks-autocapture', +] + +export const test = base.extend<{ mockStaticAssets: void }>({ + mockStaticAssets: [ + async ({ context }, use) => { + // also equivalent of cy.intercept('GET', '/surveys/*').as('surveys') ?? + void context.route('**/e/*', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 1 }), + }) + }) + + void context.route('**/ses/*', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 1 }), + }) + }) + + lazyLoadedJSFiles.forEach((key: string) => { + const jsFilePath = path.resolve(process.cwd(), `dist/${key}.js`) + const fileBody = fs.readFileSync(jsFilePath, 'utf8') + void context.route(new RegExp(`^.*/static/${key}\\.js(\\?.*)?$`), (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: fileBody, + }) + }) + + const jsMapFilePath = path.resolve(process.cwd(), `dist/${key}.js.map`) + const mapFileBody = fs.readFileSync(jsMapFilePath, 'utf8') + void context.route(`**/static/${key}.js.map`, (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: mapFileBody, + }) + }) + }) + + await use() + // there's no teardown, so nothing here + }, + // auto so that tests don't need to remember they need this... every test needs it + { auto: true }, + ], +}) +export { expect } from '@playwright/test' diff --git a/playwright/utils/setup.ts b/playwright/utils/setup.ts new file mode 100644 index 000000000..ee754018f --- /dev/null +++ b/playwright/utils/setup.ts @@ -0,0 +1,124 @@ +import { Page, BrowserContext } from '@playwright/test' +import { CaptureResult, Compression, DecideResponse, PostHogConfig } from '../../src/types' +import { EventEmitter } from 'events' +import { PostHog } from '../../src/posthog-core' +import path from 'path' + +export const captures: string[] = [] +export const fullCaptures: CaptureResult[] = [] + +export const resetCaptures = () => { + captures.length = 0 + fullCaptures.length = 0 +} + +export type WindowWithPostHog = typeof globalThis & { + posthog?: PostHog +} + +export async function start( + { + waitForDecide = true, + initPosthog = true, + resetOnInit = false, + options = {}, + decideResponseOverrides = { + sessionRecording: undefined, + isAuthenticated: false, + capturePerformance: true, + }, + url = './playground/cypress-full/index.html', + }: { + waitForDecide?: boolean + initPosthog?: boolean + resetOnInit?: boolean + options?: Partial + decideResponseOverrides?: Partial + url?: string + }, + page: Page, + context: BrowserContext +) { + // Increase the max listeners for the EventEmitter to avoid warnings in a test environment. + EventEmitter.prototype.setMaxListeners(100) + options.opt_out_useragent_filter = true + + // Prepare the mocked Decide API response + const decideResponse: DecideResponse = { + editorParams: {}, + featureFlags: { 'session-recording-player': true }, + featureFlagPayloads: {}, + errorsWhileComputingFlags: false, + toolbarParams: {}, + toolbarVersion: 'toolbar', + isAuthenticated: false, + siteApps: [], + supportedCompression: [Compression.GZipJS], + autocaptureExceptions: false, + ...decideResponseOverrides, + } + + // allow promise in e2e tests + // eslint-disable-next-line compat/compat + const decideMock = new Promise((resolve) => { + void context.route('**/decide/*', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(decideResponse), + }) + resolve('mock network to decide was triggered') + }) + }) + + // Visit the specified URL + if (url.startsWith('./')) { + const filePath = path.resolve(process.cwd(), url) + // starts with a single slash since otherwise we get three + url = `file://${filePath}` + } + await page.goto(url) + + // Initialize PostHog if required + if (initPosthog) { + await page.exposeFunction('addToFullCaptures', (event: any) => { + captures.push(event.event) + fullCaptures.push(event) + }) + + await page.evaluate( + // TS very unhappy with passing PostHogConfig here, so just pass an object + (posthogOptions: Record) => { + const opts: Partial = { + api_host: 'https://localhost:1234', + debug: true, + before_send: (event) => { + if (event) { + ;(window as any).addToFullCaptures(event) + } + return event + }, + opt_out_useragent_filter: true, + ...posthogOptions, + } + + const windowPosthog = (window as WindowWithPostHog).posthog + windowPosthog?.init('test token', opts) + }, + options as Record + ) + } + + // Reset PostHog if required + if (resetOnInit) { + await page.evaluate(() => { + const windowPosthog = (window as WindowWithPostHog).posthog + windowPosthog?.reset(true) + }) + } + + // Wait for `/decide` response if required + if (waitForDecide) { + await decideMock + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 767f79b78..792788243 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ devDependencies: '@jest/globals': specifier: ^27.5.1 version: 27.5.1 + '@playwright/test': + specifier: ^1.49.1 + version: 1.49.1 '@rollup/plugin-babel': specifier: ^6.0.4 version: 6.0.4(@babel/core@7.18.9)(rollup@4.28.1) @@ -2629,6 +2632,14 @@ packages: engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} dev: true + /@playwright/test@1.49.1: + resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==} + engines: {node: '>=18'} + hasBin: true + dependencies: + playwright: 1.49.1 + dev: true + /@rollup/plugin-babel@6.0.4(@babel/core@7.18.9)(rollup@4.28.1): resolution: {integrity: sha512-YF7Y52kFdFT/xVSuVdjkV5ZdX/3YtmX0QulG+x0taQOtJdHYzVU61aSSkAgVJ7NOv6qPkIYiJSgSWWN/DM5sGw==} engines: {node: '>=14.0.0'} @@ -5752,6 +5763,14 @@ packages: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -8724,6 +8743,22 @@ packages: find-up: 3.0.0 dev: true + /playwright-core@1.49.1: + resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==} + engines: {node: '>=18'} + hasBin: true + dev: true + + /playwright@1.49.1: + resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==} + engines: {node: '>=18'} + hasBin: true + dependencies: + playwright-core: 1.49.1 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /please-upgrade-node@3.2.0: resolution: {integrity: sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==} dependencies: From e56dbfe513623d6963856cbc2723f9cc8839cce5 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 27 Dec 2024 05:42:49 +0000 Subject: [PATCH 02/28] build first --- .github/workflows/playwright.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index c7ba0d03c..394d0afcb 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -20,6 +20,7 @@ jobs: 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 From 0ee120f6dcef24525826a7c47b4e8e5a5e1bf30e Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 27 Dec 2024 06:33:49 +0000 Subject: [PATCH 03/28] port two more tests --- playwright/session-recording.spec.ts | 125 +++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 8 deletions(-) diff --git a/playwright/session-recording.spec.ts b/playwright/session-recording.spec.ts index d35e904de..a28f9c55b 100644 --- a/playwright/session-recording.spec.ts +++ b/playwright/session-recording.spec.ts @@ -1,5 +1,51 @@ import { expect, test } from './utils/posthog-js-assets-mocks' import { captures, fullCaptures, resetCaptures, start, WindowWithPostHog } from './utils/setup' +import { Page } from '@playwright/test' +import { isUndefined } from '../src/utils/type-utils' + +async function ensureRecordingIsStopped(page: Page) { + resetCaptures() + + await page.locator('[data-cy-input]').type('hello posthog!') + // wait a little since we can't wait for the absence of a call to /ses/* + await page.waitForTimeout(250) + expect(fullCaptures.length).toBe(0) +} + +async function ensureActivitySendsSnapshots(page: Page, expectedCustomTags: string[] = []) { + resetCaptures() + + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').type('hello posthog!') + await responsePromise + + const capturedSnapshot = fullCaptures.find((e) => e.event === '$snapshot') + if (isUndefined(capturedSnapshot)) { + throw new Error('No snapshot captured') + } + + const capturedSnapshotData = capturedSnapshot['properties']['$snapshot_data'].filter((s: any) => s.type !== 6) + // first a meta and then a full snapshot + expect(capturedSnapshotData.shift()?.type).toEqual(4) + expect(capturedSnapshotData.shift()?.type).toEqual(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).toBeFalsy() + 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) + expect(customEventTags).toEqual(expectedCustomTags) +} test.describe('Session recording', () => { test.describe('array.full.js', () => { @@ -55,11 +101,7 @@ test.describe('Session recording', () => { test.fixme('network capture', () => {}) test.describe('array.js', () => { - test.fixme('captures session events', () => {}) - test.fixme('captures snapshots when the mouse moves', () => {}) - test.fixme('continues capturing to the same session when the page reloads', () => {}) - test.fixme('starts a new recording after calling reset', () => {}) - test('rotates sessions after 24 hours', async ({ page, context }) => { + test.beforeEach(async ({ page, context }) => { await start( { options: { @@ -78,7 +120,74 @@ test.describe('Session recording', () => { page, context ) + await page.waitForResponse('**/recorder.js*') + expect(captures).toEqual(['$pageview']) + resetCaptures() + }) + + test('captures session events', async ({ page }) => { + const startingSessionId = await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + return ph?.get_session_id() + }) + await ensureActivitySendsSnapshots(page, ['$remote_config_received', '$session_options', '$posthog_config']) + + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.stopSessionRecording() + }) + + await ensureRecordingIsStopped(page) + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.startSessionRecording() + }) + + await ensureActivitySendsSnapshots(page, ['$session_options', '$posthog_config']) + + // the session id is not rotated by stopping and starting the recording + const finishingSessionId = await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + return ph?.get_session_id() + }) + expect(startingSessionId).toEqual(finishingSessionId) + }) + + test('captures snapshots when the mouse moves', async ({ page }) => { + // first make sure the page is booted and recording + await ensureActivitySendsSnapshots(page, ['$remote_config_received', '$session_options', '$posthog_config']) + resetCaptures() + + const responsePromise = page.waitForResponse('**/ses/*') + await page.mouse.move(200, 300) + await page.waitForTimeout(25) + await page.mouse.move(210, 300) + await page.waitForTimeout(25) + await page.mouse.move(220, 300) + await page.waitForTimeout(25) + await page.mouse.move(240, 300) + await page.waitForTimeout(25) + await responsePromise + + const lastCaptured = fullCaptures[fullCaptures.length - 1] + expect(lastCaptured['event']).toEqual('$snapshot') + + const capturedMouseMoves = lastCaptured['properties']['$snapshot_data'].filter((s: any) => { + return s.type === 3 && !!s.data?.positions?.length + }) + expect(capturedMouseMoves.length).toBe(2) + expect(capturedMouseMoves[0].data.positions.length).toBe(1) + expect(capturedMouseMoves[0].data.positions[0].x).toBe(200) + expect(capturedMouseMoves[1].data.positions.length).toBe(1) + // 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(capturedMouseMoves[1].data.positions[0].x).toBeGreaterThan(200) + }) + + test.fixme('continues capturing to the same session when the page reloads', () => {}) + test.fixme('starts a new recording after calling reset', () => {}) + test('rotates sessions after 24 hours', async ({ page }) => { await page.locator('[data-cy-input]').fill('hello world! ') const responsePromise = page.waitForResponse('**/ses/*') await page.locator('[data-cy-input]').fill('hello posthog!') @@ -89,12 +198,12 @@ test.describe('Session recording', () => { ph?.capture('test_registered_property') }) - expect(captures).toEqual(['$pageview', '$snapshot', 'test_registered_property']) + expect(captures).toEqual(['$snapshot', 'test_registered_property']) - const firstSessionId = fullCaptures[1]['properties']['$session_id'] + const firstSessionId = fullCaptures[0]['properties']['$session_id'] expect(typeof firstSessionId).toEqual('string') expect(firstSessionId.trim().length).toBeGreaterThan(10) - expect(fullCaptures[2]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') + expect(fullCaptures[1]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') resetCaptures() await page.evaluate(() => { From 5e90c106f288eac31579884d080d31bbd07f3f24 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 27 Dec 2024 07:45:26 +0000 Subject: [PATCH 04/28] and more --- playwright/session-recording.spec.ts | 48 ++++++++++++++++++++++++++-- playwright/utils/setup.ts | 34 +++++++++++++------- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/playwright/session-recording.spec.ts b/playwright/session-recording.spec.ts index a28f9c55b..fc9767477 100644 --- a/playwright/session-recording.spec.ts +++ b/playwright/session-recording.spec.ts @@ -185,10 +185,54 @@ test.describe('Session recording', () => { expect(capturedMouseMoves[1].data.positions[0].x).toBeGreaterThan(200) }) - test.fixme('continues capturing to the same session when the page reloads', () => {}) + test('continues capturing to the same session when the page reloads', async ({ page }) => { + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').fill('hello posthog!') + await responsePromise + + const firstSessionId = await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + return ph?.get_session_id() + }) + expect(new Set(fullCaptures.map((c) => c['properties']['$session_id']))).toEqual(new Set([firstSessionId])) + + await start( + { + type: 'reload', + decideResponseOverrides: { + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, + }, + }, + page, + page.context() + ) + resetCaptures() + await page.waitForResponse('**/recorder.js*') + + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.capture('some_custom_event') + }) + expect(captures).toEqual(['$pageview', 'some_custom_event']) + expect(fullCaptures[1]['properties']['$session_id']).toEqual(firstSessionId) + expect(fullCaptures[1]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') + expect(fullCaptures[1]['properties']['$recording_status']).toEqual('active') + + resetCaptures() + + const moreResponsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').type('hello posthog!') + await moreResponsePromise + + expect(captures).toEqual(['$snapshot']) + expect(fullCaptures[0]['properties']['$session_id']).toEqual(firstSessionId) + }) + test.fixme('starts a new recording after calling reset', () => {}) test('rotates sessions after 24 hours', async ({ page }) => { - await page.locator('[data-cy-input]').fill('hello world! ') const responsePromise = page.waitForResponse('**/ses/*') await page.locator('[data-cy-input]').fill('hello posthog!') await responsePromise diff --git a/playwright/utils/setup.ts b/playwright/utils/setup.ts index ee754018f..51efdb974 100644 --- a/playwright/utils/setup.ts +++ b/playwright/utils/setup.ts @@ -1,6 +1,5 @@ import { Page, BrowserContext } from '@playwright/test' import { CaptureResult, Compression, DecideResponse, PostHogConfig } from '../../src/types' -import { EventEmitter } from 'events' import { PostHog } from '../../src/posthog-core' import path from 'path' @@ -21,6 +20,7 @@ export async function start( waitForDecide = true, initPosthog = true, resetOnInit = false, + type = 'navigate', options = {}, decideResponseOverrides = { sessionRecording: undefined, @@ -32,6 +32,7 @@ export async function start( waitForDecide?: boolean initPosthog?: boolean resetOnInit?: boolean + type?: 'navigate' | 'reload' options?: Partial decideResponseOverrides?: Partial url?: string @@ -39,8 +40,6 @@ export async function start( page: Page, context: BrowserContext ) { - // Increase the max listeners for the EventEmitter to avoid warnings in a test environment. - EventEmitter.prototype.setMaxListeners(100) options.opt_out_useragent_filter = true // Prepare the mocked Decide API response @@ -71,20 +70,31 @@ export async function start( }) }) - // Visit the specified URL - if (url.startsWith('./')) { - const filePath = path.resolve(process.cwd(), url) - // starts with a single slash since otherwise we get three - url = `file://${filePath}` + if (type === 'reload') { + await page.reload() + } else { + // Visit the specified URL + if (url.startsWith('./')) { + const filePath = path.resolve(process.cwd(), url) + // starts with a single slash since otherwise we get three + url = `file://${filePath}` + } + await page.goto(url) } - await page.goto(url) // Initialize PostHog if required if (initPosthog) { - await page.exposeFunction('addToFullCaptures', (event: any) => { - captures.push(event.event) - fullCaptures.push(event) + // not safe to exposeFunction twice, so check if it's already there + const hasFunctionAlready = await page.evaluate(() => { + // eslint-disable-next-line posthog-js/no-direct-undefined-check + return (window as any).addToFullCaptures !== undefined }) + if (!hasFunctionAlready) { + await page.exposeFunction('addToFullCaptures', (event: any) => { + captures.push(event.event) + fullCaptures.push(event) + }) + } await page.evaluate( // TS very unhappy with passing PostHogConfig here, so just pass an object From 74f6e0dbdef1601240b090ab00006eef2ba95f9f Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sat, 28 Dec 2024 14:50:48 +0000 Subject: [PATCH 05/28] works parallel --- playwright/session-recording.spec.ts | 116 ++++++++++++------ ...cks.ts => posthog-playwright-test-base.ts} | 34 ++++- playwright/utils/setup.ts | 32 +---- 3 files changed, 118 insertions(+), 64 deletions(-) rename playwright/utils/{posthog-js-assets-mocks.ts => posthog-playwright-test-base.ts} (67%) diff --git a/playwright/session-recording.spec.ts b/playwright/session-recording.spec.ts index fc9767477..308f18e45 100644 --- a/playwright/session-recording.spec.ts +++ b/playwright/session-recording.spec.ts @@ -1,25 +1,28 @@ -import { expect, test } from './utils/posthog-js-assets-mocks' -import { captures, fullCaptures, resetCaptures, start, WindowWithPostHog } from './utils/setup' +import { expect, test, WindowWithPostHog } from './utils/posthog-playwright-test-base' +import { start } from './utils/setup' import { Page } from '@playwright/test' import { isUndefined } from '../src/utils/type-utils' async function ensureRecordingIsStopped(page: Page) { - resetCaptures() + await page.resetCapturedEvents() await page.locator('[data-cy-input]').type('hello posthog!') // wait a little since we can't wait for the absence of a call to /ses/* await page.waitForTimeout(250) - expect(fullCaptures.length).toBe(0) + + const capturedEvents = await page.capturedEvents() + expect(capturedEvents).toEqual([]) } async function ensureActivitySendsSnapshots(page: Page, expectedCustomTags: string[] = []) { - resetCaptures() + await page.resetCapturedEvents() const responsePromise = page.waitForResponse('**/ses/*') await page.locator('[data-cy-input]').type('hello posthog!') await responsePromise - const capturedSnapshot = fullCaptures.find((e) => e.event === '$snapshot') + const capturedEvents = await page.capturedEvents() + const capturedSnapshot = capturedEvents?.find((e) => e.event === '$snapshot') if (isUndefined(capturedSnapshot)) { throw new Error('No snapshot captured') } @@ -79,10 +82,11 @@ test.describe('Session recording', () => { ph?.capture('test_registered_property') }) - expect(captures).toEqual(['$pageview', '$snapshot', 'test_registered_property']) + const capturedEvents = await page.capturedEvents() + expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview', '$snapshot', 'test_registered_property']) // don't care about network payloads here - const snapshotData = fullCaptures[1]['properties']['$snapshot_data'].filter((s: any) => s.type !== 6) + const snapshotData = capturedEvents[1]['properties']['$snapshot_data'].filter((s: any) => s.type !== 6) // a meta and then a full snapshot expect(snapshotData[0].type).toEqual(4) // meta @@ -94,7 +98,7 @@ test.describe('Session recording', () => { const incrementalSnapshots = snapshotData.slice(5) expect(Array.from(new Set(incrementalSnapshots.map((s: any) => s.type)))).toStrictEqual([3]) - expect(fullCaptures[2]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') + expect(capturedEvents[2]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') }) }) @@ -121,8 +125,9 @@ test.describe('Session recording', () => { context ) await page.waitForResponse('**/recorder.js*') - expect(captures).toEqual(['$pageview']) - resetCaptures() + const capturedEvents = await page.evaluate(() => (window as WindowWithPostHog).capturedEvents || []) + expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview']) + await page.resetCapturedEvents() }) test('captures session events', async ({ page }) => { @@ -157,7 +162,7 @@ test.describe('Session recording', () => { test('captures snapshots when the mouse moves', async ({ page }) => { // first make sure the page is booted and recording await ensureActivitySendsSnapshots(page, ['$remote_config_received', '$session_options', '$posthog_config']) - resetCaptures() + await page.resetCapturedEvents() const responsePromise = page.waitForResponse('**/ses/*') await page.mouse.move(200, 300) @@ -170,7 +175,8 @@ test.describe('Session recording', () => { await page.waitForTimeout(25) await responsePromise - const lastCaptured = fullCaptures[fullCaptures.length - 1] + const capturedEvents = await page.capturedEvents() + const lastCaptured = capturedEvents[capturedEvents.length - 1] expect(lastCaptured['event']).toEqual('$snapshot') const capturedMouseMoves = lastCaptured['properties']['$snapshot_data'].filter((s: any) => { @@ -179,9 +185,9 @@ test.describe('Session recording', () => { expect(capturedMouseMoves.length).toBe(2) expect(capturedMouseMoves[0].data.positions.length).toBe(1) expect(capturedMouseMoves[0].data.positions[0].x).toBe(200) - expect(capturedMouseMoves[1].data.positions.length).toBe(1) // 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(capturedMouseMoves[1].data.positions.length).toBeGreaterThan(0) expect(capturedMouseMoves[1].data.positions[0].x).toBeGreaterThan(200) }) @@ -194,8 +200,12 @@ test.describe('Session recording', () => { const ph = (window as WindowWithPostHog).posthog return ph?.get_session_id() }) - expect(new Set(fullCaptures.map((c) => c['properties']['$session_id']))).toEqual(new Set([firstSessionId])) + const capturedEvents = await page.capturedEvents() + expect(new Set(capturedEvents.map((c) => c['properties']['$session_id']))).toEqual( + new Set([firstSessionId]) + ) + const waitForRecorder = page.waitForResponse('**/recorder.js*') await start( { type: 'reload', @@ -209,29 +219,61 @@ test.describe('Session recording', () => { page, page.context() ) - resetCaptures() - await page.waitForResponse('**/recorder.js*') + + await page.resetCapturedEvents() + await waitForRecorder await page.evaluate(() => { const ph = (window as WindowWithPostHog).posthog ph?.capture('some_custom_event') }) - expect(captures).toEqual(['$pageview', 'some_custom_event']) - expect(fullCaptures[1]['properties']['$session_id']).toEqual(firstSessionId) - expect(fullCaptures[1]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') - expect(fullCaptures[1]['properties']['$recording_status']).toEqual('active') + const capturedAfterReload = await page.capturedEvents() + expect(capturedAfterReload.map((x) => x.event)).toEqual(['some_custom_event']) + expect(capturedAfterReload[0]['properties']['$session_id']).toEqual(firstSessionId) + expect(capturedAfterReload[0]['properties']['$session_recording_start_reason']).toEqual( + 'recording_initialized' + ) + expect(capturedAfterReload[0]['properties']['$recording_status']).toEqual('active') - resetCaptures() + await page.resetCapturedEvents() const moreResponsePromise = page.waitForResponse('**/ses/*') await page.locator('[data-cy-input]').type('hello posthog!') await moreResponsePromise - expect(captures).toEqual(['$snapshot']) - expect(fullCaptures[0]['properties']['$session_id']).toEqual(firstSessionId) + const capturedAfterActivity = await page.capturedEvents() + expect(capturedAfterActivity.map((x) => x.event)).toEqual(['$snapshot']) + expect(capturedAfterActivity[0]['properties']['$session_id']).toEqual(firstSessionId) + }) + + test('starts a new recording after calling reset', async ({ page }) => { + await page.resetCapturedEvents() + const startingSessionId = await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + return ph?.get_session_id() + }) + expect(startingSessionId).not.toBeNull() + + await ensureActivitySendsSnapshots(page, ['$remote_config_received', '$session_options', '$posthog_config']) + + await page.resetCapturedEvents() + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.reset() + }) + + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').fill('hello posthog!') + await responsePromise + + const capturedEvents = await page.capturedEvents() + const postResetSessionIds = new Set(capturedEvents.map((c) => c['properties']['$session_id'])) + expect(postResetSessionIds.size).toEqual(1) + const replayCapturedSessionId = Array.from(postResetSessionIds)[0] + + expect(replayCapturedSessionId).not.toEqual(startingSessionId) }) - test.fixme('starts a new recording after calling reset', () => {}) test('rotates sessions after 24 hours', async ({ page }) => { const responsePromise = page.waitForResponse('**/ses/*') await page.locator('[data-cy-input]').fill('hello posthog!') @@ -242,14 +284,15 @@ test.describe('Session recording', () => { ph?.capture('test_registered_property') }) - expect(captures).toEqual(['$snapshot', 'test_registered_property']) + const capturedEvents = await page.capturedEvents() + expect(capturedEvents.map((x) => x.event)).toEqual(['$snapshot', 'test_registered_property']) - const firstSessionId = fullCaptures[0]['properties']['$session_id'] + const firstSessionId = capturedEvents[0]['properties']['$session_id'] expect(typeof firstSessionId).toEqual('string') expect(firstSessionId.trim().length).toBeGreaterThan(10) - expect(fullCaptures[1]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') + expect(capturedEvents[1]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') - resetCaptures() + await page.resetCapturedEvents() await page.evaluate(() => { const ph = (window as WindowWithPostHog).posthog const activityTs = ph?.sessionManager?.['_sessionActivityTimestamp'] @@ -274,14 +317,17 @@ test.describe('Session recording', () => { ph?.capture('test_registered_property') }) - expect(captures).toEqual(['$snapshot', 'test_registered_property']) + const capturedEventsAfter24Hours = await page.capturedEvents() + expect(capturedEventsAfter24Hours.map((x) => x.event)).toEqual(['$snapshot', 'test_registered_property']) - expect(fullCaptures[0]['properties']['$session_id']).not.toEqual(firstSessionId) - expect(fullCaptures[0]['properties']['$snapshot_data'][0].type).toEqual(4) // meta - expect(fullCaptures[0]['properties']['$snapshot_data'][1].type).toEqual(2) // full_snapshot + expect(capturedEventsAfter24Hours[0]['properties']['$session_id']).not.toEqual(firstSessionId) + expect(capturedEventsAfter24Hours[0]['properties']['$snapshot_data'][0].type).toEqual(4) // meta + expect(capturedEventsAfter24Hours[0]['properties']['$snapshot_data'][1].type).toEqual(2) // full_snapshot - expect(fullCaptures[1]['properties']['$session_id']).not.toEqual(firstSessionId) - expect(fullCaptures[1]['properties']['$session_recording_start_reason']).toEqual('session_id_changed') + expect(capturedEventsAfter24Hours[1]['properties']['$session_id']).not.toEqual(firstSessionId) + expect(capturedEventsAfter24Hours[1]['properties']['$session_recording_start_reason']).toEqual( + 'session_id_changed' + ) }) }) diff --git a/playwright/utils/posthog-js-assets-mocks.ts b/playwright/utils/posthog-playwright-test-base.ts similarity index 67% rename from playwright/utils/posthog-js-assets-mocks.ts rename to playwright/utils/posthog-playwright-test-base.ts index 6c6b28b24..c07ef6ce1 100644 --- a/playwright/utils/posthog-js-assets-mocks.ts +++ b/playwright/utils/posthog-playwright-test-base.ts @@ -1,6 +1,8 @@ import * as fs from 'fs' -import { test as base } from '@playwright/test' +import { test as base, Page } from '@playwright/test' import path from 'path' +import { PostHog } from '../../src/posthog-core' +import { CaptureResult } from '../../src/types' const lazyLoadedJSFiles = [ 'array', @@ -13,7 +15,35 @@ const lazyLoadedJSFiles = [ 'dead-clicks-autocapture', ] -export const test = base.extend<{ mockStaticAssets: void }>({ +export type WindowWithPostHog = typeof globalThis & { + posthog?: PostHog + capturedEvents?: CaptureResult[] +} + +declare module '@playwright/test' { + interface Page { + resetCapturedEvents(): Promise + capturedEvents(): Promise + } +} + +export const test = base.extend<{ mockStaticAssets: void; page: Page }>({ + page: async ({ page }, use) => { + // Add custom methods to the page object + page.resetCapturedEvents = async function () { + await this.evaluate(() => { + ;(window as WindowWithPostHog).capturedEvents = [] + }) + } + page.capturedEvents = async function () { + return this.evaluate(() => { + return (window as WindowWithPostHog).capturedEvents || [] + }) + } + + // Pass the extended page to the test + await use(page) + }, mockStaticAssets: [ async ({ context }, use) => { // also equivalent of cy.intercept('GET', '/surveys/*').as('surveys') ?? diff --git a/playwright/utils/setup.ts b/playwright/utils/setup.ts index 51efdb974..2e6dc7fde 100644 --- a/playwright/utils/setup.ts +++ b/playwright/utils/setup.ts @@ -1,19 +1,7 @@ import { Page, BrowserContext } from '@playwright/test' -import { CaptureResult, Compression, DecideResponse, PostHogConfig } from '../../src/types' -import { PostHog } from '../../src/posthog-core' +import { Compression, DecideResponse, PostHogConfig } from '../../src/types' import path from 'path' - -export const captures: string[] = [] -export const fullCaptures: CaptureResult[] = [] - -export const resetCaptures = () => { - captures.length = 0 - fullCaptures.length = 0 -} - -export type WindowWithPostHog = typeof globalThis & { - posthog?: PostHog -} +import { WindowWithPostHog } from './posthog-js-assets-mocks' export async function start( { @@ -84,18 +72,6 @@ export async function start( // Initialize PostHog if required if (initPosthog) { - // not safe to exposeFunction twice, so check if it's already there - const hasFunctionAlready = await page.evaluate(() => { - // eslint-disable-next-line posthog-js/no-direct-undefined-check - return (window as any).addToFullCaptures !== undefined - }) - if (!hasFunctionAlready) { - await page.exposeFunction('addToFullCaptures', (event: any) => { - captures.push(event.event) - fullCaptures.push(event) - }) - } - await page.evaluate( // TS very unhappy with passing PostHogConfig here, so just pass an object (posthogOptions: Record) => { @@ -104,7 +80,9 @@ export async function start( debug: true, before_send: (event) => { if (event) { - ;(window as any).addToFullCaptures(event) + const win = window as WindowWithPostHog + win.capturedEvents = win.capturedEvents || [] + win.capturedEvents.push(event) } return event }, From ff522947d2016e27a9b7ee9821ac3b5ebbe7ae0f Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sat, 28 Dec 2024 14:52:47 +0000 Subject: [PATCH 06/28] works parallel --- playwright/utils/posthog-playwright-test-base.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/playwright/utils/posthog-playwright-test-base.ts b/playwright/utils/posthog-playwright-test-base.ts index c07ef6ce1..775d9e5dc 100644 --- a/playwright/utils/posthog-playwright-test-base.ts +++ b/playwright/utils/posthog-playwright-test-base.ts @@ -21,6 +21,12 @@ export type WindowWithPostHog = typeof globalThis & { } declare module '@playwright/test' { + /* + to support tests running in parallel + we keep captured events in the window object + for a page with custom methods added + to the Playwright Page object + */ interface Page { resetCapturedEvents(): Promise capturedEvents(): Promise From 658a5a6d2ab0805c7b232a87aea11462902ea56c Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sat, 28 Dec 2024 14:57:39 +0000 Subject: [PATCH 07/28] delete copied cypress tests --- cypress/e2e/session-recording.cy.ts | 441 ---------------------------- 1 file changed, 441 deletions(-) diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts index 35886ff73..dae5d7cd5 100644 --- a/cypress/e2e/session-recording.cy.ts +++ b/cypress/e2e/session-recording.cy.ts @@ -1,93 +1,8 @@ /// -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, @@ -115,54 +30,6 @@ function wrapFetchInCypress({ } 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' @@ -346,314 +213,6 @@ describe('Session recording', () => { }) }) - 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({ From 623686358ef9ccb62029de3c9f9e65c401de3dd3 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sat, 28 Dec 2024 15:21:41 +0000 Subject: [PATCH 08/28] move sampling tests --- cypress/e2e/session-recording.cy.ts | 109 ----------------------- playwright/session-recording.spec.ts | 125 ++++++++++++++++++--------- 2 files changed, 83 insertions(+), 151 deletions(-) diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts index dae5d7cd5..88dd91c52 100644 --- a/cypress/e2e/session-recording.cy.ts +++ b/cypress/e2e/session-recording.cy.ts @@ -1,7 +1,6 @@ /// import { start } from '../support/setup' -import { assertWhetherPostHogRequestsWereCalled, pollPhCaptures } from '../support/assertions' function wrapFetchInCypress({ originalFetch, @@ -212,112 +211,4 @@ describe('Session recording', () => { }) }) }) - - 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/playwright/session-recording.spec.ts b/playwright/session-recording.spec.ts index 308f18e45..6beae76ec 100644 --- a/playwright/session-recording.spec.ts +++ b/playwright/session-recording.spec.ts @@ -50,26 +50,29 @@ async function ensureActivitySendsSnapshots(page: Page, expectedCustomTags: stri expect(customEventTags).toEqual(expectedCustomTags) } +const startOptions = { + options: { + session_recording: {}, + }, + decideResponseOverrides: { + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, + autocapture_opt_out: true, + }, + url: './playground/cypress/index.html', +} + test.describe('Session recording', () => { + const arrayFullStartOptions = { + ...startOptions, + url: './playground/cypress-full/index.html', + } + test.describe('array.full.js', () => { test('captures session events', async ({ page, context }) => { - await start( - { - options: { - session_recording: {}, - }, - decideResponseOverrides: { - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - }, - capturePerformance: true, - autocapture_opt_out: true, - }, - }, - page, - context - ) + await start(arrayFullStartOptions, page, context) await page.locator('[data-cy-input]').fill('hello world! ') await page.waitForTimeout(500) @@ -106,24 +109,7 @@ test.describe('Session recording', () => { test.describe('array.js', () => { test.beforeEach(async ({ page, context }) => { - await start( - { - options: { - session_recording: {}, - }, - decideResponseOverrides: { - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - }, - capturePerformance: true, - autocapture_opt_out: true, - }, - url: './playground/cypress/index.html', - }, - page, - context - ) + await start(startOptions, page, context) await page.waitForResponse('**/recorder.js*') const capturedEvents = await page.evaluate(() => (window as WindowWithPostHog).capturedEvents || []) expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview']) @@ -208,13 +194,8 @@ test.describe('Session recording', () => { const waitForRecorder = page.waitForResponse('**/recorder.js*') await start( { + ...startOptions, type: 'reload', - decideResponseOverrides: { - sessionRecording: { - endpoint: '/ses/', - }, - capturePerformance: true, - }, }, page, page.context() @@ -331,5 +312,65 @@ test.describe('Session recording', () => { }) }) - test.describe.fixme('with sampling', () => {}) + test.describe('with sampling', () => { + const sampleZeroStartOptions = { + ...startOptions, + decideResponseOverrides: { + ...startOptions.decideResponseOverrides, + sessionRecording: { + ...startOptions.decideResponseOverrides.sessionRecording, + sampleRate: '0', + }, + }, + } + test.beforeEach(async ({ page, context }) => { + await start(startOptions, page, context) + + await page.waitForResponse('**/recorder.js*') + const capturedEvents = await page.evaluate(() => (window as WindowWithPostHog).capturedEvents || []) + expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview']) + await page.resetCapturedEvents() + }) + + test('does not capture events when sampling is set to 0', async ({ page }) => { + await page.locator('[data-cy-input]').fill('hello posthog!') + // because it doesn't make sense to wait for a snapshot event that won't happen + await page.waitForTimeout(250) + + const capturedEvents = await page.capturedEvents() + expect(capturedEvents).toEqual([]) + }) + + test('can override sampling when starting session recording', async ({ page, context }) => { + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.startSessionRecording({ sampling: true }) + ph?.capture('test_registered_property') + }) + const capturedEvents = await page.capturedEvents() + expect(capturedEvents.map((x) => x.event)).toEqual(['test_registered_property']) + expect(capturedEvents[0]['properties']['$session_recording_start_reason']).toEqual('sampling_overridden') + + // sampling override survives a page refresh + await page.resetCapturedEvents() + await page.reload() + + await start( + { + ...sampleZeroStartOptions, + type: 'reload', + }, + page, + context + ) + await page.waitForResponse('**/recorder.js*') + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').fill('hello posthog!') + await responsePromise + + const afterReloadCapturedEvents = await page.capturedEvents() + const lastCaptured = afterReloadCapturedEvents[afterReloadCapturedEvents.length - 1] + expect(lastCaptured['event']).toEqual('$snapshot') + }) + }) }) From be6e7c2cb89a7e597e9cfc109000bedc6b2ddc64 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sat, 28 Dec 2024 15:31:37 +0000 Subject: [PATCH 09/28] no need to start with very large test files --- .../session-recording-array-full.spec.ts | 49 ++ ...session-recording-network-recorder.spec.ts | 5 + playwright/session-recording-sampling.spec.ts | 78 +++ playwright/session-recording.spec.ts | 450 +++++++----------- 4 files changed, 302 insertions(+), 280 deletions(-) create mode 100644 playwright/session-recording-array-full.spec.ts create mode 100644 playwright/session-recording-network-recorder.spec.ts create mode 100644 playwright/session-recording-sampling.spec.ts diff --git a/playwright/session-recording-array-full.spec.ts b/playwright/session-recording-array-full.spec.ts new file mode 100644 index 000000000..18a00ea93 --- /dev/null +++ b/playwright/session-recording-array-full.spec.ts @@ -0,0 +1,49 @@ +import { expect, test, WindowWithPostHog } from './utils/posthog-playwright-test-base' +import { start } from './utils/setup' + +const startOptions = { + options: { + session_recording: {}, + }, + decideResponseOverrides: { + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, + autocapture_opt_out: true, + }, + url: './playground/cypress-full/index.html', +} + +test.describe('session recording in array.full.js', () => { + test('captures session events', async ({ page, context }) => { + await start(startOptions, page, context) + + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').fill('hello posthog!') + await responsePromise + + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.capture('test_registered_property') + }) + + const capturedEvents = await page.capturedEvents() + expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview', '$snapshot', 'test_registered_property']) + + // don't care about network payloads here + const snapshotData = capturedEvents[1]['properties']['$snapshot_data'].filter((s: any) => s.type !== 6) + + // a meta and then a full snapshot + expect(snapshotData[0].type).toEqual(4) // meta + expect(snapshotData[1].type).toEqual(2) // full_snapshot + expect(snapshotData[2].type).toEqual(5) // custom event with remote config + expect(snapshotData[3].type).toEqual(5) // custom event with options + expect(snapshotData[4].type).toEqual(5) // custom event with posthog config + // Making a set from the rest should all be 3 - incremental snapshots + const incrementalSnapshots = snapshotData.slice(5) + expect(Array.from(new Set(incrementalSnapshots.map((s: any) => s.type)))).toStrictEqual([3]) + + expect(capturedEvents[2]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') + }) +}) diff --git a/playwright/session-recording-network-recorder.spec.ts b/playwright/session-recording-network-recorder.spec.ts new file mode 100644 index 000000000..b231ef146 --- /dev/null +++ b/playwright/session-recording-network-recorder.spec.ts @@ -0,0 +1,5 @@ +import { test } from './utils/posthog-playwright-test-base' + +test.describe('Session recording - array.js', () => { + test.fixme('network capture', () => {}) +}) diff --git a/playwright/session-recording-sampling.spec.ts b/playwright/session-recording-sampling.spec.ts new file mode 100644 index 000000000..3c8acce95 --- /dev/null +++ b/playwright/session-recording-sampling.spec.ts @@ -0,0 +1,78 @@ +import { expect, test, WindowWithPostHog } from './utils/posthog-playwright-test-base' +import { start } from './utils/setup' + +const startOptions = { + options: { + session_recording: {}, + }, + decideResponseOverrides: { + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, + autocapture_opt_out: true, + }, + url: './playground/cypress/index.html', +} + +test.describe('Session recording - sampling', () => { + const sampleZeroStartOptions = { + ...startOptions, + decideResponseOverrides: { + ...startOptions.decideResponseOverrides, + sessionRecording: { + ...startOptions.decideResponseOverrides.sessionRecording, + sampleRate: '0', + }, + }, + } + test.beforeEach(async ({ page, context }) => { + await start(startOptions, page, context) + + await page.waitForResponse('**/recorder.js*') + const capturedEvents = await page.evaluate(() => (window as WindowWithPostHog).capturedEvents || []) + expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview']) + await page.resetCapturedEvents() + }) + + test('does not capture events when sampling is set to 0', async ({ page }) => { + await page.locator('[data-cy-input]').fill('hello posthog!') + // because it doesn't make sense to wait for a snapshot event that won't happen + await page.waitForTimeout(250) + + const capturedEvents = await page.capturedEvents() + expect(capturedEvents).toEqual([]) + }) + + test('can override sampling when starting session recording', async ({ page, context }) => { + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.startSessionRecording({ sampling: true }) + ph?.capture('test_registered_property') + }) + const capturedEvents = await page.capturedEvents() + expect(capturedEvents.map((x) => x.event)).toEqual(['test_registered_property']) + expect(capturedEvents[0]['properties']['$session_recording_start_reason']).toEqual('sampling_overridden') + + // sampling override survives a page refresh + await page.resetCapturedEvents() + await page.reload() + + await start( + { + ...sampleZeroStartOptions, + type: 'reload', + }, + page, + context + ) + await page.waitForResponse('**/recorder.js*') + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').fill('hello posthog!') + await responsePromise + + const afterReloadCapturedEvents = await page.capturedEvents() + const lastCaptured = afterReloadCapturedEvents[afterReloadCapturedEvents.length - 1] + expect(lastCaptured['event']).toEqual('$snapshot') + }) +}) diff --git a/playwright/session-recording.spec.ts b/playwright/session-recording.spec.ts index 6beae76ec..a96e30738 100644 --- a/playwright/session-recording.spec.ts +++ b/playwright/session-recording.spec.ts @@ -64,313 +64,203 @@ const startOptions = { url: './playground/cypress/index.html', } -test.describe('Session recording', () => { - const arrayFullStartOptions = { - ...startOptions, - url: './playground/cypress-full/index.html', - } +test.describe('Session recording - array.js', () => { + test.beforeEach(async ({ page, context }) => { + await start(startOptions, page, context) + await page.waitForResponse('**/recorder.js*') + const capturedEvents = await page.evaluate(() => (window as WindowWithPostHog).capturedEvents || []) + expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview']) + await page.resetCapturedEvents() + }) - test.describe('array.full.js', () => { - test('captures session events', async ({ page, context }) => { - await start(arrayFullStartOptions, page, context) - - await page.locator('[data-cy-input]').fill('hello world! ') - await page.waitForTimeout(500) - const responsePromise = page.waitForResponse('**/ses/*') - await page.locator('[data-cy-input]').fill('hello posthog!') - await responsePromise - - await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - ph?.capture('test_registered_property') - }) - - const capturedEvents = await page.capturedEvents() - expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview', '$snapshot', 'test_registered_property']) - - // don't care about network payloads here - const snapshotData = capturedEvents[1]['properties']['$snapshot_data'].filter((s: any) => s.type !== 6) - - // a meta and then a full snapshot - expect(snapshotData[0].type).toEqual(4) // meta - expect(snapshotData[1].type).toEqual(2) // full_snapshot - expect(snapshotData[2].type).toEqual(5) // custom event with remote config - expect(snapshotData[3].type).toEqual(5) // custom event with options - expect(snapshotData[4].type).toEqual(5) // custom event with posthog config - // Making a set from the rest should all be 3 - incremental snapshots - const incrementalSnapshots = snapshotData.slice(5) - expect(Array.from(new Set(incrementalSnapshots.map((s: any) => s.type)))).toStrictEqual([3]) - - expect(capturedEvents[2]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') + test('captures session events', async ({ page }) => { + const startingSessionId = await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + return ph?.get_session_id() }) - }) + await ensureActivitySendsSnapshots(page, ['$remote_config_received', '$session_options', '$posthog_config']) - test.fixme('network capture', () => {}) + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.stopSessionRecording() + }) + + await ensureRecordingIsStopped(page) - test.describe('array.js', () => { - test.beforeEach(async ({ page, context }) => { - await start(startOptions, page, context) - await page.waitForResponse('**/recorder.js*') - const capturedEvents = await page.evaluate(() => (window as WindowWithPostHog).capturedEvents || []) - expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview']) - await page.resetCapturedEvents() + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.startSessionRecording() }) - test('captures session events', async ({ page }) => { - const startingSessionId = await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - return ph?.get_session_id() - }) - await ensureActivitySendsSnapshots(page, ['$remote_config_received', '$session_options', '$posthog_config']) - - await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - ph?.stopSessionRecording() - }) - - await ensureRecordingIsStopped(page) - - await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - ph?.startSessionRecording() - }) - - await ensureActivitySendsSnapshots(page, ['$session_options', '$posthog_config']) - - // the session id is not rotated by stopping and starting the recording - const finishingSessionId = await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - return ph?.get_session_id() - }) - expect(startingSessionId).toEqual(finishingSessionId) + await ensureActivitySendsSnapshots(page, ['$session_options', '$posthog_config']) + + // the session id is not rotated by stopping and starting the recording + const finishingSessionId = await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + return ph?.get_session_id() }) + expect(startingSessionId).toEqual(finishingSessionId) + }) - test('captures snapshots when the mouse moves', async ({ page }) => { - // first make sure the page is booted and recording - await ensureActivitySendsSnapshots(page, ['$remote_config_received', '$session_options', '$posthog_config']) - await page.resetCapturedEvents() - - const responsePromise = page.waitForResponse('**/ses/*') - await page.mouse.move(200, 300) - await page.waitForTimeout(25) - await page.mouse.move(210, 300) - await page.waitForTimeout(25) - await page.mouse.move(220, 300) - await page.waitForTimeout(25) - await page.mouse.move(240, 300) - await page.waitForTimeout(25) - await responsePromise - - const capturedEvents = await page.capturedEvents() - const lastCaptured = capturedEvents[capturedEvents.length - 1] - expect(lastCaptured['event']).toEqual('$snapshot') - - const capturedMouseMoves = lastCaptured['properties']['$snapshot_data'].filter((s: any) => { - return s.type === 3 && !!s.data?.positions?.length - }) - expect(capturedMouseMoves.length).toBe(2) - expect(capturedMouseMoves[0].data.positions.length).toBe(1) - expect(capturedMouseMoves[0].data.positions[0].x).toBe(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(capturedMouseMoves[1].data.positions.length).toBeGreaterThan(0) - expect(capturedMouseMoves[1].data.positions[0].x).toBeGreaterThan(200) + test('captures snapshots when the mouse moves', async ({ page }) => { + // first make sure the page is booted and recording + await ensureActivitySendsSnapshots(page, ['$remote_config_received', '$session_options', '$posthog_config']) + await page.resetCapturedEvents() + + const responsePromise = page.waitForResponse('**/ses/*') + await page.mouse.move(200, 300) + await page.waitForTimeout(25) + await page.mouse.move(210, 300) + await page.waitForTimeout(25) + await page.mouse.move(220, 300) + await page.waitForTimeout(25) + await page.mouse.move(240, 300) + await page.waitForTimeout(25) + await responsePromise + + const capturedEvents = await page.capturedEvents() + const lastCaptured = capturedEvents[capturedEvents.length - 1] + expect(lastCaptured['event']).toEqual('$snapshot') + + const capturedMouseMoves = lastCaptured['properties']['$snapshot_data'].filter((s: any) => { + return s.type === 3 && !!s.data?.positions?.length }) + expect(capturedMouseMoves.length).toBe(2) + expect(capturedMouseMoves[0].data.positions.length).toBe(1) + expect(capturedMouseMoves[0].data.positions[0].x).toBe(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(capturedMouseMoves[1].data.positions.length).toBeGreaterThan(0) + expect(capturedMouseMoves[1].data.positions[0].x).toBeGreaterThan(200) + }) + + test('continues capturing to the same session when the page reloads', async ({ page }) => { + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').fill('hello posthog!') + await responsePromise - test('continues capturing to the same session when the page reloads', async ({ page }) => { - const responsePromise = page.waitForResponse('**/ses/*') - await page.locator('[data-cy-input]').fill('hello posthog!') - await responsePromise - - const firstSessionId = await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - return ph?.get_session_id() - }) - const capturedEvents = await page.capturedEvents() - expect(new Set(capturedEvents.map((c) => c['properties']['$session_id']))).toEqual( - new Set([firstSessionId]) - ) - - const waitForRecorder = page.waitForResponse('**/recorder.js*') - await start( - { - ...startOptions, - type: 'reload', - }, - page, - page.context() - ) - - await page.resetCapturedEvents() - await waitForRecorder - - await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - ph?.capture('some_custom_event') - }) - const capturedAfterReload = await page.capturedEvents() - expect(capturedAfterReload.map((x) => x.event)).toEqual(['some_custom_event']) - expect(capturedAfterReload[0]['properties']['$session_id']).toEqual(firstSessionId) - expect(capturedAfterReload[0]['properties']['$session_recording_start_reason']).toEqual( - 'recording_initialized' - ) - expect(capturedAfterReload[0]['properties']['$recording_status']).toEqual('active') - - await page.resetCapturedEvents() - - const moreResponsePromise = page.waitForResponse('**/ses/*') - await page.locator('[data-cy-input]').type('hello posthog!') - await moreResponsePromise - - const capturedAfterActivity = await page.capturedEvents() - expect(capturedAfterActivity.map((x) => x.event)).toEqual(['$snapshot']) - expect(capturedAfterActivity[0]['properties']['$session_id']).toEqual(firstSessionId) + const firstSessionId = await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + return ph?.get_session_id() }) + const capturedEvents = await page.capturedEvents() + expect(new Set(capturedEvents.map((c) => c['properties']['$session_id']))).toEqual(new Set([firstSessionId])) + + const waitForRecorder = page.waitForResponse('**/recorder.js*') + await start( + { + ...startOptions, + type: 'reload', + }, + page, + page.context() + ) - test('starts a new recording after calling reset', async ({ page }) => { - await page.resetCapturedEvents() - const startingSessionId = await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - return ph?.get_session_id() - }) - expect(startingSessionId).not.toBeNull() + await page.resetCapturedEvents() + await waitForRecorder - await ensureActivitySendsSnapshots(page, ['$remote_config_received', '$session_options', '$posthog_config']) + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.capture('some_custom_event') + }) + const capturedAfterReload = await page.capturedEvents() + expect(capturedAfterReload.map((x) => x.event)).toEqual(['some_custom_event']) + expect(capturedAfterReload[0]['properties']['$session_id']).toEqual(firstSessionId) + expect(capturedAfterReload[0]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') + expect(capturedAfterReload[0]['properties']['$recording_status']).toEqual('active') - await page.resetCapturedEvents() - await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - ph?.reset() - }) + await page.resetCapturedEvents() - const responsePromise = page.waitForResponse('**/ses/*') - await page.locator('[data-cy-input]').fill('hello posthog!') - await responsePromise + const moreResponsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').type('hello posthog!') + await moreResponsePromise - const capturedEvents = await page.capturedEvents() - const postResetSessionIds = new Set(capturedEvents.map((c) => c['properties']['$session_id'])) - expect(postResetSessionIds.size).toEqual(1) - const replayCapturedSessionId = Array.from(postResetSessionIds)[0] + const capturedAfterActivity = await page.capturedEvents() + expect(capturedAfterActivity.map((x) => x.event)).toEqual(['$snapshot']) + expect(capturedAfterActivity[0]['properties']['$session_id']).toEqual(firstSessionId) + }) - expect(replayCapturedSessionId).not.toEqual(startingSessionId) + test('starts a new recording after calling reset', async ({ page }) => { + await page.resetCapturedEvents() + const startingSessionId = await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + return ph?.get_session_id() }) + expect(startingSessionId).not.toBeNull() - test('rotates sessions after 24 hours', async ({ page }) => { - const responsePromise = page.waitForResponse('**/ses/*') - await page.locator('[data-cy-input]').fill('hello posthog!') - await responsePromise - - await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - ph?.capture('test_registered_property') - }) - - const capturedEvents = await page.capturedEvents() - expect(capturedEvents.map((x) => x.event)).toEqual(['$snapshot', 'test_registered_property']) - - const firstSessionId = capturedEvents[0]['properties']['$session_id'] - expect(typeof firstSessionId).toEqual('string') - expect(firstSessionId.trim().length).toBeGreaterThan(10) - expect(capturedEvents[1]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') - - await page.resetCapturedEvents() - await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - 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 - // @ts-expect-error can ignore that TS thinks these things might be null - ph.sessionManager['_sessionActivityTimestamp'] = activityTs - timeout - 1000 - // @ts-expect-error can ignore that TS thinks these things might be null - ph.sessionManager['_sessionStartTimestamp'] = startTs - timeout - 1000 - }) - - const anotherResponsePromise = page.waitForResponse('**/ses/*') - // using fill here means the session id doesn't rotate, must need some kind of user interaction - await page.locator('[data-cy-input]').type('hello posthog!') - await anotherResponsePromise - - await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - ph?.capture('test_registered_property') - }) - - const capturedEventsAfter24Hours = await page.capturedEvents() - expect(capturedEventsAfter24Hours.map((x) => x.event)).toEqual(['$snapshot', 'test_registered_property']) - - expect(capturedEventsAfter24Hours[0]['properties']['$session_id']).not.toEqual(firstSessionId) - expect(capturedEventsAfter24Hours[0]['properties']['$snapshot_data'][0].type).toEqual(4) // meta - expect(capturedEventsAfter24Hours[0]['properties']['$snapshot_data'][1].type).toEqual(2) // full_snapshot - - expect(capturedEventsAfter24Hours[1]['properties']['$session_id']).not.toEqual(firstSessionId) - expect(capturedEventsAfter24Hours[1]['properties']['$session_recording_start_reason']).toEqual( - 'session_id_changed' - ) + await ensureActivitySendsSnapshots(page, ['$remote_config_received', '$session_options', '$posthog_config']) + + await page.resetCapturedEvents() + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.reset() }) + + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').fill('hello posthog!') + await responsePromise + + const capturedEvents = await page.capturedEvents() + const postResetSessionIds = new Set(capturedEvents.map((c) => c['properties']['$session_id'])) + expect(postResetSessionIds.size).toEqual(1) + const replayCapturedSessionId = Array.from(postResetSessionIds)[0] + + expect(replayCapturedSessionId).not.toEqual(startingSessionId) }) - test.describe('with sampling', () => { - const sampleZeroStartOptions = { - ...startOptions, - decideResponseOverrides: { - ...startOptions.decideResponseOverrides, - sessionRecording: { - ...startOptions.decideResponseOverrides.sessionRecording, - sampleRate: '0', - }, - }, - } - test.beforeEach(async ({ page, context }) => { - await start(startOptions, page, context) + test('rotates sessions after 24 hours', async ({ page }) => { + const responsePromise = page.waitForResponse('**/ses/*') + await page.locator('[data-cy-input]').fill('hello posthog!') + await responsePromise - await page.waitForResponse('**/recorder.js*') - const capturedEvents = await page.evaluate(() => (window as WindowWithPostHog).capturedEvents || []) - expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview']) - await page.resetCapturedEvents() + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.capture('test_registered_property') }) - test('does not capture events when sampling is set to 0', async ({ page }) => { - await page.locator('[data-cy-input]').fill('hello posthog!') - // because it doesn't make sense to wait for a snapshot event that won't happen - await page.waitForTimeout(250) - - const capturedEvents = await page.capturedEvents() - expect(capturedEvents).toEqual([]) + const capturedEvents = await page.capturedEvents() + expect(capturedEvents.map((x) => x.event)).toEqual(['$snapshot', 'test_registered_property']) + + const firstSessionId = capturedEvents[0]['properties']['$session_id'] + expect(typeof firstSessionId).toEqual('string') + expect(firstSessionId.trim().length).toBeGreaterThan(10) + expect(capturedEvents[1]['properties']['$session_recording_start_reason']).toEqual('recording_initialized') + + await page.resetCapturedEvents() + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + 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 + // @ts-expect-error can ignore that TS thinks these things might be null + ph.sessionManager['_sessionActivityTimestamp'] = activityTs - timeout - 1000 + // @ts-expect-error can ignore that TS thinks these things might be null + ph.sessionManager['_sessionStartTimestamp'] = startTs - timeout - 1000 }) - test('can override sampling when starting session recording', async ({ page, context }) => { - await page.evaluate(() => { - const ph = (window as WindowWithPostHog).posthog - ph?.startSessionRecording({ sampling: true }) - ph?.capture('test_registered_property') - }) - const capturedEvents = await page.capturedEvents() - expect(capturedEvents.map((x) => x.event)).toEqual(['test_registered_property']) - expect(capturedEvents[0]['properties']['$session_recording_start_reason']).toEqual('sampling_overridden') - - // sampling override survives a page refresh - await page.resetCapturedEvents() - await page.reload() - - await start( - { - ...sampleZeroStartOptions, - type: 'reload', - }, - page, - context - ) - await page.waitForResponse('**/recorder.js*') - const responsePromise = page.waitForResponse('**/ses/*') - await page.locator('[data-cy-input]').fill('hello posthog!') - await responsePromise - - const afterReloadCapturedEvents = await page.capturedEvents() - const lastCaptured = afterReloadCapturedEvents[afterReloadCapturedEvents.length - 1] - expect(lastCaptured['event']).toEqual('$snapshot') + const anotherResponsePromise = page.waitForResponse('**/ses/*') + // using fill here means the session id doesn't rotate, must need some kind of user interaction + await page.locator('[data-cy-input]').type('hello posthog!') + await anotherResponsePromise + + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.capture('test_registered_property') }) + + const capturedEventsAfter24Hours = await page.capturedEvents() + expect(capturedEventsAfter24Hours.map((x) => x.event)).toEqual(['$snapshot', 'test_registered_property']) + + expect(capturedEventsAfter24Hours[0]['properties']['$session_id']).not.toEqual(firstSessionId) + expect(capturedEventsAfter24Hours[0]['properties']['$snapshot_data'][0].type).toEqual(4) // meta + expect(capturedEventsAfter24Hours[0]['properties']['$snapshot_data'][1].type).toEqual(2) // full_snapshot + + expect(capturedEventsAfter24Hours[1]['properties']['$session_id']).not.toEqual(firstSessionId) + expect(capturedEventsAfter24Hours[1]['properties']['$session_recording_start_reason']).toEqual( + 'session_id_changed' + ) }) }) From 7eb700c818f54b8db83c7ba0f529aaa21400c884 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sat, 28 Dec 2024 15:57:43 +0000 Subject: [PATCH 10/28] nicer --- playwright/utils/posthog-playwright-test-base.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/playwright/utils/posthog-playwright-test-base.ts b/playwright/utils/posthog-playwright-test-base.ts index 775d9e5dc..0f7e43fb0 100644 --- a/playwright/utils/posthog-playwright-test-base.ts +++ b/playwright/utils/posthog-playwright-test-base.ts @@ -1,4 +1,3 @@ -import * as fs from 'fs' import { test as base, Page } from '@playwright/test' import path from 'path' import { PostHog } from '../../src/posthog-core' @@ -71,22 +70,16 @@ export const test = base.extend<{ mockStaticAssets: void; page: Page }>({ lazyLoadedJSFiles.forEach((key: string) => { const jsFilePath = path.resolve(process.cwd(), `dist/${key}.js`) - const fileBody = fs.readFileSync(jsFilePath, 'utf8') void context.route(new RegExp(`^.*/static/${key}\\.js(\\?.*)?$`), (route) => { route.fulfill({ - status: 200, - contentType: 'application/json', - body: fileBody, + path: jsFilePath, }) }) const jsMapFilePath = path.resolve(process.cwd(), `dist/${key}.js.map`) - const mapFileBody = fs.readFileSync(jsMapFilePath, 'utf8') void context.route(`**/static/${key}.js.map`, (route) => { route.fulfill({ - status: 200, - contentType: 'application/json', - body: mapFileBody, + path: jsMapFilePath, }) }) }) From 2a9cf5cff72e08d65940b129cad852e954d26e28 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sat, 28 Dec 2024 15:59:28 +0000 Subject: [PATCH 11/28] nicer --- playwright/utils/posthog-playwright-test-base.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/playwright/utils/posthog-playwright-test-base.ts b/playwright/utils/posthog-playwright-test-base.ts index 0f7e43fb0..ea6828d6e 100644 --- a/playwright/utils/posthog-playwright-test-base.ts +++ b/playwright/utils/posthog-playwright-test-base.ts @@ -1,5 +1,4 @@ import { test as base, Page } from '@playwright/test' -import path from 'path' import { PostHog } from '../../src/posthog-core' import { CaptureResult } from '../../src/types' @@ -69,17 +68,17 @@ export const test = base.extend<{ mockStaticAssets: void; page: Page }>({ }) lazyLoadedJSFiles.forEach((key: string) => { - const jsFilePath = path.resolve(process.cwd(), `dist/${key}.js`) void context.route(new RegExp(`^.*/static/${key}\\.js(\\?.*)?$`), (route) => { route.fulfill({ - path: jsFilePath, + headers: { loaded: 'using relative path by playwright' }, + path: `./dist/${key}.js`, }) }) - const jsMapFilePath = path.resolve(process.cwd(), `dist/${key}.js.map`) void context.route(`**/static/${key}.js.map`, (route) => { route.fulfill({ - path: jsMapFilePath, + headers: { loaded: 'using relative path by playwright' }, + path: `./dist/${key}.js.map`, }) }) }) From b92f5554a678aa1630ba334972afc67787314956 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sat, 28 Dec 2024 17:18:50 +0000 Subject: [PATCH 12/28] port network tests --- cypress/e2e/session-recording.cy.ts | 214 ------------------ playground/cypress/index.html | 15 +- ...session-recording-network-recorder.spec.ts | 165 +++++++++++++- playwright/utils/setup.ts | 15 +- 4 files changed, 189 insertions(+), 220 deletions(-) delete mode 100644 cypress/e2e/session-recording.cy.ts diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts deleted file mode 100644 index 88dd91c52..000000000 --- a/cypress/e2e/session-recording.cy.ts +++ /dev/null @@ -1,214 +0,0 @@ -/// - -import { start } from '../support/setup' - -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', () => { - ;[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', - }) - ) - }) - }) - }) - }) -}) diff --git a/playground/cypress/index.html b/playground/cypress/index.html index b078af68e..e03cb0c76 100644 --- a/playground/cypress/index.html +++ b/playground/cypress/index.html @@ -15,8 +15,8 @@ Send custom event -