diff --git a/.eslintrc.js b/.eslintrc.js index 0b65965de..144a97b24 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -108,6 +108,14 @@ module.exports = { __dirname: true, }, }, + { + files: ['playwright/**/*.ts'], + rules: { + 'posthog-js/no-direct-array-check': 'off', + 'posthog-js/no-direct-undefined-check': 'off', + 'compat/compat': 'off', + }, + }, ], root: true, } diff --git a/cypress/e2e/before_send.cy.ts b/cypress/e2e/before_send.cy.ts deleted file mode 100644 index 1502e69b4..000000000 --- a/cypress/e2e/before_send.cy.ts +++ /dev/null @@ -1,48 +0,0 @@ -/// - -import { start } from '../support/setup' -import { isArray } from '../../src/utils/type-utils' - -describe('before_send', () => { - it('can sample and edit with before_send', () => { - start({}) - - cy.posthog().then((posthog) => { - let counter = 0 - const og = posthog.config.before_send - // cypress tests rely on existing before_send function to capture events - // so we have to add it back in here - posthog.config.before_send = [ - (cr) => { - if (cr.event === 'custom-event') { - counter++ - if (counter === 2) { - return null - } - } - if (cr.event === '$autocapture') { - return { - ...cr, - event: 'redacted', - } - } - return cr - }, - ...(isArray(og) ? og : [og]), - ] - }) - - cy.get('[data-cy-custom-event-button]').click() - cy.get('[data-cy-custom-event-button]').click() - - cy.phCaptures().should('deep.equal', [ - // before adding the new before sendfn - '$pageview', - 'redacted', - 'custom-event', - // second button click only has the redacted autocapture event - 'redacted', - // because the second custom-event is rejected - ]) - }) -}) diff --git a/cypress/e2e/capture.cy.ts b/cypress/e2e/capture.cy.ts deleted file mode 100644 index c3cd27214..000000000 --- a/cypress/e2e/capture.cy.ts +++ /dev/null @@ -1,476 +0,0 @@ -/// -// @ts-expect-error - you totally can import the package JSON -import { version } from '../../package.json' - -import { getBase64EncodedPayload, getGzipEncodedPayload, getPayload } from '../support/compression' -import { start } from '../support/setup' -import { pollPhCaptures } from '../support/assertions' - -const urlWithVersion = new RegExp(`&ver=${version}`) - -describe('Event capture', () => { - it('captures pageviews, autocapture, custom events', () => { - start({}) - - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 3) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', '$autocapture') - cy.phCaptures().should('include', 'custom-event') - - cy.reload() - cy.phCaptures().should('have.length', 4) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', '$pageleave') - cy.phCaptures().should('include', '$autocapture') - cy.phCaptures().should('include', 'custom-event') - }) - - describe('autocapture config', () => { - it('dont capture click when configured not to', () => { - start({ - options: { - autocapture: { - dom_event_allowlist: ['change'], - }, - }, - }) - - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 2) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', 'custom-event') - }) - - it('capture clicks when configured to', () => { - start({ - options: { - autocapture: { - dom_event_allowlist: ['click'], - }, - }, - }) - - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 3) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', '$autocapture') - cy.phCaptures().should('include', 'custom-event') - }) - - it('collect on url', () => { - start({ - options: { - autocapture: { - url_allowlist: ['.*playground/cypress'], - }, - }, - }) - - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 3) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', '$autocapture') - cy.phCaptures().should('include', 'custom-event') - }) - - it('dont collect on url', () => { - start({ - options: { - autocapture: { - url_allowlist: ['.*dontcollect'], - }, - }, - }) - - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 2) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', 'custom-event') - }) - - it('collect button elements', () => { - start({ - options: { - autocapture: { - element_allowlist: ['button'], - }, - }, - }) - - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 3) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', '$autocapture') - cy.phCaptures().should('include', 'custom-event') - }) - - it('dont collect on button elements', () => { - start({ - options: { - autocapture: { - element_allowlist: ['a'], - }, - }, - }) - - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 2) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', 'custom-event') - }) - - it('collect with data attribute', () => { - start({ - options: { - autocapture: { - css_selector_allowlist: ['[data-cy-custom-event-button]'], - }, - }, - }) - - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 3) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', '$autocapture') - cy.phCaptures().should('include', 'custom-event') - }) - - it('dont collect with data attribute', () => { - start({ - options: { - autocapture: { - css_selector_allowlist: ['[nope]'], - }, - }, - }) - - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 2) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', 'custom-event') - }) - }) - - it('captures $feature_flag_called', () => { - start({}) - - cy.get('[data-cy-feature-flag-button]').click() - - cy.phCaptures({ full: true }).then((captures) => { - const flagCallEvents = captures.filter((capture) => capture.event === '$feature_flag_called') - expect(flagCallEvents.length).to.eq(1) - const flagCallEvent = flagCallEvents[0] - expect(flagCallEvent.properties.$feature_flag_bootstrapped_response).to.not.exist - expect(flagCallEvent.properties.$feature_flag_bootstrapped_payload).to.not.exist - expect(flagCallEvent.properties.$used_bootstrap_value).to.equal(false) - }) - }) - - it('captures $feature_flag_called with bootstrapped value properties', () => { - start({ - options: { - bootstrap: { - featureFlags: { - 'some-feature': 'some-value', - }, - featureFlagPayloads: { - 'some-feature': 'some-payload', - }, - }, - advanced_disable_feature_flags: true, - }, - waitForDecide: false, - }) - - cy.intercept( - '/decide/*', - { times: 1 }, - { - forceNetworkError: true, - } - ) - - cy.get('[data-cy-feature-flag-button]').click() - - cy.phCaptures({ full: true }).then((captures) => { - const flagCallEvents = captures.filter((capture) => capture.event === '$feature_flag_called') - expect(flagCallEvents.length).to.eq(1) - const flagCallEvent = flagCallEvents[0] - expect(flagCallEvent.properties.$feature_flag_bootstrapped_response).to.equal('some-value') - expect(flagCallEvent.properties.$feature_flag_bootstrapped_payload).to.equal('some-payload') - expect(flagCallEvent.properties.$used_bootstrap_value).to.equal(true) - }) - }) - - it('captures rage clicks', () => { - start({ options: { rageclick: true } }) - - cy.get('body').click(100, 100).click(98, 102).click(101, 103) - - cy.phCaptures().should('include', '$rageclick') - }) - - describe('group analytics', () => { - it('includes group information in all event payloads', () => { - start({ - options: { - loaded: (posthog) => { - posthog.group('company', 'id:5') - }, - }, - }) - - cy.get('[data-cy-custom-event-button]').click() - - cy.phCaptures({ full: true }) - .should('have.length', 3) - .should('satisfy', (payloads) => payloads.every(({ properties }) => !!properties.$groups)) - }) - }) - - it('doesnt capture rage clicks when autocapture is disabled', () => { - start({ options: { rageclick: true, autocapture: false } }) - - cy.get('body').click(100, 100).click(98, 102).click(101, 103) - - cy.phCaptures().should('not.include', '$rageclick') - }) - - it('makes a single decide request', () => { - 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') - expect(payload.groups).to.deep.equal({}) - }) - }) - - describe('when disabled', () => { - it('captures pageviews, custom events when autocapture disabled', () => { - start({ options: { autocapture: false }, waitForDecide: false }) - - cy.wait(50) - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 2) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', 'custom-event') - - cy.wait('@capture') - // @ts-expect-error - TS is wrong that get returns HTMLElement here - cy.get('@capture').should(async ({ request }) => { - const captures = await getPayload(request) - expect(captures['event']).to.equal('$pageview') - }) - }) - - it('captures autocapture, custom events when pageviews disabled', () => { - start({ options: { capture_pageview: false } }) - - cy.get('[data-cy-custom-event-button]').click() - cy.reload() - - cy.phCaptures().should('have.length', 2) - cy.phCaptures().should('include', '$autocapture') - cy.phCaptures().should('include', 'custom-event') - }) - - it('does not capture things when multiple disabled', () => { - start({ options: { capture_pageview: false, capture_pageleave: false, autocapture: false } }) - - cy.get('[data-cy-custom-event-button]').click() - cy.reload() - - cy.phCaptures().should('have.length', 1) - cy.phCaptures().should('include', 'custom-event') - }) - }) - - describe('decoding the payload', () => { - describe('gzip-js supported', () => { - it('contains the correct payload after an event', async () => { - start({}) - - // Pageview will be sent immediately - - cy.wait('@capture').should(async ({ request }) => { - expect(request.url).to.match(urlWithVersion) - - const data = await getPayload(request) - expect(data['event']).to.equal('$pageview') - }) - - // the code below is going to trigger an event capture - // we want to assert on the request - cy.intercept('POST', '/e/*', async (request) => { - expect(request.headers['content-type']).to.eq('text/plain') - const captures = await getGzipEncodedPayload(request) - expect(captures.map(({ event }) => event)).to.deep.equal(['$autocapture', 'custom-event']) - }).as('capture-assertion') - - cy.get('[data-cy-custom-event-button]').click() - cy.phCaptures().should('have.length', 3) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', '$autocapture') - cy.phCaptures().should('include', 'custom-event') - - cy.wait('@capture-assertion') - }) - }) - }) - - describe('advanced_disable_decide config', () => { - it('does not autocapture anything when /decide is disabled', () => { - start({ options: { autocapture: false, advanced_disable_decide: true }, waitForDecide: false }) - - cy.get('body').click(100, 100) - cy.get('body').click(98, 102) - cy.get('body').click(101, 103) - cy.get('[data-cy-custom-event-button]').click() - - // No autocapture events, still captures custom events - cy.phCaptures().should('have.length', 2) - cy.phCaptures().should('include', '$pageview') - cy.phCaptures().should('include', 'custom-event') - }) - - it('does not capture session recordings', () => { - start({ options: { advanced_disable_decide: true }, waitForDecide: false }) - - cy.get('[data-cy-custom-event-button]').click() - cy.wait('@capture') - - cy.get('[data-cy-input]') - .type('hello posthog!') - .then(() => { - cy.get('@session-recording.all').then((calls) => { - expect(calls.length).to.equal(0) - }) - }) - - cy.phCaptures().should('not.include', '$snapshot') - }) - }) - - describe('subsequent decide calls', () => { - it('makes a single decide request on 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({ - token: 'test_token', - distinct_id: 'new-id', - person_properties: {}, - $anon_distinct_id: payload.$anon_distinct_id, - groups: { - company: 'id:5', - playlist: 'id:77', - }, - group_properties: { - company: { id: 5, company_name: 'Awesome Inc' }, - playlist: { length: 8 }, - }, - }) - }) - }) - - it('does a single decide call on following changes', () => { - 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) => { - expect(calls.length).to.equal(1) - }) - - cy.posthog().invoke('group', 'company', 'id:6') - cy.posthog().invoke('group', 'playlist', 'id:77') - cy.posthog().invoke('group', 'anothergroup', 'id:99') - - cy.wait('@decide') - - cy.get('@decide.all').then((calls) => { - expect(calls.length).to.equal(2) - }) - }) - }) - - it('capture dead clicks when configured to', () => { - start({ - options: { - capture_dead_clicks: true, - }, - }) - - cy.get('[data-cy-not-an-order-button]').click() - - pollPhCaptures('$dead_click').then(() => { - cy.phCaptures({ full: true }).then((captures) => { - const deadClicks = captures.filter((capture) => capture.event === '$dead_click') - expect(deadClicks.length).to.eq(1) - const deadClick = deadClicks[0] - expect(deadClick.properties.$dead_click_last_mutation_timestamp).to.be.a('number') - expect(deadClick.properties.$dead_click_event_timestamp).to.be.a('number') - expect(deadClick.properties.$dead_click_absolute_delay_ms).to.be.greaterThan(2500) - expect(deadClick.properties.$dead_click_scroll_timeout).to.eq(false) - expect(deadClick.properties.$dead_click_mutation_timeout).to.eq(false) - expect(deadClick.properties.$dead_click_absolute_timeout).to.eq(true) - }) - }) - }) - - it('does not capture dead click for selected text', () => { - start({ - options: { - capture_dead_clicks: true, - }, - }) - - cy.get('[data-cy-dead-click-text]').then(($el) => { - const text = $el.text() - const wordToSelect = text.split(' ')[0] - const position = text.indexOf(wordToSelect) - - // click the text to make a selection - cy.get('[data-cy-dead-click-text]') - .trigger('mousedown', position, 0) - .trigger('mousemove', position + wordToSelect.length, 0) - .trigger('mouseup') - .trigger('dblclick', position, 0) - }) - - cy.wait(1000) - cy.phCaptures({ full: true }).then((captures) => { - const deadClicks = captures.filter((capture) => capture.event === '$dead_click') - expect(deadClicks.length).to.eq(0) - }) - }) -}) diff --git a/cypress/e2e/error-tracking.cy.ts b/cypress/e2e/error-tracking.cy.ts deleted file mode 100644 index 419d436ee..000000000 --- a/cypress/e2e/error-tracking.cy.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { start } from '../support/setup' - -describe('Exception capture', () => { - it('manual exception capture', () => { - start({ - decideResponseOverrides: { - autocaptureExceptions: false, - }, - url: './playground/cypress', - }) - - cy.get('[data-cy-exception-button]').click() - - // ugh - cy.wait(1500) - - cy.phCaptures({ full: true }).then((captures) => { - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$autocapture', '$exception']) - expect(captures[2].event).to.be.eql('$exception') - expect(captures[2].properties.extra_prop).to.be.eql(2) - expect(captures[2].properties.$exception_source).to.eql(undefined) - expect(captures[2].properties.$exception_personURL).to.eql(undefined) - expect(captures[2].properties.$exception_list[0].value).to.be.eql('wat even am I') - expect(captures[2].properties.$exception_list[0].type).to.be.eql('Error') - }) - }) - - describe('Exception autocapture enabled', () => { - beforeEach(() => { - cy.on('uncaught:exception', () => { - // otherwise the exception we throw on purpose causes the test to fail - return false - }) - - start({ - decideResponseOverrides: { - autocaptureExceptions: true, - }, - url: './playground/cypress', - }) - cy.wait('@exception-autocapture-script') - }) - - it('adds stacktrace to captured strings', () => { - cy.get('[data-cy-exception-string-button]').click() - - // ugh - cy.wait(1500) - - cy.phCaptures({ full: true }).then((captures) => { - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$autocapture', '$exception']) - expect(captures[2].event).to.be.eql('$exception') - expect(captures[2].properties.$exception_list[0].stacktrace.type).to.be.eq('raw') - expect(captures[2].properties.$exception_list[0].stacktrace.frames.length).to.be.eq(1) - expect(captures[2].properties.$exception_list[0].stacktrace.frames[0].function).to.be.eq( - 'HTMLButtonElement.onclick' - ) - }) - }) - - it('autocaptures exceptions', () => { - cy.get('[data-cy-button-throws-error]').click() - - // ugh - cy.wait(1500) - - cy.phCaptures({ full: true }).then((captures) => { - expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$autocapture', '$exception']) - expect(captures[2].event).to.be.eql('$exception') - expect(captures[2].properties.$exception_list[0].value).to.be.eql('This is an error') - expect(captures[2].properties.$exception_list[0].type).to.be.eql('Error') - - expect(captures[2].properties.$exception_personURL).to.match( - /http:\/\/localhost:\d+\/project\/test_token\/person\/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ - ) - }) - }) - - it('sets stacktrace on manual captures if autocapture enabled', () => { - cy.get('[data-cy-exception-button]').click() - - // ugh - cy.wait(1500) - - cy.phCaptures({ full: true }).then((captures) => { - expect(captures[2].properties.$exception_list).to.exist - expect(captures[2].properties.$exception_list[0].value).to.be.eql('wat even am I') - }) - }) - }) -}) diff --git a/cypress/e2e/identify.cy.ts b/cypress/e2e/identify.cy.ts deleted file mode 100644 index 92ae88711..000000000 --- a/cypress/e2e/identify.cy.ts +++ /dev/null @@ -1,76 +0,0 @@ -/// - -import { start } from '../support/setup' - -describe('identify()', () => { - beforeEach(() => { - start({}) - }) - - it('uses the v7 uuid format', () => { - cy.posthog().invoke('capture', 'an-anonymous-event') - cy.phCaptures({ full: true }).then((events) => { - 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) - }) - }) - - it('opt_out_capturing() does not fail after identify()', () => { - cy.posthog().invoke('identify', 'some-id') - cy.posthog().invoke('opt_out_capturing') - }) - - it('merges people as expected when reset is called', () => { - cy.posthog().invoke('capture', 'an-anonymous-event') - cy.posthog().invoke('identify', 'first-identify') // test identify merges with previous events after init - cy.posthog().invoke('capture', 'an-identified-event') - cy.posthog().invoke('identify', 'second-identify-should-not-be-merged') // test identify is not sent after previous identify - cy.posthog().invoke('capture', 'another-identified-event') // but does change the distinct id - cy.posthog().invoke('reset') - cy.posthog().invoke('capture', 'an-anonymous-event') - cy.posthog().invoke('identify', 'third-identify') - cy.posthog().invoke('capture', 'an-identified-event') - - cy.phCaptures({ full: true }).then((events) => { - const eventsSeen = events.map((e) => e.event) - - expect(eventsSeen.filter((e) => e === '$identify').length).to.eq(2) - - expect(eventsSeen).to.deep.eq([ - '$pageview', - 'an-anonymous-event', - '$identify', - 'an-identified-event', - 'another-identified-event', - 'an-anonymous-event', - '$identify', - 'an-identified-event', - ]) - - expect(new Set(events.map((e) => e.properties['$device_id'])).size).to.eql(1) - - // the first two events share a distinct id - expect(events[0].properties.distinct_id).to.eql(events[1].properties.distinct_id) - // then first identify is called and sends that distinct id as its anon to merge - expect(events[2].properties.distinct_id).to.eql('first-identify') - expect(events[2].properties['$anon_distinct_id']).to.eql(events[0].properties.distinct_id) - // and an event is sent with that distinct id - expect(events[3].properties.distinct_id).to.eql('first-identify') - // then second identify is called and is ignored but does change the distinct id - expect(events[4].event).to.eql('another-identified-event') - expect(events[4].properties.distinct_id).to.eql('second-identify-should-not-be-merged') - // then reset is called and the next event has a new distinct id - expect(events[5].event).to.eql('an-anonymous-event') - expect(events[5].properties.distinct_id) - .not.to.eql('first-identify') - .and.not.to.eql('second-identify-should-not-be-merged') - // then an identify merges that distinct id with the new distinct id - expect(events[6].properties.distinct_id).to.eql('third-identify') - expect(events[6].properties['$anon_distinct_id']).to.eql(events[5].properties.distinct_id) - // then a final identified event includes that identified distinct id - expect(events[7].properties.distinct_id).to.eql('third-identify') - }) - }) -}) diff --git a/cypress/e2e/opting-out.cy.ts b/cypress/e2e/opting-out.cy.ts deleted file mode 100644 index 34ee0f159..000000000 --- a/cypress/e2e/opting-out.cy.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { assertWhetherPostHogRequestsWereCalled } from '../support/assertions' -import { start } from '../support/setup' - -describe('opting out', () => { - describe('when starting disabled in some way', () => { - beforeEach(() => { - cy.intercept('POST', '/decide/*', { - editorParams: {}, - featureFlags: ['session-recording-player'], - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - }, - capture_performance: true, - autocapture_opt_out: true, - }).as('decide') - - cy.visit('./playground/cypress') - }) - - it('does not capture events without init', () => { - cy.get('[data-cy-input]').type('hello world! ') - - assertWhetherPostHogRequestsWereCalled({ - '@recorder-script': 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-script': 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('sends a $pageview event when opting in', () => { - cy.intercept('POST', '/decide/*', { - autocapture_opt_out: true, - editorParams: {}, - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - // will never record a session with rate of 0 - sampleRate: '0', - }, - }).as('decide') - - cy.posthogInit({ - opt_out_capturing_by_default: true, - }) - // Wait for the pageview timeout - cy.wait(100) - cy.phCaptures({ full: true }).then((captures) => { - expect(captures || []).to.have.length(0) - }) - - cy.posthog().invoke('opt_in_capturing') - - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$opt_in', '$pageview']) - }) - }) - - it('does not send a duplicate $pageview event when opting in', () => { - cy.intercept('POST', '/decide/*', { - autocapture_opt_out: true, - editorParams: {}, - isAuthenticated: false, - sessionRecording: { - endpoint: '/ses/', - // will never record a session with rate of 0 - sampleRate: '0', - }, - }).as('decide') - - cy.posthogInit({}) - // Wait for the pageview timeout - cy.wait(100) - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$pageview']) - }) - - cy.posthog().invoke('opt_in_capturing') - - cy.phCaptures({ full: true }).then((captures) => { - expect((captures || []).map((c) => c.event)).to.deep.equal(['$pageview', '$opt_in']) - }) - }) - }) - - describe('user opts out after start', () => { - it('does not send any autocapture/custom events after that', () => { - start({}) - - cy.posthog().invoke('opt_out_capturing') - - cy.get('[data-cy-custom-event-button]').click() - cy.get('[data-cy-feature-flag-button]').click() - cy.reload() - - cy.phCaptures().should('deep.equal', ['$pageview']) - }) - }) -}) diff --git a/cypress/e2e/ua.cy.ts b/cypress/e2e/ua.cy.ts deleted file mode 100644 index 6df99b8ec..000000000 --- a/cypress/e2e/ua.cy.ts +++ /dev/null @@ -1,14 +0,0 @@ -/// -import { start } from '../support/setup' - -describe('User Agent Blocking', () => { - it('should pick up that our automated cypress tests are indeed bot traffic', async () => { - cy.skipOn('windows') - start({}) - - cy.window().then((win) => { - const isLikelyBot = win.eval('window.posthog._is_bot()') - expect(isLikelyBot).to.eql(true) - }) - }) -}) diff --git a/cypress/support/compression.ts b/cypress/support/compression.ts index cebb9b8f5..223359bea 100644 --- a/cypress/support/compression.ts +++ b/cypress/support/compression.ts @@ -1,10 +1,17 @@ import { decompressSync, strFromU8 } from 'fflate' -export function getBase64EncodedPayload(request) { - const data = decodeURIComponent(request.body.match(/data=(.*)/)[1]) +export function getBase64EncodedPayloadFromBody(body: unknown): Record { + if (typeof body !== 'string') { + throw new Error('Expected body to be a string') + } + const data = decodeURIComponent(body.match(/data=(.*)/)[1]) return JSON.parse(Buffer.from(data, 'base64').toString()) } +export function getBase64EncodedPayload(request) { + return getBase64EncodedPayloadFromBody(request.body) +} + export async function getGzipEncodedPayload(request) { const data = new Uint8Array(await request.body) const decoded = strFromU8(decompressSync(data)) diff --git a/package.json b/package.json index 02483fc7e..046cd6542 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,8 @@ "*.js": "eslint --cache --fix", "*.{ts,tsx}": [ "eslint src --fix", - "eslint cypress --fix" + "eslint cypress --fix", + "eslint playwright --fix" ] }, "browserslist": [ diff --git a/playground/cypress-full/index.html b/playground/cypress-full/index.html index ee788682c..e6ece5287 100644 --- a/playground/cypress-full/index.html +++ b/playground/cypress-full/index.html @@ -48,7 +48,7 @@

