@hanzo/event 0.3.32 → 0.3.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hz.test.ts DELETED
@@ -1,376 +0,0 @@
1
- // hz.js is the no-build distribution — 300 lines of shipped client that no test
2
- // had ever executed. It restates, by hand, what the bundled client imports, so the
3
- // two can drift; this runs the real file against a minimal browser stub and reads
4
- // what it actually posts — batch, URL and headers, because the credential is not
5
- // in the body and a test that reads only the batch cannot see it.
6
-
7
- import { describe, expect, it, beforeEach } from 'vitest'
8
- import { readFileSync } from 'node:fs'
9
- import { fileURLToPath } from 'node:url'
10
-
11
- const SRC = readFileSync(fileURLToPath(new URL('../hz.js', import.meta.url)), 'utf8')
12
- const PKG = JSON.parse(
13
- readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
14
- ) as { version: string }
15
-
16
- /** The event plane's session-rollup admission gate, transcribed from its own SQL. */
17
- const versionNibble = (id: string): bigint => (BigInt('0x' + id.replace(/-/g, '')) >> 76n) & 15n
18
- const embeddedMs = (id: string): bigint => BigInt('0x' + id.replace(/-/g, '')) >> 80n
19
-
20
- interface WireEvent {
21
- messageId: string
22
- sessionId: string
23
- anonymousId: string
24
- type: string
25
- event?: string
26
- library: string
27
- libraryVersion: string
28
- }
29
-
30
- /** One recorded transmission: which transport carried it, where, under what headers
31
- * and — the fact that decides whether a cross-origin unload beacon is sent at all
32
- * — the content type its body was labelled with. */
33
- interface Post {
34
- via: 'beacon' | 'fetch'
35
- url: string
36
- headers: Record<string, string>
37
- contentType: string
38
- batch: WireEvent[]
39
- }
40
-
41
- // The three CORS-safelisted request content types. A POST whose body carries one
42
- // is a SIMPLE request and goes immediately; anything else is PREFLIGHTED, and an
43
- // unloading document never gets the preflight's second round trip. This tag runs
44
- // on a customer's own domain, so its beacon is always cross-origin.
45
- const CORS_SAFELISTED = new Set([
46
- 'text/plain',
47
- 'application/x-www-form-urlencoded',
48
- 'multipart/form-data',
49
- ])
50
-
51
- interface StubOptions {
52
- /** data-* attributes on the <script> tag. */
53
- attrs?: Record<string, string>
54
- /** navigator fields — doNotTrack, globalPrivacyControl, msDoNotTrack. */
55
- navigator?: Record<string, unknown>
56
- /** Seed localStorage (e.g. an explicit hz_consent choice). */
57
- storage?: Record<string, string>
58
- /** The cookie jar. Pass one in to model a browser that already carries an id —
59
- * from another *.hanzo.ai surface, or from the npm client on this same page. */
60
- jar?: Map<string, string>
61
- /** Let navigator.sendBeacon succeed, so the beacon path is the one measured. */
62
- beacon?: boolean
63
- }
64
-
65
- type Api = { track(n: string, p?: unknown): void; flush(): void }
66
-
67
- /** Runs hz.js against a stub browser and returns everything it posted.
68
- *
69
- * Globals are DEFINED, not assigned: Node ≥ 21 ships a real `navigator` whose
70
- * descriptor is an accessor with no setter, so the plain assignment this
71
- * harness used threw — and every hz.js test failed on a current runtime,
72
- * leaving the shipped file with no executed coverage again. */
73
- function runSnippet(opts: StubOptions = {}): {
74
- posts: Post[]
75
- api: Api | undefined
76
- local: Map<string, string>
77
- jar: Map<string, string>
78
- fire: (type: string) => void
79
- hide: () => void
80
- } {
81
- const posts: Post[] = []
82
- const listeners = new Map<string, (() => void)[]>()
83
- const local = new Map<string, string>(Object.entries(opts.storage ?? {}))
84
- const jar = opts.jar ?? new Map<string, string>()
85
- const store = (m: Map<string, string>) => ({
86
- getItem: (k: string) => m.get(k) ?? null,
87
- setItem: (k: string, v: string) => void m.set(k, v),
88
- removeItem: (k: string) => void m.delete(k),
89
- })
90
- const attrs: Record<string, string> = { 'data-product': 'test', ...opts.attrs }
91
- const g = globalThis as unknown as Record<string, unknown>
92
- const define = (name: string, value: unknown) =>
93
- Object.defineProperty(g, name, { value, configurable: true, writable: true })
94
-
95
- define('location', {
96
- href: 'https://x.test/p?a=1',
97
- pathname: '/p',
98
- search: '?a=1',
99
- hostname: 'x.test',
100
- host: 'x.test',
101
- })
102
- define('document', {
103
- currentScript: { getAttribute: (a: string) => attrs[a] ?? null },
104
- // A real jar: the anonymous id is a cookie now, so a stub with no `cookie`
105
- // would leave the whole identity chain unexecuted by these tests.
106
- get cookie(): string {
107
- return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
108
- },
109
- set cookie(raw: string) {
110
- const first = raw.split(';')[0]
111
- const eq = first.indexOf('=')
112
- if (eq > 0) jar.set(first.slice(0, eq).trim(), first.slice(eq + 1).trim())
113
- },
114
- referrer: '',
115
- addEventListener: () => {},
116
- documentElement: { scrollHeight: 1000 },
117
- visibilityState: 'visible',
118
- createElement: () => ({}),
119
- head: { appendChild: () => {} },
120
- })
121
- define('navigator', {
122
- doNotTrack: '0',
123
- ...opts.navigator,
124
- sendBeacon: opts.beacon
125
- ? (url: string, blob: { body: string; type: string }) => {
126
- posts.push({
127
- via: 'beacon',
128
- url,
129
- headers: {},
130
- contentType: blob.type,
131
- batch: JSON.parse(blob.body).batch,
132
- })
133
- return true
134
- }
135
- : undefined,
136
- })
137
- define(
138
- 'Blob',
139
- class {
140
- body: string
141
- type: string
142
- constructor(parts: string[], opts?: { type?: string }) {
143
- this.body = parts.join('')
144
- this.type = opts?.type ?? ''
145
- }
146
- },
147
- )
148
- define('localStorage', store(local))
149
- define('sessionStorage', store(new Map()))
150
- define('history', { pushState: () => {}, replaceState: () => {} })
151
- // Listeners are kept, not discarded: the unload flush IS a listener, so a stub
152
- // that drops them leaves the only path the drop-off signal travels unexecuted.
153
- define('addEventListener', (type: string, fn: () => void) => {
154
- const seen = listeners.get(type)
155
- if (seen) seen.push(fn)
156
- else listeners.set(type, [fn])
157
- })
158
- define('PerformanceObserver', undefined)
159
- define('hzDNT', undefined)
160
- define('doNotTrack', undefined)
161
- // The stub `window` IS globalThis, so a public API installed by an earlier run
162
- // would still be there — and "it refused" would be indistinguishable from
163
- // "the previous run's API answered".
164
- define('hanzo', undefined)
165
- define('fetch', (url: string, init: { body: string; headers: Record<string, string> }) => {
166
- posts.push({
167
- via: 'fetch',
168
- url,
169
- headers: init.headers,
170
- contentType: init.headers['content-type'] ?? '',
171
- batch: JSON.parse(init.body).batch,
172
- })
173
- return Promise.resolve()
174
- })
175
- define('window', g)
176
-
177
- new Function(SRC)()
178
- /** Raise an event the page would raise, so its listeners actually run. */
179
- const fire = (type: string) => {
180
- for (const fn of listeners.get(type) ?? []) fn()
181
- }
182
- /** Hide the document, as a browser does before it takes one away. */
183
- const hide = () => {
184
- ;(g.document as { visibilityState: string }).visibilityState = 'hidden'
185
- fire('visibilitychange')
186
- }
187
- return { posts, api: (g.window as { hanzo?: Api }).hanzo, local, jar, fire, hide }
188
- }
189
-
190
- /** Every event across every transmission, in order. */
191
- const sentOf = (r: { posts: Post[] }): WireEvent[] => r.posts.flatMap((p) => p.batch)
192
-
193
- describe('hz.js', () => {
194
- let run: ReturnType<typeof runSnippet>
195
- beforeEach(() => {
196
- run = runSnippet()
197
- })
198
-
199
- // A visitor who leaves mid-funnel is the drop-off signal, and hz.js only gets
200
- // to report it from a teardown listener. Neither signal fires in every browser
201
- // on every path, so the tag listens for both.
202
- it('flushes what is queued when the tab is hidden', () => {
203
- run.api!.track('plan_clicked')
204
- run.hide()
205
- expect(sentOf(run).map((e) => e.event)).toContain('plan_clicked')
206
- })
207
-
208
- it('flushes what is queued on pagehide, which visibilitychange need not precede', () => {
209
- run.api!.track('checkout_started')
210
- run.fire('pagehide')
211
- expect(sentOf(run).map((e) => e.event)).toContain('checkout_started')
212
- })
213
-
214
- it('sends nothing a second time when both teardown signals arrive', () => {
215
- run.api!.track('plan_clicked')
216
- run.hide()
217
- run.fire('pagehide')
218
- const clicks = sentOf(run).filter((e) => e.event === 'plan_clicked')
219
- expect(clicks).toHaveLength(1)
220
- })
221
-
222
- it('mints session ids the plane admits', () => {
223
- run.api!.track('checkout_started')
224
- run.api!.flush()
225
- const sent = sentOf(run)
226
- expect(sent.length).toBeGreaterThan(0)
227
- const before = Date.now()
228
- for (const ev of sent) {
229
- expect(versionNibble(ev.sessionId)).toBe(7n)
230
- expect(versionNibble(ev.messageId)).toBe(7n)
231
- expect(versionNibble(ev.anonymousId)).toBe(7n)
232
- // The embedded instant is the real mint time, not a constant.
233
- expect(Number(embeddedMs(ev.sessionId))).toBeGreaterThan(before - 60_000)
234
- expect(Number(embeddedMs(ev.sessionId))).toBeLessThanOrEqual(Date.now())
235
- }
236
- })
237
-
238
- it('holds one session id across every event it emits', () => {
239
- run.api!.track('a')
240
- run.api!.track('b')
241
- run.api!.flush()
242
- const sent = sentOf(run)
243
- expect(new Set(sent.map((e) => e.sessionId)).size).toBe(1)
244
- expect(new Set(sent.map((e) => e.messageId)).size).toBe(sent.length)
245
- })
246
-
247
- it('emits the auto pageview on load and stamps the library', () => {
248
- run.api!.flush()
249
- const pv = sentOf(run).find((e) => e.type === 'pageview')
250
- expect(pv).toBeDefined()
251
- expect(pv!.library).toBe('hz.js')
252
- // The stamped version IS the package version. It had drifted to 0.3.9 against
253
- // a published 0.3.11, so every static-site row in the warehouse was dated to a
254
- // release three patches old — including the ones that changed what it sends.
255
- expect(pv!.libraryVersion).toBe(PKG.version)
256
- })
257
-
258
- // ── identity ──────────────────────────────────────────────────────────────
259
- // This file used to mint into `hz_id`, a key nothing else read or wrote, so a
260
- // page carrying both this tag and the npm client sent two anonymous ids for one
261
- // visitor — and every surface counted them as two people. It now runs the same
262
- // chain, from the same file, against the same key.
263
-
264
- const SEEDED = '01920000-0000-7000-8000-0000000000cc'
265
- const LEGACY = '01920000-0000-7000-8000-0000000000dd'
266
-
267
- it('is the same person as every other Hanzo client on the browser', () => {
268
- // The cookie the npm client (or another *.hanzo.ai surface) already wrote.
269
- const r = runSnippet({ jar: new Map([['hz_anon_id', SEEDED]]) })
270
- r.api!.track('checkout_started')
271
- r.api!.flush()
272
- const sent = sentOf(r)
273
- expect(sent.length).toBeGreaterThan(0)
274
- for (const ev of sent) expect(ev.anonymousId).toBe(SEEDED)
275
- })
276
-
277
- it('adopts the `hz_id` it used to mint rather than making a stranger', () => {
278
- // Every browser that has ever loaded this tag holds one of these. Minting
279
- // over it would detach a returning visitor from their own history.
280
- const r = runSnippet({ storage: { hz_id: LEGACY } })
281
- r.api!.flush()
282
- for (const ev of sentOf(r)) expect(ev.anonymousId).toBe(LEGACY)
283
- expect(r.jar.get('hz_anon_id')).toBe(LEGACY) // carried onto the shared key
284
- expect(r.local.get('hz_anon_id')).toBe(LEGACY)
285
- })
286
-
287
- it('writes the one key, in the durable place, and no longer mints its own', () => {
288
- const r = runSnippet()
289
- r.api!.flush()
290
- const id = sentOf(r)[0].anonymousId
291
- expect(r.jar.get('hz_anon_id')).toBe(id) // the cookie outlives the ORIGIN
292
- expect(r.local.get('hz_anon_id')).toBe(id)
293
- expect(r.local.has('hz_id')).toBe(false)
294
- })
295
-
296
- // ── the publishable key ───────────────────────────────────────────────────
297
- // Through 0.3.11 this file could present none at all: no header, no query. A
298
- // keyed static surface therefore sent UNATTRIBUTED writes, which the door
299
- // refuses — silently, because nothing here reads the response.
300
-
301
- // The fetch presents it on the SAME carrier the beacon does, and stays a CORS
302
- // simple request doing so. This file's whole job is to run on a customer's own
303
- // page, where every send is cross-origin: an Authorization header or a JSON
304
- // content type would preflight the POST, and an origin that does not pass the
305
- // preflight loses the batch that splice has already emptied. A simple request is
306
- // sent from any origin — the browser asks permission to READ a cross-origin
307
- // response, never to send one, and nothing here reads it.
308
- it('presents the publishable key on the query, as a simple request', () => {
309
- const r = runSnippet({ attrs: { 'data-publishable-key': 'pk-abc123' } })
310
- r.api!.flush()
311
- const post = r.posts.at(-1)!
312
- expect(post.via).toBe('fetch')
313
- expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
314
- expect(post.headers.authorization).toBeUndefined()
315
- expect(post.headers['content-type']).toBe('text/plain')
316
- // Content-Type is safelisted only for three values; every other header is
317
- // outside the safelist by construction, so the count is the check.
318
- expect(Object.keys(post.headers)).toEqual(['content-type'])
319
- })
320
-
321
- it('still reads the retiring data-ingest-key, on a headerless beacon', () => {
322
- const r = runSnippet({ attrs: { 'data-ingest-key': 'pk-abc123' }, beacon: true })
323
- r.api!.flush()
324
- const post = r.posts.at(-1)!
325
- expect(post.via).toBe('beacon')
326
- expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
327
- })
328
-
329
- it('labels the beacon body a CORS-simple type, so the unload POST is sent', () => {
330
- // The tag is installed on a customer's own domain, so every send here is
331
- // cross-origin. A non-safelisted body type preflights, and an unloading
332
- // document gets no second round trip — the batch would simply never leave.
333
- // The credential is in the query for the same reason: a beacon sets no
334
- // headers, and an Authorization header preflights whatever the type is.
335
- const r = runSnippet({ attrs: { 'data-ingest-key': 'pk-abc123' }, beacon: true })
336
- r.api!.flush()
337
- const post = r.posts.at(-1)!
338
- expect(post.via).toBe('beacon')
339
- expect(CORS_SAFELISTED.has(post.contentType)).toBe(true)
340
- })
341
-
342
- it('sends no credential when no key is declared — no baked literal', () => {
343
- // The key is the surface's own, stamped into the tag by its deploy from KMS;
344
- // a bare tag carries nothing, so it is honestly keyless rather than borrowing
345
- // a hardcoded org credential.
346
- run.api!.flush()
347
- const post = run.posts.at(-1)!
348
- expect(post.headers.authorization).toBeUndefined()
349
- expect(post.url).not.toContain('ingest_key')
350
- })
351
-
352
- // ── consent ───────────────────────────────────────────────────────────────
353
-
354
- it('refuses under Global Privacy Control', () => {
355
- const r = runSnippet({ navigator: { globalPrivacyControl: true } })
356
- expect(r.api).toBeUndefined()
357
- expect(r.posts).toEqual([])
358
- })
359
-
360
- it('refuses under Do Not Track, in each of its spellings', () => {
361
- for (const nav of [{ doNotTrack: '1' }, { doNotTrack: 'yes' }, { msDoNotTrack: '1' }]) {
362
- expect(runSnippet({ navigator: nav }).api).toBeUndefined()
363
- }
364
- })
365
-
366
- it('refuses on a stored denial', () => {
367
- expect(runSnippet({ storage: { hz_consent: 'denied' } }).api).toBeUndefined()
368
- })
369
-
370
- it('an explicit grant outranks the browser signal', () => {
371
- const r = runSnippet({ navigator: { doNotTrack: '1' }, storage: { hz_consent: 'granted' } })
372
- expect(r.api).toBeDefined()
373
- r.api!.flush()
374
- expect(sentOf(r).length).toBeGreaterThan(0)
375
- })
376
- })