Files
agency/.pnpm-store/v3/files/54/8652a2cf85bb0592a98ea6630632ab43d2c25d525855dd584cc8adf66c30b0c3a23451da60175479e9816eca1a4a8a2b66c3d74235ee8e9c002896d89017a4
hanzo-agent 5af6960b87 run project
Run: sess_f37640604ae254cb4d11dc6217f7b721
2026-09-30 00:04:56 +00:00

146 lines
5.8 KiB
Plaintext

// The DEFAULT transport — what every surface that does not supply its own gets,
// exercised through the real `Analytics` rather than a stub, because the two
// properties below are properties of the shipped object and nothing else.
//
// Both are invisible to a test that reads the batch: the body is identical either
// way. What decides whether the batch ARRIVES is the beacon's content type (which
// decides whether the browser sends it at all) and what the code does with
// sendBeacon's answer (which decides whether a refusal is retried or dropped).
import { describe, it, expect } from 'vitest'
import { Analytics } from './core'
/** One thing navigator.sendBeacon was handed. */
interface Beaconed {
url: string
type: string
body: string
}
/** One thing fetch was handed. */
interface Fetched {
url: string
headers: Record<string, string>
body: string
/** Whether the request may outlive the document that made it. A fetch without
* it is cancelled on teardown, which is the whole failure this file guards. */
keepalive?: boolean
}
// The three CORS-safelisted request content types. A POST whose body carries one
// of these is a SIMPLE request and is sent immediately; anything else is
// PREFLIGHTED, and an unloading document never gets the preflight's second round
// trip — so cross-origin, a non-safelisted beacon is not delayed, it is lost.
const CORS_SAFELISTED = new Set([
'text/plain',
'application/x-www-form-urlencoded',
'multipart/form-data',
])
/** Runs ONE unload flush of the real DefaultTransport against a stub browser.
* `queued` is what navigator.sendBeacon answers — true when the agent accepts the
* batch, false when it refuses (a body past the beacon size limit, or a full
* queue). Globals are DEFINED and restored: Node ships a real `navigator` whose
* descriptor has no setter, so a plain assignment throws. */
function unloadFlush(queued: boolean): { beacons: Beaconed[]; fetches: Fetched[] } {
const beacons: Beaconed[] = []
const fetches: Fetched[] = []
const g = globalThis as Record<string, unknown>
const names = ['window', 'document', 'navigator', 'Blob', 'fetch']
const saved = names.map((n) => [n, Object.getOwnPropertyDescriptor(g, n)] as const)
const define = (name: string, value: unknown) =>
Object.defineProperty(g, name, { value, configurable: true, writable: true })
define('window', {
location: { href: 'https://acme.test/checkout', pathname: '/checkout', search: '' },
addEventListener: () => {},
})
define('document', { referrer: '', visibilityState: 'visible' })
define('navigator', {
sendBeacon: (url: string, blob: { type: string; body: string }) => {
beacons.push({ url, type: blob.type, body: blob.body })
return queued
},
})
define(
'Blob',
class {
type: string
body: string
constructor(parts: string[], opts?: { type?: string }) {
this.body = parts.join('')
this.type = opts?.type ?? ''
}
},
)
define(
'fetch',
(
url: string,
init: { headers: Record<string, string>; body: string; keepalive?: boolean },
) => {
fetches.push({ url, headers: init.headers, body: init.body, keepalive: init.keepalive })
return Promise.resolve({ ok: true, status: 200 })
},
)
try {
// No `transport`, so the client builds the real DefaultTransport. The key is
// explicit rather than inherited from the build env, so the assertions below
// are about this batch and not about the machine running them.
const a = new Analytics({ product: 'test', ingestKey: 'pk-abc123', captureErrors: false })
a.capture('checkout_started')
a.flush(true)
} finally {
for (const [n, d] of saved) {
if (d) Object.defineProperty(g, n, d)
else delete g[n]
}
}
return { beacons, fetches }
}
describe('the unload beacon', () => {
it('is a CORS-simple request, so an unloading document actually sends it', () => {
const { beacons, fetches } = unloadFlush(true)
expect(beacons).toHaveLength(1)
// The load-bearing assertion. A non-safelisted type preflights, and there is
// no second round trip during unload.
expect(CORS_SAFELISTED.has(beacons[0].type)).toBe(true)
// The other half of what makes it simple: a beacon can set no headers, so the
// credential rides the query. An Authorization header would preflight whatever
// the body's type is.
expect(beacons[0].url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
// A queued batch is sent ONCE. Falling through here would double-count every
// unload event in the warehouse.
expect(fetches).toHaveLength(0)
})
it('falls through to the keepalive fetch when the agent refuses to queue it', () => {
const { beacons, fetches } = unloadFlush(false)
// Offered to the beacon first...
expect(beacons).toHaveLength(1)
// ...and, refused, carried by the fetch sitting behind it rather than dropped.
expect(fetches).toHaveLength(1)
const batch = (JSON.parse(fetches[0].body) as { batch: { event?: string }[] }).batch
expect(batch.map((e) => e.event)).toContain('checkout_started')
// The fallback carries the key the SAME way the beacon it replaces did, and
// stays a simple request doing so — a fallback that preflights is no fallback
// on the customer origin this client is meant to run on.
expect(fetches[0].url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
expect(fetches[0].headers.Authorization).toBeUndefined()
expect(fetches[0].headers['Content-Type']).toBe('text/plain')
// The fallback is only a fallback if it survives the teardown that the beacon
// was there for. Without this the batch is cancelled mid-flight and the
// refusal was merely traded for a quieter loss.
expect(fetches[0].keepalive).toBe(true)
})
})