import { initializePageDynamically } from './support/initialize-page-dynamically' import { mockManyRequests, resolveWithTimestamps } from './support/mock-many-requests' import { expectPlausibleInAction, isEngagementEvent, isPageviewEvent, switchByMode } from './support/test-utils' import { expect, test } from '@playwright/test' import { ScriptConfig } from './support/types' import { LOCAL_SERVER_ADDR } from './support/server' const DEFAULT_CONFIG: ScriptConfig = { domain: 'example.com', endpoint: `${LOCAL_SERVER_ADDR}/api/event`, captureOnLocalhost: true } for (const mode of ['web', 'esm']) { test.describe(`respects "outboundLinks" v2 config option (${mode})`, () => { test('does not track outbound links when "outboundLinks: false"', async ({ page }, { testId }) => { const outboundUrl = 'https://other.example.com/target' const outboundMock = await mockManyRequests({ page, path: outboundUrl, fulfill: { status: 200, contentType: 'text/html', body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) const config = { ...DEFAULT_CONFIG, outboundLinks: false } const { url } = await initializePageDynamically(page, { testId, scriptConfig: switchByMode( { web: config, esm: `` }, mode ), bodyContent: /* HTML */ `➡️` }) await expectPlausibleInAction(page, { action: async () => { await page.goto(url) await page.click('a') }, expectedRequests: [ { n: 'pageview', d: DEFAULT_CONFIG.domain, u: `${LOCAL_SERVER_ADDR}${url}` } ], refutedRequests: [{ n: 'Outbound Link: Click' }], shouldIgnoreRequest: isEngagementEvent }) await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) }) test('tracks outbound links when "outboundLinks: true"', async ({ page }, { testId }) => { const outboundUrl = 'https://other.example.com/target' const outboundMock = await mockManyRequests({ page, path: outboundUrl, fulfill: { status: 200, contentType: 'text/html', body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) const config = { ...DEFAULT_CONFIG, outboundLinks: true } const { url } = await initializePageDynamically(page, { testId, scriptConfig: switchByMode( { web: config, esm: `` }, mode ), bodyContent: /* HTML */ `➡️` }) await expectPlausibleInAction(page, { action: async () => { await page.goto(url) await page.click('a') }, expectedRequests: [ { n: 'pageview', d: DEFAULT_CONFIG.domain, u: `${LOCAL_SERVER_ADDR}${url}` }, { n: 'Outbound Link: Click', p: { url: outboundUrl } } ], shouldIgnoreRequest: isEngagementEvent }) await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) }) }) } for (const mode of ['legacy', 'web']) test.describe(`outbound links feature legacy/v2 parity (${mode})`, () => { for (const { clickName, click, skip } of [ { clickName: 'when left clicking on link', click: { element: 'a' } }, { clickName: 'when left clicking on link with Ctrl or Meta key', click: { element: 'a', modifiers: ['ControlOrMeta' as const] }, skip: (browserName) => test.skip( browserName === 'webkit', 'does not open links with such clicks (works when testing manually in macOS Safari)' ) }, { clickName: 'when left clicking on child element of link', click: { element: 'a > h1' } } ]) { test(`sends event ${clickName}`, async ({ page, browserName }, { testId }) => { if (skip) { skip(browserName) } const outboundUrl = 'https://other.example.com/target' const outboundMock = await mockManyRequests({ page, path: outboundUrl, fulfill: { status: 200, contentType: 'text/html', body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: switchByMode( { web: { ...DEFAULT_CONFIG, autoCapturePageviews: false, outboundLinks: true }, legacy: '' }, mode ), bodyContent: /* HTML */ `

➡️

` }) await page.goto(url) await expectPlausibleInAction(page, { action: () => page.click(click.element, { modifiers: click.modifiers }), expectedRequests: [ { n: 'Outbound Link: Click', p: { url: outboundUrl } } ] }) await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) }) } test('tracks links without delaying navigation, relying on fetch options.keepalive to deliver tracking events', async ({ page }, { testId }) => { const eventsApiMock = await mockManyRequests({ page, path: '**/api/event', awaitedRequestCount: 1, responseDelay: 500 }) const outboundUrl = 'https://other.example.com/target' const outboundMock = await mockManyRequests({ page, path: outboundUrl, fulfill: { status: 200, contentType: 'text/html', body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: switchByMode( { web: { ...DEFAULT_CONFIG, outboundLinks: true, autoCapturePageviews: false }, legacy: '' }, mode ), bodyContent: /* HTML */ `>➡️` }) await page.goto(url) await page.click('a') const [ [trackingRequestList, trackingResponseTime], [outboundMockRequestList, outboundRequestTime] ] = await resolveWithTimestamps([ eventsApiMock.getRequestList(), outboundMock.getRequestList() ]) expect(outboundRequestTime).toBeLessThan(trackingResponseTime) expect(outboundMockRequestList).toHaveLength(1) expect(trackingRequestList).toEqual([ expect.objectContaining({ n: 'Outbound Link: Click', p: { url: outboundUrl } }) ]) }) test('limitation: does not track outbound links within svg elements', async ({ page }, { testId }) => { const outboundUrl = 'https://other.example.com/target' const outboundMock = await mockManyRequests({ page, path: outboundUrl, fulfill: { status: 200, contentType: 'text/html', body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: switchByMode( { web: { ...DEFAULT_CONFIG, outboundLinks: true }, legacy: '' }, mode ), bodyContent: /* HTML */ ` ` }) const pageErrors: Error[] = [] page.on('pageerror', (err) => pageErrors.push(err)) await page.goto(url) await expectPlausibleInAction(page, { action: () => page.click('a'), refutedRequests: [{ n: 'Outbound Link: Click' }], shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] }) expect(pageErrors).toHaveLength(0) await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) }) }) test.describe('outbound links feature when using legacy .compat extension', () => { for (const { caseName, linkAttributes, click, expected, skip } of [ { caseName: 'navigates when left clicking on link', linkAttributes: '', click: { element: 'a' }, expected: { requestsOnSamePage: 1, requestsOnOtherPages: 0 } }, { caseName: 'navigates when left clicking on link with Ctrl or Meta key', linkAttributes: '', click: { element: 'a', modifiers: ['ControlOrMeta' as const] }, expected: { requestsOnSamePage: 0, requestsOnOtherPages: 1 }, skip: (browserName) => test.skip( browserName === 'webkit', 'does not open links with such clicks (works when testing manually in macOS Safari)' ) }, { caseName: 'navigates when left clicking on child element of target="_blank" link', linkAttributes: 'target="_blank"', click: { element: 'a[target="_blank"] > h1' }, expected: { requestsOnSamePage: 0, requestsOnOtherPages: 1 } }, { caseName: 'does not navigate when left clicking on link that has called event.preventDefault()', linkAttributes: 'onclick="event.preventDefault()"', click: { element: 'a' }, expected: { requestsOnSamePage: 0, requestsOnOtherPages: 0 } } ]) { test(`tracks and ${caseName}`, async ({ page, browserName }, { testId }) => { if (skip) { skip(browserName) } const outboundUrl = 'https://other.example.com/target' const outboundMockOptions = { page, path: outboundUrl, fulfill: { status: 200, contentType: 'text/html', body: OTHER_PAGE_BODY }, awaitedRequestCount: 2, mockRequestTimeout: 2000 } const outboundMockForOtherPages = await mockManyRequests({ ...outboundMockOptions, scopeMockToPage: false }) const outboundMockForSamePage = await mockManyRequests({ ...outboundMockOptions, scopeMockToPage: true }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: '', bodyContent: /* HTML */ `

➡️

` }) await page.goto(url) await expectPlausibleInAction(page, { action: () => page.click(click.element, { modifiers: click.modifiers }), expectedRequests: [ { n: 'Outbound Link: Click', p: { url: outboundUrl } } ] }) const [requestsOnOtherPages, requestsOnSamePage] = await Promise.all([ outboundMockForOtherPages.getRequestList().then((d) => d.length), outboundMockForSamePage.getRequestList().then((d) => d.length) ]) expect({ requestsOnOtherPages, requestsOnSamePage }).toEqual(expected) }) } test(`tracking delays navigation until the tracking request has finished`, async ({ page }, { testId }) => { const eventsApiMock = await mockManyRequests({ page, path: '**/api/event', awaitedRequestCount: 1, responseDelay: 1000 }) const outboundUrl = 'https://other.example.com/target' const outboundMock = await mockManyRequests({ page, path: outboundUrl, fulfill: { status: 200, contentType: 'text/html', body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: '', bodyContent: /* HTML */ `📥` }) await page.goto(url) const navigationPromise = page.waitForRequest(outboundUrl, { timeout: 2000 }) const trackingPromise = page.waitForResponse('**/api/event', { timeout: 2000 }) await page.click('a') const [[, trackingResponseTime], [, navigationTime]] = await resolveWithTimestamps([trackingPromise, navigationPromise]) await expect(page.getByText('other page')).toBeVisible() await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) expect(trackingResponseTime).toBeLessThanOrEqual(navigationTime) await expect(eventsApiMock.getRequestList()).resolves.toEqual([ expect.objectContaining({ n: 'Outbound Link: Click', p: { url: outboundUrl } }) ]) }) test('if the tracking requests delays navigation for more than 5s, it navigates anyway, without waiting for the request to resolve', async ({ page }, { testId }) => { test.setTimeout(20000) await mockManyRequests({ page, path: '**/api/event', awaitedRequestCount: 1, responseDelay: 6000 }) const outboundUrl = 'https://other.example.com/target' const outboundMock = await mockManyRequests({ page, path: outboundUrl, fulfill: { status: 200, contentType: 'text/html', body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: '', bodyContent: /* HTML */ `➡️` }) await page.goto(url) const navigationPromise = page.waitForRequest(outboundUrl, { timeout: 6000 }) await page.click('a') await navigationPromise await expect(page.getByText('other page')).toBeVisible() await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) }) test(`limitation: does not track outbound links within svg elements, but follows link properly`, async ({ page }, { testId }) => { const outboundUrl = 'https://other.example.com/target' const outboundMockOptions = { page, path: outboundUrl, fulfill: { status: 200, contentType: 'text/html', body: OTHER_PAGE_BODY }, awaitedRequestCount: 2, mockRequestTimeout: 2000 } const outboundMockForOtherPages = await mockManyRequests({ ...outboundMockOptions, scopeMockToPage: false }) const outboundMockForSamePage = await mockManyRequests({ ...outboundMockOptions, scopeMockToPage: true }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: '', bodyContent: /* HTML */ ` ` }) await page.goto(url) await expectPlausibleInAction(page, { action: () => page.click('a'), refutedRequests: [{ n: 'Outbound Link: Click' }] }) const [requestsOnOtherPages, requestsOnSamePage] = await Promise.all([ outboundMockForOtherPages.getRequestList().then((d) => d.length), outboundMockForSamePage.getRequestList().then((d) => d.length) ]) expect({ requestsOnOtherPages, requestsOnSamePage }).toEqual({ requestsOnOtherPages: 0, requestsOnSamePage: 1 }) }) }) const OTHER_PAGE_BODY = /* HTML */ ` other page other page `