diff --git a/cypress.config.ts b/cypress.config.ts index 60b03cffc..402c5664d 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -4,11 +4,6 @@ export default defineConfig({ defaultCommandTimeout: 2000, numTestsKeptInMemory: 0, e2e: { - // We've imported your old cypress plugins here. - // You may want to clean this up later by importing these. - setupNodeEvents(on, config) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require('./cypress/plugins/index.js')(on, config) - }, + specPattern: 'cypress/e2e/**/*.cy.{js,ts}', }, }) diff --git a/cypress/e2e/capture.cy.js b/cypress/e2e/capture.cy.ts similarity index 72% rename from cypress/e2e/capture.cy.js rename to cypress/e2e/capture.cy.ts index 1087803fe..349c70e4c 100644 --- a/cypress/e2e/capture.cy.js +++ b/cypress/e2e/capture.cy.ts @@ -1,39 +1,15 @@ /// +// @ts-expect-error - you totally can import the package JSON import { version } from '../../package.json' import { getBase64EncodedPayload, getGzipEncodedPayload } from '../support/compression' +import { start } from '../support/setup' const urlWithVersion = new RegExp(`&ver=${version}`) describe('Event capture', () => { - given('options', () => ({})) - given('sessionRecording', () => false) - given('supportedCompression', () => ['gzip-js']) - - // :TRICKY: Use a custom start command over beforeEach to deal with given2 not being ready yet. - const start = ({ waitForDecide = true } = {}) => { - cy.intercept('POST', '**/decide/*', { - config: { - enable_collect_everything: true, - }, - editorParams: {}, - featureFlags: ['session-recording-player'], - isAuthenticated: false, - sessionRecording: given.sessionRecording, - supportedCompression: given.supportedCompression, - excludedDomains: [], - autocaptureExceptions: false, - }).as('decide') - - cy.visit('./playground/cypress-full') - cy.posthogInit(given.options) - if (waitForDecide) { - cy.wait('@decide') - } - } - it('captures pageviews, autocapture, custom events', () => { - start() + start({}) cy.get('[data-cy-custom-event-button]').click() cy.phCaptures().should('have.length', 3) @@ -51,12 +27,13 @@ describe('Event capture', () => { describe('autocapture config', () => { it('dont capture click when configured not to', () => { - given('options', () => ({ - autocapture: { - dom_event_allowlist: ['change'], + start({ + options: { + autocapture: { + dom_event_allowlist: ['change'], + }, }, - })) - start() + }) cy.get('[data-cy-custom-event-button]').click() cy.phCaptures().should('have.length', 2) @@ -65,12 +42,13 @@ describe('Event capture', () => { }) it('capture clicks when configured to', () => { - given('options', () => ({ - autocapture: { - dom_event_allowlist: ['click'], + start({ + options: { + autocapture: { + dom_event_allowlist: ['click'], + }, }, - })) - start() + }) cy.get('[data-cy-custom-event-button]').click() cy.phCaptures().should('have.length', 3) @@ -80,12 +58,13 @@ describe('Event capture', () => { }) it('collect on url', () => { - given('options', () => ({ - autocapture: { - url_allowlist: ['.*playground/cypress'], + start({ + options: { + autocapture: { + url_allowlist: ['.*playground/cypress'], + }, }, - })) - start() + }) cy.get('[data-cy-custom-event-button]').click() cy.phCaptures().should('have.length', 3) @@ -95,12 +74,13 @@ describe('Event capture', () => { }) it('dont collect on url', () => { - given('options', () => ({ - autocapture: { - url_allowlist: ['.*dontcollect'], + start({ + options: { + autocapture: { + url_allowlist: ['.*dontcollect'], + }, }, - })) - start() + }) cy.get('[data-cy-custom-event-button]').click() cy.phCaptures().should('have.length', 2) @@ -109,12 +89,13 @@ describe('Event capture', () => { }) it('collect button elements', () => { - given('options', () => ({ - autocapture: { - element_allowlist: ['button'], + start({ + options: { + autocapture: { + element_allowlist: ['button'], + }, }, - })) - start() + }) cy.get('[data-cy-custom-event-button]').click() cy.phCaptures().should('have.length', 3) @@ -124,12 +105,13 @@ describe('Event capture', () => { }) it('dont collect on button elements', () => { - given('options', () => ({ - autocapture: { - element_allowlist: ['a'], + start({ + options: { + autocapture: { + element_allowlist: ['a'], + }, }, - })) - start() + }) cy.get('[data-cy-custom-event-button]').click() cy.phCaptures().should('have.length', 2) @@ -138,12 +120,13 @@ describe('Event capture', () => { }) it('collect with data attribute', () => { - given('options', () => ({ - autocapture: { - css_attribute_allowlist: ['[data-cy-custom-event-button]'], + start({ + options: { + autocapture: { + css_selector_allowlist: ['[data-cy-custom-event-button]'], + }, }, - })) - start() + }) cy.get('[data-cy-custom-event-button]').click() cy.phCaptures().should('have.length', 3) @@ -153,12 +136,13 @@ describe('Event capture', () => { }) it('dont collect with data attribute', () => { - given('options', () => ({ - autocapture: { - css_selector_allowlist: ['[nope]'], + start({ + options: { + autocapture: { + css_selector_allowlist: ['[nope]'], + }, }, - })) - start() + }) cy.get('[data-cy-custom-event-button]').click() cy.phCaptures().should('have.length', 2) @@ -168,7 +152,7 @@ describe('Event capture', () => { }) it('captures $feature_flag_called', () => { - start() + start({}) cy.get('[data-cy-feature-flag-button]').click() @@ -176,9 +160,7 @@ describe('Event capture', () => { }) it('captures rage clicks', () => { - given('options', () => ({ rageclick: true })) - - start() + start({ options: { rageclick: true } }) cy.get('body').click(100, 100).click(98, 102).click(101, 103) @@ -186,14 +168,14 @@ describe('Event capture', () => { }) describe('group analytics', () => { - given('options', () => ({ - loaded: (posthog) => { - posthog.group('company', 'id:5') - }, - })) - it('includes group information in all event payloads', () => { - start() + start({ + options: { + loaded: (posthog) => { + posthog.group('company', 'id:5') + }, + }, + }) cy.get('[data-cy-custom-event-button]').click() @@ -204,9 +186,7 @@ describe('Event capture', () => { }) it('doesnt capture rage clicks when autocapture is disabled', () => { - given('options', () => ({ rageclick: true, autocapture: false })) - - start() + start({ options: { rageclick: true, autocapture: false } }) cy.get('body').click(100, 100).click(98, 102).click(101, 103) @@ -214,13 +194,14 @@ describe('Event capture', () => { }) it('makes a single decide request', () => { - start() + start({}) cy.get('@decide.all').then((calls) => { expect(calls.length).to.equal(1) }) cy.phCaptures().should('include', '$pageview') + // @ts-expect-error - TS is wrong that get returns HTMLElement here cy.get('@decide').should(({ request }) => { const payload = getBase64EncodedPayload(request) expect(payload.token).to.equal('test_token') @@ -228,36 +209,9 @@ describe('Event capture', () => { }) }) - describe('session recording enabled from API', () => { - given('sessionRecording', () => ({ - endpoint: '/ses/', - })) - - it('captures $snapshot events', () => { - start() - // de-flake the test - cy.wait(100) - cy.phCaptures().should('include', '$snapshot') - }) - - describe('but disabled from config', () => { - given('options', () => ({ disable_session_recording: true })) - - it('does not capture $snapshot events', () => { - start() - - cy.wait(1000) - - cy.phCaptures().should('not.include', '$snapshot') - }) - }) - }) - describe('opting out of autocapture', () => { - given('options', () => ({ autocapture: false })) - it('captures pageviews, custom events', () => { - start({ waitForDecide: false }) + start({ options: { autocapture: false }, waitForDecide: false }) cy.wait(50) cy.get('[data-cy-custom-event-button]').click() @@ -266,6 +220,7 @@ describe('Event capture', () => { cy.phCaptures().should('include', 'custom-event') cy.wait('@capture') + // @ts-expect-error - TS is wrong that get returns HTMLElement here cy.get('@capture').should(({ request }) => { const captures = getBase64EncodedPayload(request) @@ -275,10 +230,8 @@ describe('Event capture', () => { }) describe('opting out of pageviews', () => { - given('options', () => ({ capture_pageview: false })) - it('captures autocapture, custom events', () => { - start() + start({ options: { capture_pageview: false } }) cy.get('[data-cy-custom-event-button]').click() cy.reload() @@ -291,7 +244,7 @@ describe('Event capture', () => { describe('user opts out after start', () => { it('does not send any autocapture/custom events after that', () => { - start() + start({}) cy.posthog().invoke('opt_out_capturing') @@ -303,9 +256,13 @@ describe('Event capture', () => { }) it('does not send session recording events', () => { - given('sessionRecording', () => true) - - start() + start({ + decideResponseOverrides: { + sessionRecording: { + endpoint: '/ses/', + }, + }, + }) cy.posthog().invoke('opt_out_capturing') cy.resetPhCaptures() @@ -318,14 +275,14 @@ describe('Event capture', () => { describe('decoding the payload', () => { describe('gzip-js supported', () => { it('contains the correct payload after an event', () => { - start() + start({}) // Pageview will be sent immediately cy.wait('@capture').should(({ request }) => { expect(request.headers['content-type']).to.eql('application/x-www-form-urlencoded') expect(request.url).to.match(urlWithVersion) const data = decodeURIComponent(request.body.match(/data=(.*)/)[1]) - const captures = JSON.parse(Buffer.from(data, 'base64')) + const captures = JSON.parse(Buffer.from(data, 'base64').toString()) expect(captures['event']).to.equal('$pageview') }) @@ -351,9 +308,8 @@ describe('Event capture', () => { }) describe('advanced_disable_decide config', () => { - given('options', () => ({ advanced_disable_decide: true })) it('does not autocapture anything when /decide is disabled', () => { - start({ waitForDecide: false }) + start({ options: { advanced_disable_decide: true }, waitForDecide: false }) cy.get('body').click(100, 100).click(98, 102).click(101, 103) cy.get('[data-cy-custom-event-button]').click() @@ -365,7 +321,7 @@ describe('Event capture', () => { }) it('does not capture session recordings', () => { - start({ waitForDecide: false }) + start({ options: { advanced_disable_decide: true }, waitForDecide: false }) cy.get('[data-cy-custom-event-button]').click() cy.wait('@capture') @@ -383,21 +339,22 @@ describe('Event capture', () => { }) describe('subsequent decide calls', () => { - given('options', () => ({ - loaded: (posthog) => { - posthog.identify('new-id') - posthog.group('company', 'id:5', { id: 5, company_name: 'Awesome Inc' }) - posthog.group('playlist', 'id:77', { length: 8 }) - }, - })) - it('makes a single decide request on start', () => { - start() + start({ + options: { + loaded: (posthog) => { + posthog.identify('new-id') + posthog.group('company', 'id:5', { id: 5, company_name: 'Awesome Inc' }) + posthog.group('playlist', 'id:77', { length: 8 }) + }, + }, + }) cy.get('@decide.all').then((calls) => { expect(calls.length).to.equal(1) }) + // @ts-expect-error - TS is wrong that get returns HTMLElement here cy.get('@decide').should(({ request }) => { const payload = getBase64EncodedPayload(request) expect(payload).to.deep.equal({ @@ -417,7 +374,15 @@ describe('Event capture', () => { }) it('does a single decide call on following changes', () => { - start() + start({ + options: { + loaded: (posthog) => { + posthog.identify('new-id') + posthog.group('company', 'id:5', { id: 5, company_name: 'Awesome Inc' }) + posthog.group('playlist', 'id:77', { length: 8 }) + }, + }, + }) cy.wait(200) cy.get('@decide.all').then((calls) => { diff --git a/cypress/e2e/identify.cy.js b/cypress/e2e/identify.cy.ts similarity index 89% rename from cypress/e2e/identify.cy.js rename to cypress/e2e/identify.cy.ts index 1f59bbc7e..92ae88711 100644 --- a/cypress/e2e/identify.cy.js +++ b/cypress/e2e/identify.cy.ts @@ -1,24 +1,16 @@ /// -function setup(initOptions) { - cy.visit('./playground/cypress-full') - cy.posthogInit({ ...initOptions }) - // reset and clear device ID so that we can test the uuid format - // without worrying about the previous test's device ID - cy.posthog().invoke('reset', true) - cy.wait('@decide') -} +import { start } from '../support/setup' describe('identify()', () => { beforeEach(() => { - setup() + start({}) }) it('uses the v7 uuid format', () => { cy.posthog().invoke('capture', 'an-anonymous-event') cy.phCaptures({ full: true }).then((events) => { - cy.log(events) - let deviceIds = new Set(events.map((e) => e.properties['$device_id'])) + const deviceIds = new Set(events.map((e) => e.properties['$device_id'])) expect(deviceIds.size).to.eql(1) const [deviceId] = deviceIds expect(deviceId.length).to.be.eql(36) diff --git a/cypress/e2e/opting-out.cy.ts b/cypress/e2e/opting-out.cy.ts new file mode 100644 index 000000000..23715a6cf --- /dev/null +++ b/cypress/e2e/opting-out.cy.ts @@ -0,0 +1,122 @@ +import { assertWhetherPostHogRequestsWereCalled } from '../support/assertions' + +describe('opting out', () => { + describe('session recording', () => { + beforeEach(() => { + cy.intercept('POST', '**/decide/*', { + config: { enable_collect_everything: false }, + editorParams: {}, + featureFlags: ['session-recording-player'], + isAuthenticated: false, + sessionRecording: { + endpoint: '/ses/', + }, + capture_performance: true, + }).as('decide') + + cy.visit('./playground/cypress') + }) + + it('does not capture events without init', () => { + cy.get('[data-cy-input]').type('hello world! ') + + assertWhetherPostHogRequestsWereCalled({ + '@recorder': false, + '@decide': false, + '@session-recording': false, + }) + + cy.get('[data-cy-input]') + .type('hello posthog!') + .then(() => { + cy.phCaptures({ full: true }).then((captures) => { + expect(captures || []).to.deep.equal([]) + }) + }) + }) + + it('does not capture events when config opts out by default', () => { + cy.posthogInit({ opt_out_capturing_by_default: true }) + + assertWhetherPostHogRequestsWereCalled({ + '@recorder': false, + '@decide': true, + '@session-recording': false, + }) + + cy.get('[data-cy-input]') + .type('hello posthog!') + .then(() => { + cy.phCaptures({ full: true }).then((captures) => { + expect(captures || []).to.deep.equal([]) + }) + }) + }) + + it('does not capture recordings when config disables session recording', () => { + cy.posthogInit({ disable_session_recording: true }) + + assertWhetherPostHogRequestsWereCalled({ + '@recorder': false, + '@decide': true, + '@session-recording': false, + }) + + cy.get('[data-cy-input]') + .type('hello posthog!') + .then(() => { + cy.phCaptures().then((captures) => { + expect(captures || []).to.deep.equal(['$pageview']) + }) + }) + }) + + // TODO: after opting in the onCapture hook isn't being called + // so we're not able to assert on behaviour anymore + // but observing it all works ok + it.skip('can start recording after starting opted out', () => { + cy.posthogInit({ opt_out_capturing_by_default: true }) + + assertWhetherPostHogRequestsWereCalled({ + '@recorder': false, + '@decide': true, + '@session-recording': false, + }) + + cy.posthog().invoke('opt_in_capturing') + // TODO: should we require this call? + cy.posthog().invoke('startSessionRecording') + + cy.phCaptures({ full: true }).then((captures) => { + expect((captures || []).map((c) => c.event)).to.deep.equal(['$opt_in']) + }) + + assertWhetherPostHogRequestsWereCalled({ + '@recorder': true, + '@decide': true, + // no call to session-recording yet + }) + + cy.resetPhCaptures() + + cy.get('[data-cy-input]') + .type('hello posthog!') + .wait(200) + .then(() => { + cy.phCaptures({ full: true }).then((captures) => { + // should be a pageview and a $snapshot + expect(captures.map((c) => c.event)).to.deep.equal(['$snapshot']) + + expect(captures[1]['properties']['$snapshot_data']).to.have.length.above(33).and.below(38) + // 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 options + // Making a set from the rest should all be 3 - incremental snapshots + const incrementalSnapshots = captures[1]['properties']['$snapshot_data'].slice(3) + expect(new Set(incrementalSnapshots.map((s) => s.type))).to.deep.equal(new Set([3])) + }) + }) + }) + }) +}) diff --git a/cypress/e2e/session-recording.cy.js b/cypress/e2e/session-recording.cy.ts similarity index 90% rename from cypress/e2e/session-recording.cy.js rename to cypress/e2e/session-recording.cy.ts index 83bc810a5..6b033503f 100644 --- a/cypress/e2e/session-recording.cy.js +++ b/cypress/e2e/session-recording.cy.ts @@ -1,35 +1,22 @@ /// import { _isNull } from '../../src/utils/type-utils' - -function onPageLoad() { - cy.posthogInit(given.options) - cy.wait('@decide') - cy.wait('@recorder') -} +import { start } from '../support/setup' describe('Session recording', () => { - given('options', () => ({})) - describe('array.full.js', () => { - beforeEach(() => { - cy.intercept('POST', '**/decide/*', { - config: { enable_collect_everything: false }, - editorParams: {}, - featureFlags: ['session-recording-player'], - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', + it('captures session events', () => { + start({ + decideResponseOverrides: { + config: { enable_collect_everything: false }, + isAuthenticated: false, + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, }, - capture_performance: true, - }).as('decide') - - cy.visit('./playground/cypress-full') - cy.posthogInit(given.options) - cy.wait('@decide') - }) + }) - it('captures session events', () => { cy.get('[data-cy-input]').type('hello world! ') cy.wait(500) cy.get('[data-cy-input]') @@ -55,20 +42,18 @@ describe('Session recording', () => { describe('array.js', () => { beforeEach(() => { - cy.intercept('POST', '**/decide/*', { - config: { enable_collect_everything: false }, - editorParams: {}, - featureFlags: ['session-recording-player'], - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', + start({ + decideResponseOverrides: { + config: { enable_collect_everything: false }, + isAuthenticated: false, + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, }, - supportedCompression: ['gzip', 'lz64'], - capture_performance: true, - }).as('decide') - - cy.visit('./playground/cypress') - onPageLoad() + url: './playground/cypress', + }) + cy.wait('@recorder') }) it('captures session events', () => { @@ -95,7 +80,7 @@ describe('Session recording', () => { }) it('captures snapshots when the mouse moves', () => { - let sessionId = null + 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! ') @@ -158,7 +143,7 @@ describe('Session recording', () => { }) it('continues capturing to the same session when the page reloads', () => { - let sessionId = null + 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! ') @@ -178,7 +163,9 @@ describe('Session recording', () => { cy.resetPhCaptures() // and refresh the page cy.reload() - onPageLoad() + cy.posthogInit({}) + cy.wait('@decide') + cy.wait('@recorder') cy.get('body') .trigger('mousemove', { clientX: 200, clientY: 300 }) @@ -231,7 +218,7 @@ describe('Session recording', () => { }) it('rotates sessions after 24 hours', () => { - let firstSessionId = null + let firstSessionId: string | null = null // first we start a session and give it some activity cy.get('[data-cy-input]').type('hello world! ') diff --git a/cypress/e2e/surveys.cy.js b/cypress/e2e/surveys.cy.ts similarity index 98% rename from cypress/e2e/surveys.cy.js rename to cypress/e2e/surveys.cy.ts index 789aea9ab..24bb76955 100644 --- a/cypress/e2e/surveys.cy.js +++ b/cypress/e2e/surveys.cy.ts @@ -1,14 +1,13 @@ /// import { getBase64EncodedPayload } from '../support/compression' -function onPageLoad() { - cy.posthogInit(given.options) +function onPageLoad(options = {}) { + cy.posthogInit(options) cy.wait('@decide') cy.wait('@surveys') } describe('Surveys', () => { - given('options', () => ({})) beforeEach(() => { cy.intercept('POST', '**/decide/*', { config: { enable_collect_everything: false }, diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js deleted file mode 100644 index 6d7d44d5f..000000000 --- a/cypress/plugins/index.js +++ /dev/null @@ -1,21 +0,0 @@ -/// -// *********************************************************** -// This example plugins/index.js can be used to load plugins -// -// You can change the location of this file or turn off loading -// the plugins file with the 'pluginsFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/plugins-guide -// *********************************************************** - -// This function is called when a project is opened or re-opened (e.g. due to -// the project's config changing) - -/** - * @type {Cypress.PluginConfig} - */ -module.exports = (on, config) => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config -} diff --git a/cypress/support/assertions.ts b/cypress/support/assertions.ts new file mode 100644 index 000000000..506b2d737 --- /dev/null +++ b/cypress/support/assertions.ts @@ -0,0 +1,18 @@ +/** + * Receives an object with keys as the name of the route and values as whether the route should have been called. + * e.g. { '@recorder': true, '@decide': false } + * the keys must match a `cy.intercept` alias + **/ +export function assertWhetherPostHogRequestsWereCalled(expectedCalls: Record) { + cy.wait(200) + + for (const [key, value] of Object.entries(expectedCalls)) { + cy.get(key).then((interceptions) => { + if (value) { + expect(interceptions).to.be.an('object') + } else { + expect(interceptions).not.to.be.an('object') + } + }) + } +} diff --git a/cypress/support/commands.js b/cypress/support/commands.js deleted file mode 100644 index 4c686d983..000000000 --- a/cypress/support/commands.js +++ /dev/null @@ -1,60 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) - -let $captures, $fullCaptures - -Cypress.Commands.add('posthog', () => cy.window().then(($window) => $window.posthog)) - -Cypress.Commands.add('posthogInit', (options) => { - $captures = [] - $fullCaptures = [] - - cy.posthog().invoke('init', 'test_token', { - api_host: location.origin, - debug: true, - _onCapture: (event, eventData) => { - $captures.push(event) - $fullCaptures.push(eventData) - }, - ...options, - }) -}) - -Cypress.Commands.add('phCaptures', (options = {}) => { - function resolve() { - const result = options.full ? $fullCaptures : $captures - return cy.verifyUpcomingAssertions(result, options, { - onRetry: resolve, - }) - } - - return resolve() -}) - -Cypress.Commands.add('resetPhCaptures', () => { - $captures = [] - $fullCaptures = [] -}) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 000000000..2c82d7f45 --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,35 @@ +let $captures, $fullCaptures + +Cypress.Commands.add('posthog', () => cy.window().then(($window) => ($window as any).posthog)) + +Cypress.Commands.add('posthogInit', (options) => { + $captures = [] + $fullCaptures = [] + + cy.posthog().invoke('init', 'test_token', { + api_host: location.origin, + debug: true, + _onCapture: (event, eventData) => { + $captures.push(event) + $fullCaptures.push(eventData) + }, + ...options, + }) +}) + +Cypress.Commands.add('phCaptures', (options = { full: false }) => { + function resolve() { + const result = options.full ? $fullCaptures : $captures + // @ts-expect-error TS can't find verifyUpcomingAssertions, but it's there 🤷‍ + return cy.verifyUpcomingAssertions(result, options, { + onRetry: resolve, + }) + } + + return resolve() +}) + +Cypress.Commands.add('resetPhCaptures', () => { + $captures = [] + $fullCaptures = [] +}) diff --git a/cypress/support/compression.js b/cypress/support/compression.ts similarity index 61% rename from cypress/support/compression.js rename to cypress/support/compression.ts index f603cbf9c..93d3fe1f2 100644 --- a/cypress/support/compression.js +++ b/cypress/support/compression.ts @@ -1,12 +1,12 @@ -import * as fflate from 'fflate' +import { decompressSync, strFromU8 } from 'fflate' export function getBase64EncodedPayload(request) { const data = decodeURIComponent(request.body.match(/data=(.*)/)[1]) - return JSON.parse(Buffer.from(data, 'base64')) + return JSON.parse(Buffer.from(data, 'base64').toString()) } export async function getGzipEncodedPayload(request) { const data = new Uint8Array(await request.body) - const decoded = fflate.strFromU8(fflate.decompressSync(data)) + const decoded = strFromU8(decompressSync(data)) return JSON.parse(decoded) } diff --git a/cypress/support/e2e.js b/cypress/support/e2e.ts similarity index 57% rename from cypress/support/e2e.js rename to cypress/support/e2e.ts index dc197e752..82ddd8156 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.ts @@ -1,27 +1,6 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: import './commands' -import 'given2/setup' - -// Alternatively you can use CommonJS syntax: -// require('./commands') // Add console errors into cypress logs. -// eslint-disable-next-line no-undef Cypress.on('window:before:load', (win) => { cy.spy(win.console, 'error') cy.spy(win.console, 'warn') diff --git a/cypress/support/index.ts b/cypress/support/index.ts new file mode 100644 index 000000000..22e18cfb6 --- /dev/null +++ b/cypress/support/index.ts @@ -0,0 +1,32 @@ +/// + +import { PostHog } from '../../src/posthog-core' +import { PostHogConfig } from '../../src/types' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Custom command to get PostHog from the window + */ + posthog(): Chainable + + /** + * Custom command to initialize PostHog + */ + posthogInit(options: Partial): void + + /** + * custom command to get the events captured by posthog + * @param options pass full to get the whole event, omit or false to get just the name + */ + phCaptures(options?: { full: boolean }): Chainable + + /** + * custom command to reset the store of events captured by posthog + */ + resetPhCaptures(): void + } + } +} diff --git a/cypress/support/setup.ts b/cypress/support/setup.ts new file mode 100644 index 000000000..e5534eeb6 --- /dev/null +++ b/cypress/support/setup.ts @@ -0,0 +1,47 @@ +import { DecideResponse, PostHogConfig } from '../../src/types' + +export const start = ({ + waitForDecide = true, + initPosthog = true, + resetOnInit = false, + options = {}, + decideResponseOverrides = { + config: { enable_collect_everything: true }, + sessionRecording: undefined, + isAuthenticated: false, + capturePerformance: true, + }, + url = './playground/cypress-full', +}: { + waitForDecide?: boolean + initPosthog?: boolean + resetOnInit?: boolean + options?: Partial + decideResponseOverrides?: Partial + url?: string +}) => { + const decideResponse = { + editorParams: {}, + featureFlags: ['session-recording-player'], + supportedCompression: ['gzip-js'], + excludedDomains: [], + autocaptureExceptions: false, + ...decideResponseOverrides, + config: { enable_collect_everything: true, ...decideResponseOverrides.config }, + } + cy.intercept('POST', '**/decide/*', decideResponse).as('decide') + + cy.visit(url) + + if (initPosthog) { + cy.posthogInit(options) + } + + if (resetOnInit) { + cy.posthog().invoke('reset', true) + } + + if (waitForDecide) { + cy.wait('@decide') + } +} diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 000000000..02963d41b --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "es2015", + "lib": ["es5", "dom"], + "types": ["cypress", "node"], + "moduleResolution": "node" + }, + "include": ["**/*.ts"] +} diff --git a/package.json b/package.json index 94e07a3fd..4a31df6f8 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@typescript-eslint/parser": "^6.19.0", "babel-eslint": "10.1.0", "babel-jest": "^26.6.3", - "cypress": "13.5.1", + "cypress": "13.6.3", "eslint": "8.56.0", "eslint-config-posthog-js": "link:./eslint-rules", "eslint-config-prettier": "^8.5.0", diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 3b0f8a75d..c2a52606a 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -25,6 +25,7 @@ import { logger } from '../../utils/logger' import { assignableWindow, window } from '../../utils/globals' import { buildNetworkRequestOptions } from './config' import { isLocalhost } from '../../utils/request-utils' +import { userOptedOut } from '../../gdpr-utils' const BASE_ENDPOINT = '/s/' @@ -348,7 +349,8 @@ export class SessionRecording { } // We do not switch recorder versions midway through a recording. - if (this._captureStarted || this.instance.config.disable_session_recording) { + // do not start if explicitly disabled or if the user has opted out + if (this._captureStarted || this.instance.config.disable_session_recording || userOptedOut(this.instance)) { return } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 2a99c13c8..8388c799e 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -1754,7 +1754,13 @@ export class PostHog { } if (this.sessionRecording && !_isUndefined(config.disable_session_recording)) { - if (oldConfig.disable_session_recording !== config.disable_session_recording) { + const disable_session_recording_has_changed = + oldConfig.disable_session_recording !== config.disable_session_recording + // if opting back in, this config might not have changed + const try_enable_after_opt_in = + !userOptedOut(this) && !config.disable_session_recording && !this.sessionRecording.started + + if (disable_session_recording_has_changed || try_enable_after_opt_in) { if (config.disable_session_recording) { this.sessionRecording.stopRecording() } else { diff --git a/yarn.lock b/yarn.lock index ec5d5e723..69bad5b3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3749,13 +3749,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.37.tgz#abb38afa9d6e8a2f627a8cb52290b3c80fbe61ed" integrity sha512-i1KGxqcvJaLQali+WuypQnXwcplhtNtjs66eNsZpp2P2FL/trJJxx/VWsM0YCL2iMoIJrbXje48lvIQAQ4p2ZA== -"@types/node@^18.17.5": - version "18.18.9" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.18.9.tgz#5527ea1832db3bba8eb8023ce8497b7d3f299592" - integrity sha512-0f5klcuImLnG4Qreu9hPj/rEfFq6YRc5n2mAjSsH+ec/mJL+3voBH0+8T7o8RpFjH7ovc+TRsL/c7OYIQsPTfQ== - dependencies: - undici-types "~5.26.4" - "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -5317,14 +5310,13 @@ cuint@^0.2.2: resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" integrity sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs= -cypress@13.5.1: - version "13.5.1" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.5.1.tgz#8b19bf0b9f31ea43f78980b2479bd3f25197d5cc" - integrity sha512-yqLViT0D/lPI8Kkm7ciF/x/DCK/H/DnogdGyiTnQgX4OVR2aM30PtK+kvklTOD1u3TuItiD9wUQAF8EYWtyZug== +cypress@13.6.3: + version "13.6.3" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.6.3.tgz#54f03ca07ee56b2bc18211e7bd32abd2533982ba" + integrity sha512-d/pZvgwjAyZsoyJ3FOsJT5lDsqnxQ/clMqnNc++rkHjbkkiF2h9s0JsZSyyH4QXhVFW3zPFg82jD25roFLOdZA== dependencies: "@cypress/request" "^3.0.0" "@cypress/xvfb" "^1.2.4" - "@types/node" "^18.17.5" "@types/sinonjs__fake-timers" "8.1.1" "@types/sizzle" "^2.3.2" arch "^2.2.0" @@ -11874,11 +11866,6 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" -undici-types@~5.26.4: - version "5.26.5" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" - integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== - unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"