my favourite takeaway has this order now not button in their hero image

-
it's a great example of why you need dead click tracking
+
it's a great example of why you need dead click tracking
my favourite takeaway has this order now not button in their hero image
diff --git a/playground/cypress/index.html b/playground/cypress/index.html index e3340f48e..0ed6f036c 100644 --- a/playground/cypress/index.html +++ b/playground/cypress/index.html @@ -90,7 +90,7 @@

my favourite takeaway has this order now not button in their hero image

-
it's a great example of why you need dead click tracking
+
it's a great example of why you need dead click tracking
my favourite takeaway has this order now not button in their hero image
diff --git a/playwright/autocapture-config.spec.ts b/playwright/autocapture-config.spec.ts new file mode 100644 index 000000000..499f8373f --- /dev/null +++ b/playwright/autocapture-config.spec.ts @@ -0,0 +1,145 @@ +import { test } from './utils/posthog-playwright-test-base' +import { start } from './utils/setup' + +const startOptions = { + options: {}, + decideResponseOverrides: { + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, + }, + url: './playground/cypress/index.html', +} + +test.describe('autocapture config', () => { + test('do not capture click if not in allowlist', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { + ...startOptions.options, + capture_pageview: false, + autocapture: { + dom_event_allowlist: ['change'], + }, + }, + }, + page, + context + ) + + await page.locator('[data-cy-custom-event-button]').click() + // no autocapture event from click + await page.expectCapturedEventsToBe(['custom-event']) + + await page.locator('[data-cy-input]').fill('hello posthog!') + // blur the input + await page.locator('body').click() + await page.expectCapturedEventsToBe(['custom-event', '$autocapture']) + }) + + test('capture clicks when configured to', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { ...startOptions.options, autocapture: { dom_event_allowlist: ['click'] } }, + }, + page, + context + ) + + await page.locator('[data-cy-custom-event-button]').click() + await page.expectCapturedEventsToBe(['$pageview', '$autocapture', 'custom-event']) + + await page.locator('[data-cy-input]').fill('hello posthog!') + // blur the input + await page.locator('body').click() + // no change autocapture event + await page.expectCapturedEventsToBe(['$pageview', '$autocapture', 'custom-event']) + }) + + test('obeys url allowlist', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { ...startOptions.options, autocapture: { url_allowlist: ['.*test-is-not-on-this.*'] } }, + }, + page, + context + ) + + await page.locator('[data-cy-custom-event-button]').click() + await page.expectCapturedEventsToBe(['$pageview', 'custom-event']) + + await page.resetCapturedEvents() + await start( + { + ...startOptions, + options: { ...startOptions.options, autocapture: { url_allowlist: ['.*cypress.*'] } }, + }, + page, + context + ) + + await page.locator('[data-cy-custom-event-button]').click() + await page.expectCapturedEventsToBe(['$pageview', '$autocapture', 'custom-event']) + }) + + test('obeys element allowlist', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { ...startOptions.options, autocapture: { element_allowlist: ['button'] } }, + }, + page, + context + ) + + await page.locator('[data-cy-custom-event-button]').click() + await page.expectCapturedEventsToBe(['$pageview', '$autocapture', 'custom-event']) + + await page.resetCapturedEvents() + await start( + { + ...startOptions, + options: { ...startOptions.options, autocapture: { element_allowlist: ['input'] } }, + }, + page, + context + ) + + await page.locator('[data-cy-custom-event-button]').click() + await page.expectCapturedEventsToBe(['$pageview', 'custom-event']) + }) + + test('obeys css selector allowlist', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { + ...startOptions.options, + autocapture: { css_selector_allowlist: ['[data-cy-custom-event-button]'] }, + }, + }, + page, + context + ) + + await page.locator('[data-cy-custom-event-button]').click() + await page.expectCapturedEventsToBe(['$pageview', '$autocapture', 'custom-event']) + + await page.resetCapturedEvents() + await start( + { + ...startOptions, + options: { ...startOptions.options, autocapture: { css_selector_allowlist: ['[data-cy-input]'] } }, + }, + page, + context + ) + + await page.locator('[data-cy-custom-event-button]').click() + await page.expectCapturedEventsToBe(['$pageview', 'custom-event']) + }) +}) diff --git a/playwright/before_send.spec.ts b/playwright/before_send.spec.ts new file mode 100644 index 000000000..26a5e35ea --- /dev/null +++ b/playwright/before_send.spec.ts @@ -0,0 +1,76 @@ +import { expect, test, WindowWithPostHog } from './utils/posthog-playwright-test-base' +import { start } from './utils/setup' +import { BeforeSendFn } from '../src/types' + +const startOptions = { + options: { + session_recording: {}, + }, + decideResponseOverrides: { + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, + }, + url: './playground/cypress-full/index.html', +} + +test.describe('before_send', () => { + test('can sample and edit with before_send', async ({ page, context }) => { + await start(startOptions, page, context) + + await page.evaluate(() => { + const posthog = (window as WindowWithPostHog).posthog + if (!posthog) { + throw new Error('PostHog is not initialized') + } + let counter = 0 + // box the original before_send function + const og: BeforeSendFn[] = Array.isArray(posthog.config.before_send) + ? posthog.config.before_send + : posthog.config.before_send !== undefined + ? [posthog.config.before_send] + : [] + + posthog.config.before_send = [ + (cr) => { + if (!cr) { + return null + } + + if (cr.event === 'custom-event') { + counter++ + if (counter === 2) { + return null + } + } + if (cr.event === '$autocapture') { + return { + ...cr, + event: 'redacted', + } + } + return cr + }, + // these tests rely on existing before_send function to capture events + // so we have to add it back in here + ...og, + ] + }) + + await page.locator('[data-cy-custom-event-button]').click() + await page.locator('[data-cy-custom-event-button]').click() + + const captures = (await page.capturedEvents()).map((x) => x.event) + + expect(captures).toEqual([ + // before adding the new before sendfn + '$pageview', + 'redacted', + 'custom-event', + // second button click only has the redacted autocapture event + 'redacted', + // because the second custom-event is rejected + ]) + }) +}) diff --git a/playwright/capture.spec.ts b/playwright/capture.spec.ts new file mode 100644 index 000000000..5f3192cd0 --- /dev/null +++ b/playwright/capture.spec.ts @@ -0,0 +1,213 @@ +import { expect, test } from './utils/posthog-playwright-test-base' +import { start } from './utils/setup' +import { pollUntilEventCaptured } from './utils/event-capture-utils' +import { Request } from '@playwright/test' +import { decompressSync, strFromU8 } from 'fflate' + +function getGzipEncodedPayloady(req: Request): Record { + const data = req.postDataBuffer() + if (!data) { + throw new Error('Expected body to be present') + } + const decoded = strFromU8(decompressSync(data)) + + return JSON.parse(decoded) +} + +const startOptions = { + options: {}, + decideResponseOverrides: { + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, + }, + url: './playground/cypress/index.html', +} + +test.describe('event capture', () => { + test('captures pageviews, autocapture, and custom events', async ({ page, context }) => { + await start(startOptions, page, context) + + await page.click('[data-cy-custom-event-button]') + await pollUntilEventCaptured(page, 'custom-event') + await page.expectCapturedEventsToBe(['$pageview', '$autocapture', 'custom-event']) + + await start({ ...startOptions, type: 'reload' }, page, context) + // we can't capture $pageleave because we're storing it on the page and reloading wipes that :/ + // TODO is there a way to catch and store between page loads + await page.expectCapturedEventsToBe(['$pageview']) + }) + + test('contains the correct payload after an event', async ({ page, context, browserName }) => { + const captureRequests: Request[] = [] + + page.on('request', (request) => { + if (request.url().includes('/e/') && request.method() === 'POST') { + captureRequests.push(request) + } + }) + + await start({}, page, context) + + // Pageview will be sent immediately + await pollUntilEventCaptured(page, '$pageview') + expect(captureRequests.length).toEqual(1) + const captureRequest = captureRequests[0] + expect(captureRequest.headers()['content-type']).toEqual('text/plain') + expect(captureRequest.url()).toMatch(/gzip/) + // webkit doesn't allow us to read the body for some reason + // see e.g. https://github.com/microsoft/playwright/issues/6479 + if (browserName !== 'webkit') { + const payload = getGzipEncodedPayloady(captureRequest) + expect(payload.event).toEqual('$pageview') + expect(Object.keys(payload.properties).length).toBeGreaterThan(0) + } + }) + + test('captures $feature_flag_called event', async ({ page, context }) => { + await start(startOptions, page, context) + await page.click('[data-cy-feature-flag-button]') + await pollUntilEventCaptured(page, '$feature_flag_called') + const featureFlagCalledEvent = await page + .capturedEvents() + .then((events) => events.find((e) => e.event === '$feature_flag_called')) + expect(featureFlagCalledEvent).toBeTruthy() + expect(featureFlagCalledEvent?.properties.$feature_flag_bootstrapped_response).toBeNull() + expect(featureFlagCalledEvent?.properties.$feature_flag_bootstrapped_payload).toBeNull() + expect(featureFlagCalledEvent?.properties.$used_bootstrap_value).toEqual(false) + }) + + test('captures $feature_flag_called with bootstrapped value properties', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { + ...startOptions.options, + bootstrap: { + featureFlags: { + 'some-feature': 'some-value', + }, + featureFlagPayloads: { + 'some-feature': 'some-payload', + }, + }, + advanced_disable_feature_flags: true, + }, + waitForDecide: false, + }, + page, + context + ) + + await page.locator('[data-cy-feature-flag-button]').click() + await pollUntilEventCaptured(page, '$feature_flag_called') + const featureFlagCalledEvent = await page + .capturedEvents() + .then((events) => events.find((e) => e.event === '$feature_flag_called')) + expect(featureFlagCalledEvent).toBeTruthy() + expect(featureFlagCalledEvent?.properties.$feature_flag_bootstrapped_response).toEqual('some-value') + expect(featureFlagCalledEvent?.properties.$feature_flag_bootstrapped_payload).toEqual('some-payload') + expect(featureFlagCalledEvent?.properties.$used_bootstrap_value).toEqual(true) + }) + + test('captures rage clicks', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { + ...startOptions.options, + rageclick: true, + }, + }, + page, + context + ) + + const button = page.locator('[data-cy-custom-event-button]') + await button.click() + await button.click() + await button.click() + + await pollUntilEventCaptured(page, '$rageclick') + }) + + test('does not capture rage clicks when autocapture is disabled', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { + ...startOptions.options, + rageclick: true, + autocapture: false, + }, + }, + page, + context + ) + + const button = page.locator('[data-cy-custom-event-button]') + await button.click() + await button.click() + await button.click() + + // no rageclick event to wait for so just wait a little + await page.waitForTimeout(250) + await page.expectCapturedEventsToBe(['$pageview', 'custom-event', 'custom-event', 'custom-event']) + }) + + test('captures pageviews and custom events when autocapture disabled', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { + ...startOptions.options, + autocapture: false, + }, + }, + page, + context + ) + + await page.click('[data-cy-custom-event-button]') + await pollUntilEventCaptured(page, 'custom-event') + await page.expectCapturedEventsToBe(['$pageview', 'custom-event']) + }) + + test('captures autocapture, custom events, when pageviews is disabled', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { + ...startOptions.options, + capture_pageview: false, + }, + }, + page, + context + ) + + await page.click('[data-cy-custom-event-button]') + await pollUntilEventCaptured(page, 'custom-event') + await page.expectCapturedEventsToBe(['$autocapture', 'custom-event']) + }) + + test('can capture custom events when auto events is disabled', async ({ page, context }) => { + await start( + { + ...startOptions, + options: { + ...startOptions.options, + capture_pageview: false, + autocapture: false, + }, + }, + page, + context + ) + + await page.click('[data-cy-custom-event-button]') + await pollUntilEventCaptured(page, 'custom-event') + await page.expectCapturedEventsToBe(['custom-event']) + }) +}) diff --git a/playwright/dead-clicks.spec.ts b/playwright/dead-clicks.spec.ts new file mode 100644 index 000000000..1d132243e --- /dev/null +++ b/playwright/dead-clicks.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from './utils/posthog-playwright-test-base' +import { start } from './utils/setup' +import { pollUntilEventCaptured } from './utils/event-capture-utils' + +const startOptions = { + options: { + capture_dead_clicks: true, + }, + url: './playground/cypress/index.html', +} + +test.describe('Dead clicks', () => { + test('capture dead clicks when configured to', async ({ page, context }) => { + await start(startOptions, page, context) + + await page.locator('[data-cy-not-an-order-button]').click() + + await pollUntilEventCaptured(page, '$dead_click') + + const deadClicks = (await page.capturedEvents()).filter((event) => event.event === '$dead_click') + expect(deadClicks.length).toBe(1) + const deadClick = deadClicks[0] + + // this assertion flakes, sometimes there is no $dead_click_last_mutation_timestamp + //expect(deadClick.properties.$dead_click_last_mutation_timestamp).toBeGreaterThan(0) + expect(deadClick.properties.$dead_click_event_timestamp).toBeGreaterThan(0) + expect(deadClick.properties.$dead_click_absolute_delay_ms).toBeGreaterThan(0) + expect(deadClick.properties.$dead_click_scroll_timeout).toBe(false) + expect(deadClick.properties.$dead_click_mutation_timeout).toBe(false) + expect(deadClick.properties.$dead_click_absolute_timeout).toBe(true) + }) + + test('does not capture dead click for selected text', async ({ page, context }) => { + await start(startOptions, page, context) + + await page.resetCapturedEvents() + + const locator = page.locator('[data-cy-dead-click-text]') + const boundingBox = await locator.boundingBox() + if (!boundingBox) { + throw new Error('must get a bounding box') + } + const position = boundingBox.x + boundingBox.width / 2 + const wordToSelectLength = 50 + + await page.mouse.move(position, boundingBox.y) + + await page.mouse.down() + await page.mouse.move(position + wordToSelectLength, boundingBox.y) + await page.mouse.up() + await page.mouse.dblclick(position, boundingBox.y) + + const selection = await page.evaluate(() => window.getSelection()?.toString()) + expect(selection?.trim().length).toBeGreaterThan(0) + + await page.waitForTimeout(1000) + await page.expectCapturedEventsToBe([]) + }) +}) diff --git a/playwright/decide.spec.ts b/playwright/decide.spec.ts new file mode 100644 index 000000000..6e55a7498 --- /dev/null +++ b/playwright/decide.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from './utils/posthog-playwright-test-base' +import { Request } from '@playwright/test' +import { start } from './utils/setup' +import { PostHog } from '../src/posthog-core' +import { getBase64EncodedPayloadFromBody } from '../cypress/support/compression' + +const startOptions = { + options: {}, + decideResponseOverrides: { + sessionRecording: { + endpoint: '/ses/', + }, + capturePerformance: true, + }, + url: './playground/cypress/index.html', +} + +test.describe('decide', () => { + // we want to grab any requests to decide so we can inspect their payloads + let decideRequests: Request[] = [] + + test.beforeEach(async ({ page, context }) => { + decideRequests = [] + + page.on('request', (request) => { + if (request.url().includes('/decide/')) { + decideRequests.push(request) + } + }) + + await start( + { + ...startOptions, + options: { + ...startOptions.options, + }, + runBeforePostHogInit: async (page) => { + // it's tricky to pass functions as args the way posthog config is passed in playwright + // so here we set the function on the window object + // and then call it in the loaded function during init + await page.evaluate(() => { + ;(window as any).__ph_loaded = (ph: PostHog) => { + ph.identify('new-id') + ph.group('company', 'id:5', { id: 5, company_name: 'Awesome Inc' }) + ph.group('playlist', 'id:77', { length: 8 }) + } + }) + }, + }, + page, + context + ) + }) + + test('makes decide request on start', async () => { + expect(decideRequests.length).toBe(1) + const decideRequest = decideRequests[0] + const decidePayload = getBase64EncodedPayloadFromBody(decideRequest.postData()) + expect(decidePayload).toEqual({ + token: 'test token', + distinct_id: 'new-id', + person_properties: {}, + $anon_distinct_id: decidePayload.$anon_distinct_id, + groups: { + company: 'id:5', + playlist: 'id:77', + }, + group_properties: { + company: { id: 5, company_name: 'Awesome Inc' }, + playlist: { length: 8 }, + }, + }) + }) + + test('does a single decide call on following changes', async ({ page }) => { + expect(decideRequests.length).toBe(1) + + await page.waitingForNetworkCausedBy(['**/decide/**'], async () => { + await page.evaluate(() => { + const ph = (window as any).posthog + ph.group('company', 'id:6') + ph.group('playlist', 'id:77') + ph.group('anothergroup', 'id:99') + }) + }) + // need a short delay so that the decide request can be captured into the decideRequests array + await page.waitForTimeout(1) + expect(decideRequests.length).toBe(2) + }) +}) diff --git a/playwright/error-tracking.spec.ts b/playwright/error-tracking.spec.ts new file mode 100644 index 000000000..0d003f827 --- /dev/null +++ b/playwright/error-tracking.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from './utils/posthog-playwright-test-base' +import { start } from './utils/setup' +import { pollUntilEventCaptured } from './utils/event-capture-utils' + +test.describe('Exception capture', () => { + test('manual exception capture', async ({ page, context }) => { + await start( + { + decideResponseOverrides: { + autocaptureExceptions: false, + }, + url: './playground/cypress/index.html', + }, + page, + context + ) + + await page.click('[data-cy-exception-button]') + + await pollUntilEventCaptured(page, '$exception') + + const captures = await page.capturedEvents() + expect(captures.map((c) => c.event)).toEqual(['$pageview', '$autocapture', '$exception']) + expect(captures[2].event).toEqual('$exception') + expect(captures[2].properties.extra_prop).toEqual(2) + expect(captures[2].properties.$exception_source).toBeUndefined() + expect(captures[2].properties.$exception_personURL).toBeUndefined() + expect(captures[2].properties.$exception_list[0].value).toEqual('wat even am I') + expect(captures[2].properties.$exception_list[0].type).toEqual('Error') + }) + + test.describe('Exception autocapture enabled', () => { + test.beforeEach(async ({ page, context }) => { + await page.waitingForNetworkCausedBy(['**/exception-autocapture.js*'], async () => { + await start( + { + decideResponseOverrides: { + autocaptureExceptions: true, + }, + url: './playground/cypress/index.html', + }, + page, + context + ) + }) + }) + + test('adds stacktrace to captured strings', async ({ page, browserName }) => { + await page.click('[data-cy-exception-string-button]') + + await pollUntilEventCaptured(page, '$exception') + + const captures = await page.capturedEvents() + expect(captures.map((c) => c.event)).toEqual(['$pageview', '$autocapture', '$exception']) + expect(captures[2].event).toEqual('$exception') + expect(captures[2].properties.$exception_list[0].stacktrace.type).toEqual('raw') + expect(captures[2].properties.$exception_list[0].stacktrace.frames.length).toEqual(1) + expect(captures[2].properties.$exception_list[0].stacktrace.frames[0].function).toEqual( + // turns out firefox and chromium capture different info :/ + browserName === 'chromium' ? 'HTMLButtonElement.onclick' : 'onclick' + ) + }) + }) +}) diff --git a/playwright/group-analytics.spec.ts b/playwright/group-analytics.spec.ts new file mode 100644 index 000000000..43e6bfb69 --- /dev/null +++ b/playwright/group-analytics.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from './utils/posthog-playwright-test-base' +import { start } from './utils/setup' +import { PostHog } from '../src/posthog-core' + +test.describe('group analytics', () => { + test('includes group information in all event payloads', async ({ page, context }) => { + await start( + { + runBeforePostHogInit: async (page) => { + // it's tricky to pass functions as args the way posthog config is passed in playwright + // so here we set the function on the window object + // and then call it in the loaded function during init + await page.evaluate(() => { + ;(window as any).__ph_loaded = (ph: PostHog) => { + ph.group('company', 'id:5') + } + }) + }, + }, + page, + context + ) + + await page.locator('[data-cy-custom-event-button]').click() + + const capturedEvents = await page.capturedEvents() + expect(capturedEvents).toHaveLength(3) + const hasGroups = new Set(capturedEvents.map((x) => !!x.properties.$groups)) + expect(hasGroups).toEqual(new Set([true])) + }) +}) diff --git a/playwright/identify.spec.ts b/playwright/identify.spec.ts new file mode 100644 index 000000000..23caf1538 --- /dev/null +++ b/playwright/identify.spec.ts @@ -0,0 +1,85 @@ +import { expect, test, WindowWithPostHog } from './utils/posthog-playwright-test-base' +import { start } from './utils/setup' + +test.describe('Identify', () => { + test.beforeEach(async ({ page, context }) => { + await start({}, page, context) + }) + + test('uses the v7 uuid format for device id', async ({ page }) => { + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.capture('an-anonymous-event') + }) + const capturedEvents = await page.capturedEvents() + const deviceIds = new Set(capturedEvents.map((e) => e.properties['$device_id'])) + expect(deviceIds.size).toEqual(1) + const [deviceId] = deviceIds + expect(deviceId.length).toEqual(36) + }) + + test('opt out capturing does not fail after identify', async ({ page }) => { + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.identify('some-id') + }) + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.opt_out_capturing() + }) + const isOptedOut = await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + return ph?.has_opted_out_capturing() + }) + expect(isOptedOut).toEqual(true) + }) + + test('merges people as expected when reset is called', async ({ page }) => { + await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + ph?.capture('an-anonymous-event') + ph?.identify('first-identify') + ph?.capture('an-identified-event') + ph?.identify('second-identify-should-not-be-merged') + ph?.capture('another-identified-event') + ph?.reset() + ph?.capture('an-anonymous-event') + ph?.identify('third-identify') + ph?.capture('an-identified-event') + }) + const capturedEvents = await page.capturedEvents() + const eventsSeen = capturedEvents.map((e) => e.event) + expect(eventsSeen.filter((e) => e === '$identify').length).toEqual(2) + expect(eventsSeen).toEqual([ + '$pageview', + 'an-anonymous-event', + '$identify', + 'an-identified-event', + 'another-identified-event', + 'an-anonymous-event', + '$identify', + 'an-identified-event', + ]) + expect(new Set(capturedEvents.map((e) => e.properties['$device_id'])).size).toEqual(1) + + // the first two events share a distinct id + expect(capturedEvents[0].properties.distinct_id).toEqual(capturedEvents[1].properties.distinct_id) + // then first identify is called and sends that distinct id as its anon to merge + expect(capturedEvents[2].properties.distinct_id).toEqual('first-identify') + expect(capturedEvents[2].properties['$anon_distinct_id']).toEqual(capturedEvents[0].properties.distinct_id) + // and an event is sent with that distinct id + expect(capturedEvents[3].properties.distinct_id).toEqual('first-identify') + // then second identify is called and is ignored but does change the distinct id + expect(capturedEvents[4].event).toEqual('another-identified-event') + expect(capturedEvents[4].properties.distinct_id).toEqual('second-identify-should-not-be-merged') + // then reset is called and the next event has a new distinct id + expect(capturedEvents[5].event).toEqual('an-anonymous-event') + expect(capturedEvents[5].properties.distinct_id).not.toEqual('first-identify') + expect(capturedEvents[5].properties.distinct_id).not.toEqual('second-identify-should-not-be-merged') + // then an identify merges that distinct id with the new distinct id + expect(capturedEvents[6].properties.distinct_id).toEqual('third-identify') + expect(capturedEvents[6].properties['$anon_distinct_id']).toEqual(capturedEvents[5].properties.distinct_id) + // then a final identified event includes that identified distinct id + expect(capturedEvents[7].properties.distinct_id).toEqual('third-identify') + }) +}) diff --git a/playwright/opting-out.spec.ts b/playwright/opting-out.spec.ts new file mode 100644 index 000000000..2519c277b --- /dev/null +++ b/playwright/opting-out.spec.ts @@ -0,0 +1,115 @@ +import { test, WindowWithPostHog } from './utils/posthog-playwright-test-base' +import { start, gotoPage } from './utils/setup' + +test.describe('opting out', () => { + test.describe('when not initialized', () => { + test('does not capture events without init', async ({ page }) => { + await gotoPage(page, './playground/cypress/index.html') + await page.type('[data-cy-input]', 'hello posthog!') + await page.expectCapturedEventsToBe([]) + }) + }) + + test.describe('when starting disabled in some way', () => { + test('does not capture events when config opts out by default', async ({ page, context }) => { + await start( + { + decideResponseOverrides: { + autocapture_opt_out: true, + }, + options: { + opt_out_capturing_by_default: true, + }, + url: './playground/cypress/index.html', + }, + page, + context + ) + + await page.expectCapturedEventsToBe([]) + + await page.type('[data-cy-input]', 'hello posthog!') + + await page.expectCapturedEventsToBe([]) + }) + + test('sends a $pageview event when opting in', async ({ page, context }) => { + await start( + { + decideResponseOverrides: { + autocapture_opt_out: true, + }, + options: { + opt_out_capturing_by_default: true, + }, + url: './playground/cypress/index.html', + }, + page, + context + ) + + await page.expectCapturedEventsToBe([]) + + await page.evaluate(() => { + ;(window as WindowWithPostHog).posthog?.opt_in_capturing() + }) + + await page.expectCapturedEventsToBe(['$opt_in', '$pageview']) + }) + + test('does not send a duplicate $pageview event when opting in', async ({ page, context }) => { + await start( + { + decideResponseOverrides: { + autocapture_opt_out: true, + }, + options: { + // start opted in! + opt_out_capturing_by_default: false, + }, + url: './playground/cypress/index.html', + }, + page, + context + ) + + await page.expectCapturedEventsToBe(['$pageview']) + + await page.evaluate(() => { + ;(window as WindowWithPostHog).posthog?.opt_in_capturing() + }) + + await page.expectCapturedEventsToBe(['$pageview', '$opt_in']) + }) + }) + + test.describe('user opts out after start', () => { + test('does not send any events after that', async ({ page, context }) => { + await start( + { + decideResponseOverrides: { + autocapture_opt_out: false, + }, + url: './playground/cypress/index.html', + }, + page, + context + ) + + await page.expectCapturedEventsToBe(['$pageview']) + + await page.click('[data-cy-custom-event-button]') + + await page.expectCapturedEventsToBe(['$pageview', '$autocapture', 'custom-event']) + + await page.evaluate(() => { + ;(window as WindowWithPostHog).posthog?.opt_out_capturing() + }) + + await page.click('[data-cy-custom-event-button]') + + // no new events + await page.expectCapturedEventsToBe(['$pageview', '$autocapture', 'custom-event']) + }) + }) +}) diff --git a/playwright/session-recording/opting-out.spec.ts b/playwright/session-recording/opting-out.spec.ts index fc70f6c91..51464f6d7 100644 --- a/playwright/session-recording/opting-out.spec.ts +++ b/playwright/session-recording/opting-out.spec.ts @@ -88,4 +88,33 @@ test.describe('Session Recording - opting out', () => { await pollUntilEventCaptured(page, '$snapshot') await assertThatRecordingStarted(page) }) + + test('does not capture session recordings when decide is disabled', async ({ page, context }) => { + await start( + { options: { advanced_disable_decide: true, autocapture: false }, waitForDecide: false }, + page, + context + ) + + await page.locator('[data-cy-custom-event-button]').click() + + const callsToSessionRecording = page.waitForResponse('**/ses/').catch(() => { + // when the test ends, waitForResponse will throw an error + // we're happy not to get a response here so we can swallow it + return null + }) + + await page.locator('[data-cy-input]').type('hello posthog!') + + void callsToSessionRecording.then((response) => { + if (response) { + throw new Error('Session recording call was made and should not have been') + } + }) + await page.waitForTimeout(200) + + const capturedEvents = await page.capturedEvents() + // no snapshot events sent + expect(capturedEvents.map((x) => x.event)).toEqual(['$pageview', 'custom-event']) + }) }) diff --git a/playwright/ua.spec.ts b/playwright/ua.spec.ts new file mode 100644 index 000000000..02aa83c59 --- /dev/null +++ b/playwright/ua.spec.ts @@ -0,0 +1,27 @@ +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, + }, + url: './playground/cypress-full/index.html', +} + +test.describe('User Agent Blocking', () => { + test('should pick up that our automated playwright tests are indeed bot traffic', async ({ page, context }) => { + await start(startOptions, page, context) + + const isLikelyBot = await page.evaluate(() => { + const ph = (window as WindowWithPostHog).posthog + return ph?._is_bot() + }) + expect(isLikelyBot).toEqual(true) + }) +}) diff --git a/playwright/utils/posthog-playwright-test-base.ts b/playwright/utils/posthog-playwright-test-base.ts index bd96e95f4..6d39e6714 100644 --- a/playwright/utils/posthog-playwright-test-base.ts +++ b/playwright/utils/posthog-playwright-test-base.ts @@ -78,6 +78,11 @@ export const test = base.extend<{ mockStaticAssets: void; page: Page }>({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 1 }), + headers: { + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Credentials': 'true', + }, }) }) @@ -92,7 +97,9 @@ export const test = base.extend<{ mockStaticAssets: void; page: Page }>({ lazyLoadedJSFiles.forEach((key: string) => { void context.route(new RegExp(`^.*/static/${key}\\.js(\\?.*)?$`), (route) => { route.fulfill({ - headers: { loaded: 'using relative path by playwright' }, + headers: { + loaded: 'using relative path by playwright', + }, path: `./dist/${key}.js`, }) }) diff --git a/playwright/utils/setup.ts b/playwright/utils/setup.ts index b82eabe02..43d9eb94c 100644 --- a/playwright/utils/setup.ts +++ b/playwright/utils/setup.ts @@ -3,13 +3,29 @@ import { Compression, DecideResponse, PostHogConfig } from '../../src/types' import path from 'path' import { WindowWithPostHog } from './posthog-playwright-test-base' +/** + * uses the standard playwright page.goto + * but if the URL starts with './' + * treats it as a relative file path + * + */ +export async function gotoPage(page: Page, url: string) { + // 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) +} + export async function start( { waitForDecide = true, initPosthog = true, resetOnInit = false, - runBeforePostHogInit = () => {}, - runAfterPostHogInit = () => {}, + runBeforePostHogInit = undefined, + runAfterPostHogInit = undefined, type = 'navigate', options = {}, decideResponseOverrides = { @@ -69,16 +85,10 @@ export async function start( 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 gotoPage(page, url) } - runBeforePostHogInit(page) + runBeforePostHogInit?.(page) // Initialize PostHog if required if (initPosthog) { @@ -89,17 +99,23 @@ export async function start( api_host: 'https://localhost:1234', debug: true, before_send: (event) => { + const win = window as WindowWithPostHog + win.capturedEvents = win.capturedEvents || [] + if (event) { - const win = window as WindowWithPostHog - win.capturedEvents = win.capturedEvents || [] win.capturedEvents.push(event) } + return event }, loaded: (ph) => { if (ph.sessionRecording) { ph.sessionRecording._forceAllowLocalhostNetworkCapture = true } + // playwright can't serialize functions to pass around from the playwright to browser context + // if we want to run custom code in the loaded function we need to pass it on the page's window, + // but it's a new window so we have to create it in the `before_posthog_init` option + ;(window as any).__ph_loaded?.(ph) }, opt_out_useragent_filter: true, ...posthogOptions, @@ -112,7 +128,7 @@ export async function start( ) } - runAfterPostHogInit(page) + runAfterPostHogInit?.(page) // Reset PostHog if required if (resetOnInit) {