From 94993547937ef900fca9acf705c348e1e3cec233 Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Sat, 19 Sep 2026 16:08:41 -0700 Subject: [PATCH] fix: Harden production analytics behavior Preserve allowed pageviews while filtering private routes. Verify deployed analytics surfaces independently and package the license with the public client. Co-authored-by: Codex --- docs/development/reference/api.md | 8 +- docs/development/reference/cli.md | 12 ++- docs/operations/how-to/verify-a-deployment.md | 5 +- packages/analytics/package.json | 3 +- packages/analytics/src/cli/commands.ts | 53 +++++++++-- packages/analytics/src/init.ts | 83 +++++++++++++++-- packages/analytics/src/standalone-runtime.ts | 1 + packages/analytics/tests/cli.test.ts | 66 +++++++++++-- .../tests/init-no-send-beacon.test.ts | 31 +++++++ packages/analytics/tests/init.test.ts | 93 ++++++++++++++++++- packages/analytics/tests/publishing.test.ts | 10 +- 11 files changed, 324 insertions(+), 41 deletions(-) create mode 100644 packages/analytics/tests/init-no-send-beacon.test.ts diff --git a/docs/development/reference/api.md b/docs/development/reference/api.md index 7ce99de..69d1055 100644 --- a/docs/development/reference/api.md +++ b/docs/development/reference/api.md @@ -24,10 +24,10 @@ const analytics = init({ while preserving allowlisted conversion events. `spa` defaults to `true`; `clicks` defaults to `true`. -Configuring `exclude` or `noPageviews` disables Cloudflare's automatic soft-navigation tracking. -Cloudflare Web Analytics does not expose a per-route SPA filter, so enabling it would send pageviews -for excluded paths. Full page loads on allowed paths remain measured, and custom events recheck -`exclude` against the current path before every send. +Cloudflare's automatic soft-navigation tracking remains enabled when `exclude` or `noPageviews` is +configured. The client filters Cloudflare Web Analytics requests against those rules, preserving +pageviews for allowed routes while suppressing pageviews for matching initial and soft-navigation +paths. Custom events recheck `exclude` against the current path before every send. The returned handle contains `enabled`, an optional gate `reason`, and `track(name, props)`. Calling `init` again returns the first handle and does not install another beacon or click listener. diff --git a/docs/development/reference/cli.md b/docs/development/reference/cli.md index 608720b..4915e94 100644 --- a/docs/development/reference/cli.md +++ b/docs/development/reference/cli.md @@ -11,11 +11,13 @@ pnpm exec lvbt-analytics verify https://labs.lasvegasfortransit.org \ --expect present ``` -The verifier opens the page in Chromium and observes requests to the Cloudflare beacon and LVBT -collector. This exercises the browser gate instead of looking for dormant strings in a bundle. -`--expect absent` proves a preview or archive sends neither kind of request. A navigation failure or -mismatched expectation exits nonzero. The consuming repository supplies `@playwright/test` and its -Chromium browser. +The verifier opens the page in Chromium and independently observes the Cloudflare script download, +an actual Cloudflare Web Analytics request, and any LVBT collector requests. It also verifies the +site declared by the deployed client and rejects collector requests attributed to another site. This +exercises the browser gate instead of looking for dormant strings in a bundle. `--expect absent` +proves a preview or archive sends none of those requests. A navigation failure or mismatched +expectation exits nonzero. The consuming repository supplies `@playwright/test` and its Chromium +browser. ## CSP checks diff --git a/docs/operations/how-to/verify-a-deployment.md b/docs/operations/how-to/verify-a-deployment.md index 1a3d8da..e38c5fc 100644 --- a/docs/operations/how-to/verify-a-deployment.md +++ b/docs/operations/how-to/verify-a-deployment.md @@ -1,7 +1,8 @@ # Verify a deployment -Verification checks the page, bundled client, security policy, collector, privacy gate, and one real -event. A successful page response alone does not prove analytics works. +Verification checks the page, bundled client, Cloudflare Web Analytics request, configured site, +security policy, collector, privacy gate, and one real event. A successful page response alone does +not prove analytics works. For production: diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 62c8f49..ce951a4 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -38,7 +38,8 @@ }, "files": [ "astro", - "dist" + "dist", + "LICENSE" ], "scripts": { "build": "tsdown && tsdown --config tsdown.client.config.ts", diff --git a/packages/analytics/src/cli/commands.ts b/packages/analytics/src/cli/commands.ts index ceb7d2e..8f49f23 100644 --- a/packages/analytics/src/cli/commands.ts +++ b/packages/analytics/src/cli/commands.ts @@ -40,12 +40,14 @@ export async function writeClient(path: string) { } interface VerifyRoute { + continue(): Promise; fulfill(options: { body?: string; contentType?: string; status: number }): Promise; - request(): { url(): string }; + request(): { postData(): string | null; url(): string }; } export interface VerifyPage { - goto(url: string, options?: { waitUntil?: 'domcontentloaded' }): Promise; + getAttribute(selector: string, name: string): Promise; + goto(url: string, options?: { waitUntil?: 'load' }): Promise; route(pattern: string, handler: (route: VerifyRoute) => Promise): Promise; waitForTimeout(milliseconds: number): Promise; } @@ -79,26 +81,61 @@ export async function verifyDeployment( const pageUrl = new URL(url); if (expect === 'present' && pageUrl.hostname !== site && pageUrl.hostname !== `www.${site}`) throw new Error(`${pageUrl.hostname} does not match expected site ${site}.`); + const scriptRequests: string[] = []; const beaconRequests: string[] = []; const eventRequests: string[] = []; + const eventSites: string[] = []; await withPage(async (page) => { await page.route('https://static.cloudflareinsights.com/**', async (route) => { + scriptRequests.push(route.request().url()); + return route.continue(); + }); + await page.route('https://cloudflareinsights.com/**', async (route) => { beaconRequests.push(route.request().url()); - return route.fulfill({ body: 'export {};', contentType: 'text/javascript', status: 200 }); + return route.fulfill({ status: 204 }); }); await page.route('https://events.lasvegasfortransit.org/**', async (route) => { - eventRequests.push(route.request().url()); + const request = route.request(); + eventRequests.push(request.url()); + const body = request.postData(); + if (body) { + try { + const payload = JSON.parse(body) as { site?: unknown }; + if (typeof payload.site === 'string') eventSites.push(payload.site); + } catch { + // The collector owns full payload validation; deployment verification only checks site. + } + } return route.fulfill({ status: 204 }); }); - await page.goto(pageUrl.href, { waitUntil: 'domcontentloaded' }); + await page.goto(pageUrl.href, { waitUntil: 'load' }); await page.waitForTimeout(500); + if (expect === 'present') { + const configuredSite = await page.getAttribute('[data-lvbt-analytics]', 'data-lvbt-site'); + if (configuredSite !== site) + throw new Error( + `Expected analytics site ${site} at ${pageUrl.href}; the deployed client declared ${configuredSite ?? 'no site'}.`, + ); + } }); + const wrongSite = eventSites.find((eventSite) => eventSite !== site); + if (wrongSite) + throw new Error( + `Expected collector requests for ${site} at ${pageUrl.href}; observed a collector request for ${wrongSite}.`, + ); + if (expect === 'present' && scriptRequests.length !== 1) + throw new Error( + `Expected analytics to be present for ${site} at ${pageUrl.href}; the browser made ${scriptRequests.length} Cloudflare script requests.`, + ); if (expect === 'present' && beaconRequests.length !== 1) throw new Error( - `Expected analytics to be present for ${site} at ${pageUrl.href}; the browser made ${beaconRequests.length} Cloudflare beacon requests.`, + `Expected analytics to be present for ${site} at ${pageUrl.href}; the browser made ${beaconRequests.length} Cloudflare Web Analytics beacon requests.`, ); - if (expect === 'absent' && beaconRequests.length + eventRequests.length !== 0) + if ( + expect === 'absent' && + scriptRequests.length + beaconRequests.length + eventRequests.length !== 0 + ) throw new Error( - `Expected analytics to be absent for ${site} at ${pageUrl.href}; the browser made ${beaconRequests.length} Cloudflare beacon requests and ${eventRequests.length} collector requests.`, + `Expected analytics to be absent for ${site} at ${pageUrl.href}; the browser made ${scriptRequests.length} Cloudflare script requests, ${beaconRequests.length} Cloudflare Web Analytics beacon requests, and ${eventRequests.length} collector requests.`, ); } diff --git a/packages/analytics/src/init.ts b/packages/analytics/src/init.ts index e4ba67c..7adbcc2 100644 --- a/packages/analytics/src/init.ts +++ b/packages/analytics/src/init.ts @@ -24,8 +24,9 @@ interface AnalyticsWindow extends Window { let current: AnalyticsHandle | undefined; let removeClickListener: (() => void) | undefined; +let removePageviewFilter: (() => void) | undefined; -function installBeacon(token: string, spa: boolean) { +function installBeacon(site: string, token: string, spa: boolean) { if (document.querySelector('[data-lvbt-analytics]')) return; const script = document.createElement('script'); script.type = 'module'; @@ -33,9 +34,72 @@ function installBeacon(token: string, spa: boolean) { script.src = 'https://static.cloudflareinsights.com/beacon.min.js'; script.dataset.cfBeacon = JSON.stringify({ token, spa }); script.dataset.lvbtAnalytics = ''; + script.dataset.lvbtSite = site; document.head.append(script); } +function isCloudflareBeacon(url: string | URL) { + const endpoint = new URL(String(url), location.href); + return endpoint.hostname === 'cloudflareinsights.com' && endpoint.pathname === '/cdn-cgi/rum'; +} + +function payloadPath(body: Document | XMLHttpRequestBodyInit | null | undefined) { + if (typeof body !== 'string') return location.pathname; + try { + const payload = JSON.parse(body) as { location?: unknown }; + return typeof payload.location === 'string' + ? new URL(payload.location, location.href).pathname + : location.pathname; + } catch { + return location.pathname; + } +} + +function installPageviewFilter(patterns: RegExp[]) { + const blocks = (pathname: string) => patterns.some((pattern) => matches(pattern, pathname)); + const destinations = new WeakMap(); + const originalOpen = Reflect.get(XMLHttpRequest.prototype, 'open'); + const originalSend = Reflect.get(XMLHttpRequest.prototype, 'send'); + const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const originalSendBeacon = + typeof navigator.sendBeacon === 'function' ? navigator.sendBeacon.bind(navigator) : undefined; + + XMLHttpRequest.prototype.open = function ( + this: XMLHttpRequest, + ...args: [ + method: string, + url: string | URL, + async?: boolean, + username?: string | null, + password?: string | null, + ] + ) { + const url = args[1]; + destinations.set(this, url); + Reflect.apply(originalOpen, this, args); + }; + XMLHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) { + const destination = destinations.get(this); + if (destination && isCloudflareBeacon(destination) && blocks(payloadPath(body))) return; + originalSend.call(this, body); + }; + if (typeof originalSendBeacon === 'function') + Object.defineProperty(navigator, 'sendBeacon', { + configurable: true, + value(url: string | URL, data?: BodyInit | null) { + if (isCloudflareBeacon(url) && blocks(location.pathname)) return true; + return originalSendBeacon.call(navigator, url, data); + }, + }); + + return () => { + XMLHttpRequest.prototype.open = originalOpen; + XMLHttpRequest.prototype.send = originalSend; + if (sendBeaconDescriptor) Object.defineProperty(navigator, 'sendBeacon', sendBeaconDescriptor); + else Reflect.deleteProperty(navigator, 'sendBeacon'); + }; +} + function eventSender(site: string, collector: string, exclude: RegExp[] = []) { const sent = new Set(); return (name: N, props: PropsFor) => { @@ -74,13 +138,14 @@ export function init(options: InitOptions): AnalyticsHandle { } const track = eventSender(options.site, options.collector ?? DEFAULT_COLLECTOR, options.exclude); current = { enabled: true, track }; - if (!options.noPageviews?.some((pattern) => matches(pattern, location.pathname))) - installBeacon( - options.token?.trim() ?? '', - options.spa === false || options.exclude?.length || options.noPageviews?.length - ? false - : true, - ); + const pageviewRules = [...(options.exclude ?? []), ...(options.noPageviews ?? [])]; + if (options.spa !== false && pageviewRules.length > 0) + removePageviewFilter = installPageviewFilter(pageviewRules); + const initialPageviewBlocked = pageviewRules.some((pattern) => + matches(pattern, location.pathname), + ); + if (options.spa !== false || !initialPageviewBlocked) + installBeacon(options.site, options.token?.trim() ?? '', options.spa !== false); if (options.clicks !== false) removeClickListener = installClickTracking(track); (window as AnalyticsWindow).lvbt = { track }; return current; @@ -88,7 +153,9 @@ export function init(options: InitOptions): AnalyticsHandle { export function resetForTesting() { removeClickListener?.(); + removePageviewFilter?.(); removeClickListener = undefined; + removePageviewFilter = undefined; current = undefined; delete (window as AnalyticsWindow).lvbt; } diff --git a/packages/analytics/src/standalone-runtime.ts b/packages/analytics/src/standalone-runtime.ts index d8f5984..a568488 100644 --- a/packages/analytics/src/standalone-runtime.ts +++ b/packages/analytics/src/standalone-runtime.ts @@ -86,6 +86,7 @@ export function startStandalone( beacon.src = 'https://static.cloudflareinsights.com/beacon.min.js'; beacon.dataset.cfBeacon = JSON.stringify({ token, spa: script.dataset.lvbtSpa !== 'false' }); beacon.dataset.lvbtAnalytics = ''; + beacon.dataset.lvbtSite = site; document.head.append(beacon); } if (script.dataset.lvbtClicks !== 'false') diff --git a/packages/analytics/tests/cli.test.ts b/packages/analytics/tests/cli.test.ts index 2f889e0..b749724 100644 --- a/packages/analytics/tests/cli.test.ts +++ b/packages/analytics/tests/cli.test.ts @@ -33,22 +33,25 @@ test('keeps the committed event reference synchronized with the runtime allowlis expect(normalizeTable(committed ?? '')).toBe(normalizeTable(eventsMarkdown())); }); -function pageThatRequests(...requestedUrls: string[]): VerifyPage { +function pageThatRequests(...requests: Array): VerifyPage { const routes: Array<{ pattern: string; handler: (route: { + continue(): Promise; fulfill(options: { body?: string; contentType?: string; status: number }): Promise; - request(): { url(): string }; + request(): { postData(): string | null; url(): string }; }) => Promise; }> = []; const goto: VerifyPage['goto'] = async () => { - for (const url of requestedUrls) { + for (const request of requests) { + const { body, url } = typeof request === 'string' ? { body: null, url: request } : request; const route = routes.find(({ pattern }) => pattern.endsWith('/**') ? url.startsWith(pattern.slice(0, -2)) : url === pattern, ); await route?.handler({ + continue: () => Promise.resolve(), fulfill: () => Promise.resolve(), - request: () => ({ url: () => url }), + request: () => ({ postData: () => body, url: () => url }), }); } }; @@ -57,7 +60,10 @@ function pageThatRequests(...requestedUrls: string[]): VerifyPage { return Promise.resolve(); }; const waitForTimeout: VerifyPage['waitForTimeout'] = () => Promise.resolve(); + const getAttribute: VerifyPage['getAttribute'] = (_selector, name) => + Promise.resolve(name === 'data-lvbt-site' ? 'labs.lasvegasfortransit.org' : null); return { + getAttribute: vi.fn(getAttribute), goto: vi.fn(goto), route: vi.fn(route), waitForTimeout: vi.fn(waitForTimeout), @@ -66,7 +72,12 @@ function pageThatRequests(...requestedUrls: string[]): VerifyPage { test('verifies analytics from browser-observed requests instead of bundle text', async () => { const withPage = (run: (page: VerifyPage) => Promise) => - run(pageThatRequests('https://static.cloudflareinsights.com/beacon.min.js')); + run( + pageThatRequests( + 'https://static.cloudflareinsights.com/beacon.min.js', + 'https://cloudflareinsights.com/cdn-cgi/rum', + ), + ); await expect( verifyDeployment( @@ -88,7 +99,21 @@ test('fails when the built client exists but the runtime gate sends no request', 'present', withPage, ), - ).rejects.toThrow('browser made 0 Cloudflare beacon requests'); + ).rejects.toThrow('browser made 0 Cloudflare script requests'); +}); + +test('does not accept the script download as proof that Web Analytics sent a beacon', async () => { + const withPage = (run: (page: VerifyPage) => Promise) => + run(pageThatRequests('https://static.cloudflareinsights.com/beacon.min.js')); + + await expect( + verifyDeployment( + 'https://labs.lasvegasfortransit.org', + 'labs.lasvegasfortransit.org', + 'present', + withPage, + ), + ).rejects.toThrow('browser made 0 Cloudflare Web Analytics beacon requests'); }); test('does not accept a collector event as proof that Web Analytics loaded', async () => { @@ -102,7 +127,34 @@ test('does not accept a collector event as proof that Web Analytics loaded', asy 'present', withPage, ), - ).rejects.toThrow('browser made 0 Cloudflare beacon requests'); + ).rejects.toThrow('browser made 0 Cloudflare script requests'); +}); + +test('rejects collector events attributed to a different production site', async () => { + const withPage = (run: (page: VerifyPage) => Promise) => + run( + pageThatRequests( + 'https://static.cloudflareinsights.com/beacon.min.js', + 'https://cloudflareinsights.com/cdn-cgi/rum', + { + body: JSON.stringify({ + name: 'join_click', + props: { placement: 'header' }, + site: 'map.lasvegasfortransit.org', + }), + url: 'https://events.lasvegasfortransit.org/e', + }, + ), + ); + + await expect( + verifyDeployment( + 'https://labs.lasvegasfortransit.org', + 'labs.lasvegasfortransit.org', + 'present', + withPage, + ), + ).rejects.toThrow('collector request for map.lasvegasfortransit.org'); }); test('requires the expected site to match a production deployment hostname', async () => { diff --git a/packages/analytics/tests/init-no-send-beacon.test.ts b/packages/analytics/tests/init-no-send-beacon.test.ts new file mode 100644 index 0000000..f18cee7 --- /dev/null +++ b/packages/analytics/tests/init-no-send-beacon.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { init, resetForTesting } from '../src/init.js'; + +const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + +afterEach(() => { + resetForTesting(); + vi.restoreAllMocks(); + if (sendBeaconDescriptor) Object.defineProperty(navigator, 'sendBeacon', sendBeaconDescriptor); + else Reflect.deleteProperty(navigator, 'sendBeacon'); +}); + +test('uses the collector fetch fallback when sendBeacon is unavailable', () => { + Object.defineProperty(navigator, 'sendBeacon', { configurable: true, value: undefined }); + Object.defineProperty(navigator, 'globalPrivacyControl', { configurable: true, value: false }); + Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: '0' }); + vi.spyOn(document.head, 'append').mockImplementation(() => undefined); + const fetch = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); + + const analytics = init({ + site: 'test.example', + token: 'a'.repeat(32), + noPageviews: [/^\/private/], + }); + analytics.track('join_click', { placement: 'header' }); + + expect(fetch).toHaveBeenCalledWith( + 'https://events.lasvegasfortransit.org/e', + expect.objectContaining({ keepalive: true, method: 'POST' }), + ); +}); diff --git a/packages/analytics/tests/init.test.ts b/packages/analytics/tests/init.test.ts index 12c47ba..7f01e47 100644 --- a/packages/analytics/tests/init.test.ts +++ b/packages/analytics/tests/init.test.ts @@ -2,13 +2,13 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { init, resetForTesting } from '../src/init.js'; beforeEach(() => { + resetForTesting(); vi.restoreAllMocks(); Object.defineProperty(navigator, 'globalPrivacyControl', { configurable: true, value: false }); Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: '0' }); document.head.replaceChildren(); document.body.replaceChildren(); history.replaceState({}, '', '/'); - resetForTesting(); }); test('initializes once and sends allowlisted events without browser storage', () => { @@ -99,6 +99,8 @@ test('falls back to keepalive fetch when sendBeacon declines the event', () => { test('suppresses pageviews without disabling allowlisted events', () => { const append = vi.spyOn(document.head, 'append').mockImplementation(() => undefined); + vi.spyOn(XMLHttpRequest.prototype, 'open').mockImplementation(() => undefined); + const send = vi.spyOn(XMLHttpRequest.prototype, 'send').mockImplementation(() => undefined); const sendBeacon = vi.fn(() => true); Object.defineProperty(navigator, 'sendBeacon', { configurable: true, value: sendBeacon }); history.replaceState({}, '', '/private'); @@ -108,13 +110,18 @@ test('suppresses pageviews without disabling allowlisted events', () => { noPageviews: [/^\/private/], }); + send.mockClear(); + const pageview = new XMLHttpRequest(); + pageview.open('POST', 'https://cloudflareinsights.com/cdn-cgi/rum'); + pageview.send(JSON.stringify({ location: 'https://test.example/private' })); analytics.track('join_click', { placement: 'header' }); - expect(append).not.toHaveBeenCalled(); + expect(append).toHaveBeenCalledOnce(); + expect(send).not.toHaveBeenCalled(); expect(sendBeacon).toHaveBeenCalledOnce(); }); -test('disables automatic SPA pageviews when path rules are configured', () => { +test('keeps automatic SPA pageviews enabled when path rules are configured', () => { const append = vi.spyOn(document.head, 'append').mockImplementation(() => undefined); init({ site: 'test.example', @@ -123,7 +130,85 @@ test('disables automatic SPA pageviews when path rules are configured', () => { }); const script = append.mock.calls[0]?.[0] as HTMLScriptElement; - expect(JSON.parse(script.dataset.cfBeacon ?? '{}')).toMatchObject({ spa: false }); + expect(JSON.parse(script.dataset.cfBeacon ?? '{}')).toMatchObject({ spa: true }); +}); + +test('filters Cloudflare pageviews across initial and SPA route changes', () => { + vi.spyOn(document.head, 'append').mockImplementation(() => undefined); + const open = vi.spyOn(XMLHttpRequest.prototype, 'open').mockImplementation(() => undefined); + const send = vi.spyOn(XMLHttpRequest.prototype, 'send').mockImplementation(() => undefined); + const token = 'a'.repeat(32); + + init({ + site: 'test.example', + token, + exclude: [/^\/excluded/], + noPageviews: [/^\/private/], + }); + open.mockClear(); + send.mockClear(); + + const sendPageview = (pathname: string) => { + const body = JSON.stringify({ location: `https://test.example${pathname}`, siteToken: token }); + const request = new XMLHttpRequest(); + request.open('POST', 'https://cloudflareinsights.com/cdn-cgi/rum'); + request.send(body); + return body; + }; + + const initialPageview = sendPageview('/'); + history.pushState({}, '', '/private/share'); + sendPageview('/private/share'); + history.replaceState({}, '', '/allowed'); + const allowedPageview = sendPageview('/allowed'); + history.pushState({}, '', '/excluded/archive'); + window.dispatchEvent(new PopStateEvent('popstate')); + sendPageview('/excluded/archive'); + + expect(send).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenNthCalledWith(1, initialPageview); + expect(send).toHaveBeenNthCalledWith(2, allowedPageview); +}); + +test('recovers pageviews after starting on a no-pageviews route', () => { + history.replaceState({}, '', '/private/start'); + const append = vi.spyOn(document.head, 'append').mockImplementation(() => undefined); + const open = vi.spyOn(XMLHttpRequest.prototype, 'open').mockImplementation(() => undefined); + const send = vi.spyOn(XMLHttpRequest.prototype, 'send').mockImplementation(() => undefined); + + init({ + site: 'test.example', + token: 'a'.repeat(32), + noPageviews: [/^\/private/], + }); + const script = append.mock.calls[0]?.[0] as HTMLScriptElement; + expect(JSON.parse(script.dataset.cfBeacon ?? '{}')).toMatchObject({ spa: true }); + open.mockClear(); + send.mockClear(); + + const request = new XMLHttpRequest(); + request.open('POST', 'https://cloudflareinsights.com/cdn-cgi/rum'); + request.send(JSON.stringify({ location: 'https://test.example/private/start' })); + history.pushState({}, '', '/allowed'); + request.open('POST', 'https://cloudflareinsights.com/cdn-cgi/rum'); + request.send(JSON.stringify({ location: 'https://test.example/allowed' })); + + expect(send).toHaveBeenCalledOnce(); +}); + +test('does not load the beacon on an initially excluded route when SPA tracking is off', () => { + history.replaceState({}, '', '/private/start'); + const append = vi.spyOn(document.head, 'append').mockImplementation(() => undefined); + + const analytics = init({ + site: 'test.example', + token: 'a'.repeat(32), + exclude: [/^\/private/], + spa: false, + }); + + expect(analytics).toMatchObject({ enabled: false, reason: 'excluded-path' }); + expect(append).not.toHaveBeenCalled(); }); test('stops custom events after navigating to an excluded path', () => { diff --git a/packages/analytics/tests/publishing.test.ts b/packages/analytics/tests/publishing.test.ts index edea17c..39cda8d 100644 --- a/packages/analytics/tests/publishing.test.ts +++ b/packages/analytics/tests/publishing.test.ts @@ -1,12 +1,13 @@ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { expect, test } from 'vitest'; -import { VERSION } from '../src/index.js'; +import { EVENTS, VERSION } from '../src/index.js'; test('publishes from the canonical repository through npm trusted publishing', async () => { const packageJson = JSON.parse(await readFile(resolve('package.json'), 'utf8')) as { bin?: Record; repository?: { directory?: string; type?: string; url?: string }; + files?: string[]; version?: string; }; const workflow = await readFile(resolve('../../.github/workflows/publish.yml'), 'utf8'); @@ -23,8 +24,13 @@ test('publishes from the canonical repository through npm trusted publishing', a expect(packageJson.version).toBe('0.1.0'); expect(VERSION).toBe(packageJson.version); expect(packageJson.bin).toEqual({ 'lvbt-analytics': 'dist/cli/index.mjs' }); + expect(packageJson.files).toContain('LICENSE'); await expect(readFile(resolve('LICENSE'), 'utf8')).resolves.toBe( await readFile(resolve('../../LICENSE'), 'utf8'), ); - expect(weekly).toContain('https://fund.lasvegasfortransit.org'); + const productionSites = [...new Set(Object.values(EVENTS).flatMap((event) => event.sites))]; + for (const site of productionSites) { + expect(weekly).toContain(`verify https://${site}`); + expect(weekly).toContain(`--site ${site} --expect present`); + } });