diff --git a/README.md b/README.md index 9596b63..d8e57ef 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,42 @@ Peer dependencies: `react ^19`, `react-dom ^19`, `next ^16`, `framer-motion ^12` Each component is exported on its own subpath — `@isimplifyme/ui/bento`, `@isimplifyme/ui/seo`, `@isimplifyme/ui/concierge`, and so on. See `package.json` `exports` for the full map. +## Click beacon + +`OutboundClicks` counts the clicks a server access log cannot see: phone taps, email links, links off the site, and anything the page labels itself. A click sends one `GET /out.txt?e=&p=&…&t=` for a tiny static file, and the CDN's own access log is the counter — no cookie, no identifier, no third party, no server code, and the visitor's link is never rewritten. + +```tsx +// app/layout.tsx +import { OutboundClicks } from '@isimplifyme/ui/outbound-clicks' +; + +// anywhere +(312) 500-2674 + +``` + +The consuming site must ship the file the beacon asks for — `public/out.txt`, any short body — or every click is a 404 (still logged, but it reads as an error). + +| `e` | fires on | also carries | +| --- | --- | --- | +| `tel` | `a[href^="tel:"]` | `n` label | +| `mailto` | `a[href^="mailto:"]` | `n` label | +| `out` | an `http(s)` link to another host | `h` hostname, `n` label | +| `amazon` / `merchant` | `a[data-amazon]` / `a[data-merchant]` | `n` product, `i` position, `m` merchant | +| `track` | any `[data-track]` that is not inside a link | `n` label | + +What may leave the page is an event name, the current path, a hostname, and labels written in the site's own source — never a phone number, an email address, a link's path or query, or anything a visitor typed. `BEACON_KEYS` in `src/outbound.ts` is the whole vocabulary, and `src/outbound.test.ts` fails if a new key appears without being named there. + +Internal links are deliberately silent: a click that ends in a request to our own server is already in the same log as a page view. Pass `ignoreHosts` for hostnames the site considers its own, and `enabled={false}` (or mount inside a consent gate) where a site asks before it measures. + +## Tests + +```bash +npm install +npm test # vitest +npm run typecheck +``` + ## Security Report security issues to [ai@isimplifyme.com](mailto:ai@isimplifyme.com). diff --git a/package.json b/package.json index f26823f..ca3e3d0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@isimplifyme/ui", - "version": "1.2.1", - "description": "React/Next.js UI library for iSimplifyMe properties — design system, article layouts, SEO helpers, bot middleware, and the iSM Concierge widget.", + "version": "1.3.0", + "description": "React/Next.js UI library for iSimplifyMe properties \u2014 design system, article layouts, SEO helpers, bot middleware, and the iSM Concierge widget.", "homepage": "https://isimplifyme.com", "type": "module", "exports": { @@ -23,10 +23,18 @@ "./image-lightbox": "./src/image-lightbox.tsx", "./related-articles": "./src/related-articles.tsx", "./bot-middleware": "./src/bot-middleware.ts", - "./concierge": "./src/concierge.tsx" + "./concierge": "./src/concierge.tsx", + "./outbound": "./src/outbound.ts", + "./outbound-clicks": "./src/outbound-clicks.tsx" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "files": [ - "src" + "src", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" ], "peerDependencies": { "react": "^19", @@ -35,12 +43,17 @@ "next": "^16.2.5" }, "devDependencies": { - "react": "^19", - "react-dom": "^19", + "@types/node": "^24", + "@types/react": "^19", + "@types/react-dom": "^19", "framer-motion": "^12", + "jsdom": "^27", "next": "^16", + "react": "^19", + "react-dom": "^19", "typescript": "^6", - "@types/react": "^19" + "vitest": "^4", + "web-vitals": "^5" }, "repository": { "type": "git", diff --git a/src/index.ts b/src/index.ts index 03830ea..8734ed5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,3 +33,7 @@ export { Analytics, WebVitals, trackEvent, analytics } from './analytics' export { ArticleNav } from './article-nav' export { SearchFilter } from './search-filter' export { StagingBanner } from './staging-banner' +export { OutboundClicks } from './outbound-clicks' +export type { OutboundClicksProps } from './outbound-clicks' +export { OUTBOUND_BEACON_PATH, BEACON_KEYS, beaconUrl, describeClick } from './outbound' +export type { OutboundClick, OutboundKind, ClickContext } from './outbound' diff --git a/src/outbound-clicks.tsx b/src/outbound-clicks.tsx new file mode 100644 index 0000000..07f3e4c --- /dev/null +++ b/src/outbound-clicks.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useEffect } from 'react' +import { OUTBOUND_BEACON_PATH, beaconUrl, describeClick } from './outbound' + +export interface OutboundClicksProps { + /** Path of the static file to request. Must exist — ship `public/out.txt`. */ + beaconPath?: string + /** + * Hostnames to treat as internal alongside the page's own: a staging domain, + * a sibling property, a booking host the site considers part of itself. + */ + ignoreHosts?: readonly string[] + /** + * Set false to send nothing. A site that asks before analytics mounts this + * inside its consent gate, or passes the gate's answer here. + */ + enabled?: boolean +} + +/** + * Counts the clicks a server log cannot see — phone taps, email links, links + * off the site, and anything the page labels with `data-track` (see + * ./outbound.ts for what may leave the page). + * + * One delegated listener on the document, so every page stays a server + * component and nothing has to be wrapped. Renders null. + * + * // in the root layout + * … + * + * + * Put `data-track` on the thing that gets clicked, not on a section around it: + * the label is read from the nearest labelled ancestor, so a wrapper would + * claim every click inside it. + */ +export function OutboundClicks({ beaconPath = OUTBOUND_BEACON_PATH, ignoreHosts, enabled = true }: OutboundClicksProps) { + // A caller's inline array is a new array every render; its contents are not. + const ignoreKey = (ignoreHosts ?? []).join(',') + useEffect(() => { + if (!enabled) return + const ignored = ignoreKey ? ignoreKey.split(',') : [] + const onClick = (event: MouseEvent) => { + // Left click, or middle click (auxclick) opening a background tab. + if (event.type === 'auxclick' && event.button !== 1) return + const target = event.target as Element | null + if (!target?.closest) return + const click = describeClick(target, { + pathname: window.location.pathname, + hostname: window.location.hostname, + ignoreHosts: ignored, + }) + if (!click) return + // keepalive lets the request finish if the tab navigates; credentials + // are omitted so not even our own cookies ride along. + fetch(beaconUrl(click, Date.now(), beaconPath), { + method: 'GET', + keepalive: true, + cache: 'no-store', + credentials: 'omit', + }).catch(() => {}) + } + document.addEventListener('click', onClick, true) + document.addEventListener('auxclick', onClick, true) + return () => { + document.removeEventListener('click', onClick, true) + document.removeEventListener('auxclick', onClick, true) + } + }, [beaconPath, ignoreKey, enabled]) + return null +} + +export default OutboundClicks diff --git a/src/outbound.test.ts b/src/outbound.test.ts new file mode 100644 index 0000000..ea4a666 --- /dev/null +++ b/src/outbound.test.ts @@ -0,0 +1,314 @@ +/** + * jsdom prints "Not implemented: navigation to another Document" while these + * run. That is the point: the clicks are real and the hrefs are untouched, so + * jsdom tries to follow them. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { BEACON_KEYS, OUTBOUND_BEACON_PATH, beaconUrl, describeClick } from './outbound' +import { OutboundClicks } from './outbound-clicks' + +const SITE = { pathname: '/contact', hostname: 'example.com' } + +/** Builds the markup and returns the element the visitor actually clicks. */ +function clicked(html: string, selector = '[data-hit]'): Element { + document.body.innerHTML = html + const element = document.querySelector(selector) + if (!element) throw new Error(`no ${selector} in ${html}`) + return element +} + +const read = (...parts: string[]) => fs.readFileSync(path.join(process.cwd(), ...parts), 'utf-8') + +describe('describeClick — phone and email', () => { + it('counts a phone tap, and carries the label instead of the number', () => { + const click = describeClick(clicked('Call us'), SITE) + expect(click).toEqual({ kind: 'tel', page: '/contact', label: 'header' }) + expect(beaconUrl(click!, 1)).not.toContain('3125002674') + }) + + it('counts an unlabelled phone tap with no label at all', () => { + expect(describeClick(clicked('Call'), SITE)).toEqual({ + kind: 'tel', + page: '/contact', + label: undefined, + }) + }) + + it('counts an email click without the address', () => { + const click = describeClick(clicked('Email'), SITE) + expect(click).toEqual({ kind: 'mailto', page: '/contact', label: 'footer' }) + expect(beaconUrl(click!, 1)).not.toContain('hello') + }) + + it('reads the label off the clicked child when the anchor is unlabelled', () => { + const click = describeClick(clicked('Call'), SITE) + expect(click?.label).toBe('hero') + }) +}) + +describe('describeClick — links off the site', () => { + it('keeps the hostname and nothing else from the URL', () => { + const click = describeClick( + clicked('Apply'), + SITE, + ) + expect(click).toEqual({ kind: 'out', page: '/contact', host: 'apply.rate.com', label: 'apply' }) + const url = beaconUrl(click!, 1) + expect(url).not.toContain('loans') + expect(url).not.toContain('secret') + expect(url).not.toContain('ssn') + }) + + it('lower-cases the hostname', () => { + expect(describeClick(clicked('Directions'), SITE)?.host).toBe('maps.google.com') + }) + + it('ignores our own host, with or without www', () => { + expect(describeClick(clicked('About'), SITE)).toBeNull() + expect(describeClick(clicked('About'), SITE)).toBeNull() + }) + + it('ignores a relative link — a request to us is already in the log', () => { + expect(describeClick(clicked('Services'), SITE)).toBeNull() + }) + + it('ignores the hosts the site calls its own', () => { + const context = { ...SITE, ignoreHosts: ['staging.example.com', 'Example.net'] } + expect(describeClick(clicked('x'), context)).toBeNull() + expect(describeClick(clicked('x'), context)).toBeNull() + expect(describeClick(clicked('x'), context)?.host).toBe('other.example.org') + }) + + it('ignores schemes it cannot read a hostname from', () => { + expect(describeClick(clicked('x'), SITE)).toBeNull() + expect(describeClick(clicked('Text'), SITE)).toBeNull() + }) +}) + +describe('describeClick — product links keep adellion’s shape', () => { + it('reads an Amazon card link', () => { + expect( + describeClick( + clicked('Buy'), + { pathname: '/capture-cards', hostname: 'adellion.com' }, + ), + ).toEqual({ kind: 'amazon', page: '/capture-cards', label: 'Elgato HD60 X', position: 2 }) + }) + + it('reads a merchant card link and keeps the merchant name', () => { + expect( + describeClick( + clicked('Buy'), + { pathname: '/best-gaming-chairs', hostname: 'adellion.com' }, + ), + ).toEqual({ kind: 'merchant', page: '/best-gaming-chairs', label: 'Titan Evo', position: 1, merchant: 'Secretlab' }) + }) + + it('emits exactly the query adellion’s reader already parses', () => { + const click = describeClick( + clicked('Buy'), + { pathname: '/capture-cards', hostname: 'adellion.com' }, + ) + expect(Object.fromEntries(new URLSearchParams(beaconUrl(click!, 1700000000000).split('?')[1]))).toEqual({ + e: 'amazon', + p: '/capture-cards', + n: 'Elgato HD60 X', + i: '2', + t: '1700000000000', + }) + }) + + it('survives a missing position', () => { + const click = describeClick(clicked('Buy'), SITE) + expect(click?.position).toBe(0) + }) +}) + +describe('describeClick — labelled things that are not links', () => { + it('counts a button the site asked to count', () => { + expect(describeClick(clicked(''), SITE)).toEqual({ + kind: 'track', + page: '/contact', + label: 'book-online', + }) + }) + + it('stays silent on an unlabelled click anywhere else', () => { + expect(describeClick(clicked('
just words
'), SITE)).toBeNull() + }) + + it('an element inside a link belongs to the link, not to itself', () => { + const click = describeClick(clicked(''), SITE) + expect(click?.kind).toBe('tel') + }) +}) + +describe('describeClick — clipping and defaults', () => { + it('clips long values and defaults an empty path', () => { + const click = describeClick(clicked(``), { + pathname: 'p'.repeat(300), + hostname: 'example.com', + }) + expect(click?.label).toHaveLength(60) + expect(click?.page).toHaveLength(120) + expect(describeClick(clicked(''), { pathname: '', hostname: 'example.com' })?.page).toBe('/') + }) + + it('treats a blank label as no label', () => { + expect(describeClick(clicked(''), SITE)).toBeNull() + expect(describeClick(clicked('Call'), SITE)?.label).toBeUndefined() + }) +}) + +describe('beaconUrl', () => { + it('is a same-origin GET for the static beacon file', () => { + expect(beaconUrl({ kind: 'tel', page: '/' }, 1).startsWith(`${OUTBOUND_BEACON_PATH}?`)).toBe(true) + expect(beaconUrl({ kind: 'tel', page: '/' }, 1, '/beacon.txt').startsWith('/beacon.txt?')).toBe(true) + }) + + it('is unique per click, so no cache answers it', () => { + const click = { kind: 'out' as const, page: '/x', host: 'y.com' } + expect(beaconUrl(click, 1)).not.toBe(beaconUrl(click, 2)) + }) + + it('omits the keys a click has nothing to say about', () => { + expect(beaconUrl({ kind: 'tel', page: '/contact' }, 1)).toBe('/out.txt?e=tel&p=%2Fcontact&t=1') + }) + + /** + * The privacy guarantee is the size of this vocabulary. A new key here means + * something new about the visitor can leave the page, so it has to be named + * in BEACON_KEYS and in this literal before any test passes again. + */ + it('never emits a key outside the allow-list', () => { + expect([...BEACON_KEYS]).toEqual(['e', 'p', 'n', 'h', 'i', 'm', 't']) + const kinds = ['tel', 'mailto', 'out', 'amazon', 'merchant', 'track'] as const + for (const kind of kinds) { + const url = beaconUrl({ kind, page: '/p', label: 'l', host: 'h.com', position: 1, merchant: 'm' }, 1) + for (const key of new URLSearchParams(url.split('?')[1]).keys()) { + expect(BEACON_KEYS).toContain(key) + } + } + }) +}) + +describe(' on a real page', () => { + let fetchMock: ReturnType + let container: HTMLElement + let root: ReturnType + + const mount = (props: Record = {}) => { + act(() => root.render(createElement(OutboundClicks, props))) + } + + beforeEach(() => { + ;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + fetchMock = vi.fn(() => Promise.resolve({ ok: true } as Response)) + vi.stubGlobal('fetch', fetchMock) + document.body.innerHTML = '' + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + vi.unstubAllGlobals() + }) + + it('sends exactly one cookieless beacon per tap, and leaves the link alone', () => { + mount() + const link = document.createElement('a') + link.href = 'tel:+13125002674' + link.dataset.track = 'header' + document.body.appendChild(link) + + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + link.dispatchEvent(event) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toMatch(/^\/out\.txt\?e=tel&p=[^&]*&n=header&t=\d+$/) + expect(init).toMatchObject({ method: 'GET', credentials: 'omit', cache: 'no-store', keepalive: true }) + expect(init.headers).toBeUndefined() + // The visitor's own click still does what it was going to do. + expect(event.defaultPrevented).toBe(false) + expect(link.getAttribute('href')).toBe('tel:+13125002674') + }) + + it('sends nothing for a click with nothing to count', () => { + mount() + document.body.appendChild(document.createElement('p')).dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('counts a middle click that opens a background tab, but not a right click', () => { + mount() + const link = document.createElement('a') + link.href = 'https://apply.rate.com/x' + document.body.appendChild(link) + link.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 2 })) + expect(fetchMock).not.toHaveBeenCalled() + link.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 1 })) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][0]).toContain('h=apply.rate.com') + }) + + it('sends nothing at all while disabled — a consent gate can hold it shut', () => { + mount({ enabled: false }) + const link = document.createElement('a') + link.href = 'tel:+13125002674' + document.body.appendChild(link) + link.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('stops listening when it unmounts', () => { + mount() + const link = document.createElement('a') + link.href = 'tel:+13125002674' + document.body.appendChild(link) + act(() => root.unmount()) + link.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(fetchMock).not.toHaveBeenCalled() + root = createRoot(container) // afterEach unmounts something valid + }) + + it('respects a custom beacon path and the site’s own hosts', () => { + mount({ beaconPath: '/b.txt', ignoreHosts: ['apply.rate.com'] }) + const ignored = document.createElement('a') + ignored.href = 'https://apply.rate.com/x' + document.body.appendChild(ignored) + ignored.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(fetchMock).not.toHaveBeenCalled() + + const counted = document.createElement('a') + counted.href = 'https://maps.google.com/x' + document.body.appendChild(counted) + counted.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][0]).toMatch(/^\/b\.txt\?/) + }) +}) + +describe('the source keeps its promises', () => { + const component = read('src', 'outbound-clicks.tsx') + + it('sends no cookies', () => { + expect(component).toContain("credentials: 'omit'") + }) + + it('never touches the visitor’s link', () => { + expect(component).not.toMatch(/\.href\s*=|preventDefault|window\.open|location\.assign|location\.replace/) + }) + + it('names no third-party host anywhere in the beacon code', () => { + for (const source of [component, read('src', 'outbound.ts')]) { + expect(source).not.toMatch(/https?:\/\/(?!$)[a-z0-9.-]+\.(com|net|io|ai)\//i) + } + }) +}) diff --git a/src/outbound.ts b/src/outbound.ts new file mode 100644 index 0000000..1b36402 --- /dev/null +++ b/src/outbound.ts @@ -0,0 +1,147 @@ +/** + * Clicks the server logs cannot see, counted without analytics. + * + * A tap on a phone number, an email link, a link off the site or any element + * the page has labelled sends one GET for a tiny static file, with the click + * described in its query string. CloudFront's access log already records every + * request, query string included, so the log IS the counter — no cookie, no + * identifier, no third party, no server code. Read it with + * ~/claude/projects/iSM/scripts/outbound_clicks.py. + * + * The visitor's link is never touched: no redirect, no rewritten href, no + * preventDefault. The beacon rides alongside the click. + * + * Internal links are deliberately silent — a click that ends in a request to + * our own server is already in the same access log as a page view. + * + * WHAT MAY LEAVE THE PAGE: an event name, the current path, a hostname, and + * labels the site's own source code wrote. Never a phone number, an email + * address, a link's path or query string, form input, or anything a visitor + * typed. `BEACON_KEYS` is the whole vocabulary and outbound.test.ts fails if + * this file starts emitting a key that is not on it. + */ + +/** Default path of the static file the beacon requests. */ +export const OUTBOUND_BEACON_PATH = '/out.txt' + +export type OutboundKind = 'tel' | 'mailto' | 'out' | 'amazon' | 'merchant' | 'track' + +/** Every query key the beacon may ever carry. Adding one is a privacy decision. */ +export const BEACON_KEYS = ['e', 'p', 'n', 'h', 'i', 'm', 't'] as const + +export interface OutboundClick { + /** `e` — what happened. */ + kind: OutboundKind + /** `p` — pathname of the page the click happened on. */ + page: string + /** `n` — the site's own label for this element (`data-track`), or a product name. */ + label?: string + /** `h` — hostname of an outbound link. Hostname only: never the path or query. */ + host?: string + /** `i` — 1-based position of the element in its list, when the site says so. */ + position?: number + /** `m` — merchant name, for kind "merchant". */ + merchant?: string +} + +/** The part of an element this module reads; a DOM Element satisfies it. */ +export interface ElementLike { + getAttribute(name: string): string | null + closest(selector: string): ElementLike | null +} + +export interface ClickContext { + /** Pathname of the current page. */ + pathname: string + /** The page's own hostname. A link here is internal, not outbound. */ + hostname: string + /** Extra hostnames to treat as internal (a staging domain, a sibling property). */ + ignoreHosts?: readonly string[] +} + +const clip = (value: string, max: number) => value.trim().slice(0, max) + +const LABEL_MAX = 60 + +/** The site's own label for the clicked thing, from the nearest `data-track`. */ +function labelFor(element: ElementLike): string | undefined { + const labelled = element.closest('[data-track]') + const label = labelled?.getAttribute('data-track') + if (!label) return undefined + return clip(label, LABEL_MAX) || undefined +} + +/** Hostname of an absolute http(s) href, or null when it is not one we can read. */ +function hostOf(href: string): string | null { + if (!/^https?:\/\//i.test(href)) return null + try { + return new URL(href).hostname.toLowerCase() || null + } catch { + return null + } +} + +function isInternal(host: string, context: ClickContext): boolean { + const own = context.hostname.toLowerCase() + const bare = (h: string) => (h.startsWith('www.') ? h.slice(4) : h) + if (bare(host) === bare(own)) return true + return (context.ignoreHosts ?? []).some((ignored) => bare(host) === bare(ignored.toLowerCase())) +} + +/** + * Describes the click on `element`, or null when there is nothing to count. + * + * Order matters: a product link is a product link even though it is also + * outbound, and an element inside a link belongs to the link. + */ +export function describeClick(element: ElementLike, context: ClickContext): OutboundClick | null { + const page = clip(context.pathname, 120) || '/' + const anchor = element.closest('a[href]') + + if (anchor) { + const href = anchor.getAttribute('href') ?? '' + const label = labelFor(element) + + const merchant = anchor.getAttribute('data-merchant') + const isAmazon = anchor.getAttribute('data-amazon') !== null + if (isAmazon || merchant !== null) { + // adellion's original event, keys unchanged so outbound_clicks.py keeps reading it. + const position = Number.parseInt(anchor.getAttribute('data-position') ?? '', 10) + return { + kind: isAmazon ? 'amazon' : 'merchant', + page, + label: clip(anchor.getAttribute('data-product') ?? '', 80) || label, + position: Number.isFinite(position) ? position : 0, + ...(isAmazon || !merchant ? {} : { merchant: clip(merchant, 40) }), + } + } + + // A phone number and an email address are the message, not a label for it. + if (/^tel:/i.test(href)) return { kind: 'tel', page, label } + if (/^mailto:/i.test(href)) return { kind: 'mailto', page, label } + + const host = hostOf(href) + if (!host || isInternal(host, context)) return null + return { kind: 'out', page, host, label } + } + + // Not a link: a booking or chat opener, a store badge drawn as a button, a + // tool's "calculate". Only fires where the site asked for it by name. + const label = labelFor(element) + return label ? { kind: 'track', page, label } : null +} + +/** `t` makes every click its own URL, so neither the browser nor the edge answers one from cache. */ +export function beaconUrl( + click: OutboundClick, + now: number = Date.now(), + beaconPath: string = OUTBOUND_BEACON_PATH, +): string { + const params = new URLSearchParams({ e: click.kind, p: click.page }) + if (click.label) params.set('n', click.label) + if (click.host) params.set('h', click.host) + if (click.position !== undefined) params.set('i', String(click.position)) + if (click.merchant) params.set('m', click.merchant) + params.set('t', String(now)) + return `${beaconPath}?${params.toString()}` +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d473944 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + // The beacon is a delegated document listener; its tests click real elements. + environment: 'jsdom', + environmentOptions: { jsdom: { url: 'https://example.com/contact' } }, + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + }, +})