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: