@hanzo/event 0.3.42 → 0.3.44
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/dist/consent.d.ts +36 -0
- package/dist/consent.d.ts.map +1 -0
- package/dist/cookie.d.ts +5 -0
- package/dist/cookie.d.ts.map +1 -0
- package/dist/core.d.ts +8 -0
- package/dist/core.d.ts.map +1 -1
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/index.cjs +594 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +572 -33
- package/dist/index.mjs.map +1 -1
- package/dist/link.d.ts +28 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/react.cjs +120 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +120 -2
- package/dist/react.mjs.map +1 -1
- package/dist/storage.d.ts +6 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/tags.d.ts +42 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/touch.d.ts +7 -0
- package/dist/touch.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/anon.d.ts +3 -0
- package/src/anon.js +19 -1
- package/src/consent.test.ts +140 -0
- package/src/consent.ts +116 -0
- package/src/cookie.ts +28 -0
- package/src/core.ts +45 -3
- package/src/events.ts +1 -1
- package/src/index.ts +10 -0
- package/src/link.test.ts +222 -0
- package/src/link.ts +103 -0
- package/src/storage.ts +14 -1
- package/src/tags.test.ts +187 -0
- package/src/tags.ts +449 -0
- package/src/touch.ts +68 -0
package/src/link.test.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Cross-domain continuity: a link between Hanzo hosts carries the visitor, the
|
|
2
|
+
// destination adopts it before its first pageview and cleans the address bar.
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
5
|
+
import { linkUrl, readLink, stripLink, encodeFirstTouch } from './link'
|
|
6
|
+
import { keyFor, keyForPage } from './org'
|
|
7
|
+
import type { Attribution, Transport, WireEvent } from './types'
|
|
8
|
+
|
|
9
|
+
const AID = '01920000-0000-7000-8000-0000000000aa'
|
|
10
|
+
const SID = '01920000-0000-7000-8000-0000000000bb'
|
|
11
|
+
const LOCAL = '01920000-0000-7000-8000-0000000000cc'
|
|
12
|
+
const FT: Attribution = {
|
|
13
|
+
utm: { source: 'x', medium: 'cpc', campaign: 'launch' },
|
|
14
|
+
refCode: 'r1',
|
|
15
|
+
referrer: 'https://t.co/abc',
|
|
16
|
+
channel: 'paid',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const g = globalThis as Record<string, unknown>
|
|
20
|
+
|
|
21
|
+
function browser(href: string, storage: Record<string, string> = {}) {
|
|
22
|
+
const url = new URL(href)
|
|
23
|
+
const store = new Map(Object.entries(storage))
|
|
24
|
+
const jar = new Map<string, string>()
|
|
25
|
+
const replaced: string[] = []
|
|
26
|
+
const location = {
|
|
27
|
+
href: url.href,
|
|
28
|
+
hostname: url.hostname,
|
|
29
|
+
protocol: url.protocol,
|
|
30
|
+
pathname: url.pathname,
|
|
31
|
+
search: url.search,
|
|
32
|
+
}
|
|
33
|
+
g.window = {
|
|
34
|
+
location,
|
|
35
|
+
history: {
|
|
36
|
+
state: null,
|
|
37
|
+
replaceState: (_s: unknown, _t: string, next: string) => {
|
|
38
|
+
replaced.push(next)
|
|
39
|
+
const u = new URL(next, url)
|
|
40
|
+
location.href = u.href
|
|
41
|
+
location.search = u.search
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
localStorage: {
|
|
45
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
46
|
+
setItem: (k: string, v: string) => void store.set(k, v),
|
|
47
|
+
},
|
|
48
|
+
addEventListener: () => {},
|
|
49
|
+
}
|
|
50
|
+
g.document = {
|
|
51
|
+
get cookie() {
|
|
52
|
+
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
|
|
53
|
+
},
|
|
54
|
+
set cookie(raw: string) {
|
|
55
|
+
const kv = raw.split(';')[0]
|
|
56
|
+
const eq = kv.indexOf('=')
|
|
57
|
+
jar.set(kv.slice(0, eq).trim(), kv.slice(eq + 1))
|
|
58
|
+
},
|
|
59
|
+
referrer: 'https://hanzo.ai/',
|
|
60
|
+
visibilityState: 'visible',
|
|
61
|
+
}
|
|
62
|
+
return { store, jar, replaced, location }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class Tx implements Transport {
|
|
66
|
+
all: WireEvent[] = []
|
|
67
|
+
send(_u: string, body: string) {
|
|
68
|
+
try {
|
|
69
|
+
this.all.push(...(JSON.parse(body) as { batch: WireEvent[] }).batch)
|
|
70
|
+
} catch {
|
|
71
|
+
/* not JSON */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const load = async () => {
|
|
77
|
+
vi.resetModules()
|
|
78
|
+
return await import('./core')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
delete g.window
|
|
83
|
+
delete g.document
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('linkUrl', () => {
|
|
87
|
+
const state = { anonId: AID, sessionId: SID, firstTouch: FT }
|
|
88
|
+
|
|
89
|
+
it('appends id, session and first touch for Hanzo-owned hosts', () => {
|
|
90
|
+
for (const host of ['hanzo.id', 'hanzo.app', 'hanzo.team', 'hanzo.bot', 'id.lux.network', 'zoo.ngo']) {
|
|
91
|
+
const u = new URL(linkUrl(`https://${host}/login`, state, 'hanzo.ai'))
|
|
92
|
+
expect(u.searchParams.get('hz_aid')).toBe(AID)
|
|
93
|
+
expect(u.searchParams.get('hz_sid')).toBe(SID)
|
|
94
|
+
expect(readLink(u.search).firstTouch).toEqual(FT)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('leaves foreign hosts and same-host links alone', () => {
|
|
99
|
+
expect(linkUrl('https://example.com/x', state, 'hanzo.ai')).toBe('https://example.com/x')
|
|
100
|
+
expect(linkUrl('https://hanzo.ai/x', state, 'hanzo.ai')).toBe('https://hanzo.ai/x')
|
|
101
|
+
expect(linkUrl('mailto:z@hanzo.ai', state, 'hanzo.ai')).toBe('mailto:z@hanzo.ai')
|
|
102
|
+
expect(linkUrl('https://evilhanzo.id/x', state, 'hanzo.ai')).toBe('https://evilhanzo.id/x')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('keeps existing query and hash, and refuses a non-UUID id', () => {
|
|
106
|
+
const out = linkUrl('https://hanzo.id/login/oauth/authorize?client_id=a&x=1#h', { anonId: 'nope' }, 'hanzo.ai')
|
|
107
|
+
expect(out).toBe('https://hanzo.id/login/oauth/authorize?client_id=a&x=1#h')
|
|
108
|
+
const ok = new URL(linkUrl('https://hanzo.id/a?client_id=a#h', state, 'hanzo.ai'))
|
|
109
|
+
expect(ok.searchParams.get('client_id')).toBe('a')
|
|
110
|
+
expect(ok.hash).toBe('#h')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('round-trips first touch and rejects garbage', () => {
|
|
114
|
+
expect(readLink('?hz_ft=' + encodeURIComponent(encodeFirstTouch(FT))).firstTouch).toEqual(FT)
|
|
115
|
+
expect(readLink('?hz_ft=%5B1%5D&hz_aid=zzz').firstTouch).toBeUndefined()
|
|
116
|
+
expect(readLink('?hz_aid=zzz').anonId).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('stripLink removes only its own params', () => {
|
|
120
|
+
expect(stripLink(`https://hanzo.id/a?hz_aid=${AID}&keep=1&hz_sid=${SID}#f`)).toBe('/a?keep=1#f')
|
|
121
|
+
expect(stripLink('https://hanzo.id/a?keep=1')).toBeUndefined()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('Analytics adopts a link on init', () => {
|
|
126
|
+
const href = `https://hanzo.id/login?client_id=c&hz_aid=${AID}&hz_sid=${SID}&hz_ft=${encodeURIComponent(encodeFirstTouch(FT))}`
|
|
127
|
+
|
|
128
|
+
it('first pageview carries the linked ids, first touch and a clean address bar', async () => {
|
|
129
|
+
const b = browser(href, { 'iam-anon-id': LOCAL })
|
|
130
|
+
const { Analytics } = await load()
|
|
131
|
+
const tx = new Tx()
|
|
132
|
+
const a = new Analytics({ product: 't', host: '', transport: tx })
|
|
133
|
+
a.pageview()
|
|
134
|
+
a.flush()
|
|
135
|
+
const e = tx.all[0]
|
|
136
|
+
expect(e.anonymousId).toBe(AID)
|
|
137
|
+
expect(e.sessionId).toBe(SID)
|
|
138
|
+
expect(e.channel).toBe('paid')
|
|
139
|
+
expect(e.utm?.campaign).toBe('launch')
|
|
140
|
+
expect(e.url).not.toContain('hz_aid')
|
|
141
|
+
expect(b.replaced).toEqual(['/login?client_id=c'])
|
|
142
|
+
expect(b.location.search).toBe('?client_id=c')
|
|
143
|
+
expect(b.jar.get('iam-anon-id')).toBe(AID)
|
|
144
|
+
expect(b.store.get('iam-anon-id')).toBe(AID)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('an existing first touch is not overwritten by a link', async () => {
|
|
148
|
+
browser(href, { hz_first_touch: JSON.stringify({ utm: { source: 'old' }, channel: 'organic' }) })
|
|
149
|
+
const { Analytics } = await load()
|
|
150
|
+
const tx = new Tx()
|
|
151
|
+
const a = new Analytics({ product: 't', host: '', transport: tx })
|
|
152
|
+
a.pageview()
|
|
153
|
+
a.flush()
|
|
154
|
+
expect(tx.all[0].utm?.source).toBe('old')
|
|
155
|
+
expect(tx.all[0].anonymousId).toBe(AID)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('a link with a bad id changes nothing but is still stripped', async () => {
|
|
159
|
+
const b = browser('https://hanzo.id/x?hz_aid=evil', { 'iam-anon-id': LOCAL })
|
|
160
|
+
const { Analytics } = await load()
|
|
161
|
+
const tx = new Tx()
|
|
162
|
+
new Analytics({ product: 't', host: '', transport: tx }).pageview()
|
|
163
|
+
expect(b.replaced).toEqual(['/x'])
|
|
164
|
+
expect(b.store.get('iam-anon-id')).toBe(LOCAL)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('Analytics.link / authorize / identify', () => {
|
|
169
|
+
it('link decorates an IAM authorize URL with the current visitor', async () => {
|
|
170
|
+
browser('https://hanzo.ai/chat?utm_source=news', { 'iam-anon-id': LOCAL })
|
|
171
|
+
const { Analytics } = await load()
|
|
172
|
+
const a = new Analytics({ product: 't', host: '', transport: new Tx() })
|
|
173
|
+
const u = new URL(a.link('https://hanzo.id/login/oauth/authorize?client_id=c&state=s'))
|
|
174
|
+
expect(u.searchParams.get('client_id')).toBe('c')
|
|
175
|
+
expect(u.searchParams.get('hz_aid')).toBe(LOCAL)
|
|
176
|
+
expect(u.searchParams.get('hz_sid')).toMatch(/^[0-9a-f-]{36}$/)
|
|
177
|
+
expect(readLink(u.search).firstTouch?.utm.source).toBe('news')
|
|
178
|
+
expect(a.link('https://example.com/x')).toBe('https://example.com/x')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('authorize decorates and flushes what is queued before unload', async () => {
|
|
182
|
+
browser('https://hanzo.ai/chat', { 'iam-anon-id': LOCAL })
|
|
183
|
+
const { Analytics } = await load()
|
|
184
|
+
const tx = new Tx()
|
|
185
|
+
const a = new Analytics({ product: 't', host: '', transport: tx, flushIntervalMs: 999999 })
|
|
186
|
+
a.capture('chat_started')
|
|
187
|
+
const out = a.authorize('https://hanzo.id/login/oauth/authorize?client_id=c')
|
|
188
|
+
expect(out).toContain('hz_aid=' + LOCAL)
|
|
189
|
+
expect(tx.all.map((e) => e.event)).toEqual(['chat_started'])
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('identify emits the anonymous id and the person id together', async () => {
|
|
193
|
+
browser('https://hanzo.ai/', { 'iam-anon-id': LOCAL })
|
|
194
|
+
const { Analytics } = await load()
|
|
195
|
+
const tx = new Tx()
|
|
196
|
+
const a = new Analytics({ product: 't', host: '', transport: tx })
|
|
197
|
+
a.identify('person-1')
|
|
198
|
+
a.flush()
|
|
199
|
+
const e = tx.all[0]
|
|
200
|
+
expect(e.type).toBe('identify')
|
|
201
|
+
expect(e.anonymousId).toBe(LOCAL)
|
|
202
|
+
expect(e.personId).toBe('person-1')
|
|
203
|
+
expect(e.distinctId).toBe('person-1')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('one ingest key across the journey', () => {
|
|
208
|
+
it('hanzo.ai, hanzo.id, hanzo.app resolve to the same org key', () => {
|
|
209
|
+
const k = keyFor('hanzo.ai')
|
|
210
|
+
expect(k).toBeTruthy()
|
|
211
|
+
for (const h of ['www.hanzo.ai', 'hanzo.id', 'hanzo.app', 'hanzo.team', 'hanzo.bot']) expect(keyFor(h)).toBe(k)
|
|
212
|
+
const ring = { hanzo: 'pk-runtime' }
|
|
213
|
+
expect(keyFor('hanzo.id', ring)).toBe(keyFor('hanzo.ai', ring))
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('keyForPage on either host names the same project', () => {
|
|
217
|
+
browser('https://hanzo.id/login')
|
|
218
|
+
const id = keyForPage()
|
|
219
|
+
browser('https://hanzo.ai/')
|
|
220
|
+
expect(keyForPage()).toBe(id)
|
|
221
|
+
})
|
|
222
|
+
})
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Cross-domain visitor continuity.
|
|
2
|
+
//
|
|
3
|
+
// Cookies and localStorage stop at the registrable domain, so a visitor who goes
|
|
4
|
+
// hanzo.ai -> hanzo.id (sign-in) -> back arrived as separate people on each host, and
|
|
5
|
+
// the journey across the sign-in redirect could not be read. A link between two
|
|
6
|
+
// Hanzo hosts therefore carries the visitor's identity in three short query
|
|
7
|
+
// parameters, and the destination adopts them before its first pageview and
|
|
8
|
+
// removes them from the address bar.
|
|
9
|
+
//
|
|
10
|
+
// hz_aid anonymous id hz_sid session id hz_ft first-touch attribution
|
|
11
|
+
//
|
|
12
|
+
// They are appended ONLY when the destination host belongs to a brand in ORG_DOMAIN:
|
|
13
|
+
// an id handed to a host outside the table would be handed to a stranger.
|
|
14
|
+
|
|
15
|
+
import { orgOf } from './org'
|
|
16
|
+
import type { Attribution } from './types'
|
|
17
|
+
|
|
18
|
+
export const LINK_PARAMS = { anon: 'hz_aid', session: 'hz_sid', firstTouch: 'hz_ft' } as const
|
|
19
|
+
|
|
20
|
+
/** Ids are v7 UUIDs; anything else in a URL is someone else's input and is refused. */
|
|
21
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
22
|
+
|
|
23
|
+
export const isId = (v: string | null | undefined): v is string => !!v && UUID.test(v)
|
|
24
|
+
|
|
25
|
+
/** What a link carries. Any field may be absent. */
|
|
26
|
+
export interface LinkState {
|
|
27
|
+
anonId?: string
|
|
28
|
+
sessionId?: string
|
|
29
|
+
firstTouch?: Attribution
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** First touch as a compact tuple: utm source, medium, campaign, term, content, refCode,
|
|
33
|
+
* referrer, channel. Short because it rides a URL; a tuple because keys cost bytes. */
|
|
34
|
+
export function encodeFirstTouch(a: Attribution): string {
|
|
35
|
+
return JSON.stringify([
|
|
36
|
+
a.utm.source, a.utm.medium, a.utm.campaign, a.utm.term, a.utm.content,
|
|
37
|
+
a.refCode, a.referrer, a.channel,
|
|
38
|
+
].map((v) => v ?? ''))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function decodeFirstTouch(raw: string | null | undefined): Attribution | undefined {
|
|
42
|
+
if (!raw || raw.length > 1024) return undefined
|
|
43
|
+
try {
|
|
44
|
+
const t: unknown = JSON.parse(raw)
|
|
45
|
+
if (!Array.isArray(t) || t.length !== 8 || t.some((v) => typeof v !== 'string')) return undefined
|
|
46
|
+
const [source, medium, campaign, term, content, refCode, referrer, channel] = t as string[]
|
|
47
|
+
const u = (v: string) => v || undefined
|
|
48
|
+
return {
|
|
49
|
+
utm: { source: u(source), medium: u(medium), campaign: u(campaign), term: u(term), content: u(content) },
|
|
50
|
+
refCode: u(refCode),
|
|
51
|
+
referrer: u(referrer),
|
|
52
|
+
channel: u(channel),
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** linkUrl returns `url` with the visitor's identity appended when its host is
|
|
60
|
+
* Hanzo-owned and is not `fromHost`; otherwise `url` unchanged. Relative URLs
|
|
61
|
+
* resolve against `base`. Never throws. */
|
|
62
|
+
export function linkUrl(url: string, state: LinkState, fromHost: string, base?: string): string {
|
|
63
|
+
let u: URL
|
|
64
|
+
try {
|
|
65
|
+
u = new URL(url, base)
|
|
66
|
+
} catch {
|
|
67
|
+
return url
|
|
68
|
+
}
|
|
69
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return url
|
|
70
|
+
if (!orgOf(u.hostname) || u.hostname === fromHost) return url
|
|
71
|
+
const set = (k: string, v: string | undefined) => v && u.searchParams.set(k, v)
|
|
72
|
+
if (isId(state.anonId)) set(LINK_PARAMS.anon, state.anonId)
|
|
73
|
+
if (isId(state.sessionId)) set(LINK_PARAMS.session, state.sessionId)
|
|
74
|
+
if (state.firstTouch) set(LINK_PARAMS.firstTouch, encodeFirstTouch(state.firstTouch))
|
|
75
|
+
return u.toString()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** readLink parses what a link carried out of a location.search value. Invalid
|
|
79
|
+
* values are dropped, not repaired. */
|
|
80
|
+
export function readLink(search: string): LinkState {
|
|
81
|
+
const q = new URLSearchParams(search || '')
|
|
82
|
+
const a = q.get(LINK_PARAMS.anon)
|
|
83
|
+
const s = q.get(LINK_PARAMS.session)
|
|
84
|
+
return {
|
|
85
|
+
anonId: isId(a) ? a : undefined,
|
|
86
|
+
sessionId: isId(s) ? s : undefined,
|
|
87
|
+
firstTouch: decodeFirstTouch(q.get(LINK_PARAMS.firstTouch)),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** stripLink returns the path, query and hash of `href` without the link parameters, or
|
|
92
|
+
* undefined when there were none. */
|
|
93
|
+
export function stripLink(href: string): string | undefined {
|
|
94
|
+
const u = new URL(href)
|
|
95
|
+
let hit = false
|
|
96
|
+
for (const k of Object.values(LINK_PARAMS)) {
|
|
97
|
+
if (u.searchParams.has(k)) {
|
|
98
|
+
u.searchParams.delete(k)
|
|
99
|
+
hit = true
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return hit ? u.pathname + u.search + u.hash : undefined
|
|
103
|
+
}
|
package/src/storage.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// the one value the OTHER two distributions must agree with, so its chain lives in
|
|
7
7
|
// ./anon.js and this file only calls it. Everything else here stays origin-local.
|
|
8
8
|
|
|
9
|
-
import { hzAnonId } from './anon.js'
|
|
9
|
+
import { hzAnonAdopt, hzAnonId } from './anon.js'
|
|
10
10
|
import type { Attribution, Cohort } from './types'
|
|
11
11
|
import { uuidv7 } from './uid'
|
|
12
12
|
|
|
@@ -75,6 +75,19 @@ export function sessionId(now = Date.now()): string | undefined {
|
|
|
75
75
|
return state.id
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** Make `id` the current session, as if it had begun now. The session a link
|
|
79
|
+
* carries is the one the visitor is already in, so the journey stays one session
|
|
80
|
+
* across the hop instead of splitting at the host boundary. */
|
|
81
|
+
export function adoptSession(id: string, now = Date.now()): void {
|
|
82
|
+
const s = ls()
|
|
83
|
+
if (s) s.setItem(KEY.session, JSON.stringify({ id, last: now }))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Make `id` the browser's anonymous id, replacing the one it held. */
|
|
87
|
+
export function adoptAnonId(id: string): void {
|
|
88
|
+
hzAnonAdopt(id)
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
/** Read the persisted first-touch attribution. */
|
|
79
92
|
export function getFirstTouch(): Attribution | undefined {
|
|
80
93
|
const s = ls()
|
package/src/tags.test.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The tag manager: nothing loads before consent, everything loads after, and an
|
|
2
|
+
// event reaches each platform under one id and the names the platform table gives.
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const g = globalThis as Record<string, unknown>
|
|
7
|
+
const original = {
|
|
8
|
+
navigator: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
|
|
9
|
+
Intl: Object.getOwnPropertyDescriptor(globalThis, 'Intl'),
|
|
10
|
+
}
|
|
11
|
+
const put = (k: string, value: unknown) => Object.defineProperty(globalThis, k, { value, configurable: true, writable: true })
|
|
12
|
+
|
|
13
|
+
const TAGS = {
|
|
14
|
+
tags: [
|
|
15
|
+
{ platform: 'ga4', type: 'ga', id: 'G-TEST' },
|
|
16
|
+
{ platform: 'google-ads', type: 'gads', id: 'AW-1', events: { order_completed: 'AW-1/abc' } },
|
|
17
|
+
{ platform: 'meta', type: 'meta', id: '111' },
|
|
18
|
+
{ platform: 'linkedin', type: 'linkedin', id: '555', events: { order_completed: '9876' } },
|
|
19
|
+
{ platform: 'x', type: 'x', id: 'o1abc', events: { order_completed: 'tw-o1abc-buy' } },
|
|
20
|
+
{ platform: 'tiktok', type: 'tiktok', id: 'TT1' },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function browser(zone: string, jar = new Map<string, string>()) {
|
|
25
|
+
const scripts: string[] = []
|
|
26
|
+
const listeners = new Map<string, Array<() => void>>()
|
|
27
|
+
const requests: string[] = []
|
|
28
|
+
put('navigator', { globalPrivacyControl: false, userAgent: 'UA' })
|
|
29
|
+
put('Intl', { DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: zone }) }) })
|
|
30
|
+
g.Event = class {
|
|
31
|
+
constructor(public type: string) {}
|
|
32
|
+
}
|
|
33
|
+
g.window = {
|
|
34
|
+
location: { hostname: 'hanzo.ai', protocol: 'https:', search: '', href: 'https://hanzo.ai/' },
|
|
35
|
+
dispatchEvent: (e: { type: string }) => listeners.get(e.type)?.forEach((f) => f()),
|
|
36
|
+
addEventListener: (t: string, f: () => void) => listeners.set(t, [...(listeners.get(t) ?? []), f]),
|
|
37
|
+
removeEventListener: () => undefined,
|
|
38
|
+
}
|
|
39
|
+
g.addEventListener = (g.window as { addEventListener: unknown }).addEventListener
|
|
40
|
+
g.removeEventListener = () => undefined
|
|
41
|
+
g.document = {
|
|
42
|
+
get cookie() {
|
|
43
|
+
return [...jar].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ')
|
|
44
|
+
},
|
|
45
|
+
set cookie(line: string) {
|
|
46
|
+
const [pair] = line.split(';')
|
|
47
|
+
const i = pair.indexOf('=')
|
|
48
|
+
jar.set(pair.slice(0, i), decodeURIComponent(pair.slice(i + 1)))
|
|
49
|
+
},
|
|
50
|
+
createElement: () => ({}) as Record<string, unknown>,
|
|
51
|
+
head: { appendChild: (s: { src: string; onload?: () => void }) => (scripts.push(s.src), setTimeout(() => s.onload?.(), 0)) },
|
|
52
|
+
}
|
|
53
|
+
put('fetch', async (url: string) => {
|
|
54
|
+
requests.push(url)
|
|
55
|
+
return { ok: true, json: async () => TAGS }
|
|
56
|
+
})
|
|
57
|
+
let n = 0
|
|
58
|
+
put('crypto', { randomUUID: () => `id-${++n}` })
|
|
59
|
+
return { jar, scripts, requests }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeEach(() => vi.resetModules())
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
for (const k of ['window', 'document', 'Event', 'addEventListener', 'removeEventListener', 'fetch', 'crypto']) delete g[k]
|
|
65
|
+
for (const [k, d] of Object.entries(original)) if (d) Object.defineProperty(globalThis, k, d)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const tick = () => new Promise((r) => setTimeout(r, 10))
|
|
69
|
+
|
|
70
|
+
describe('before consent', () => {
|
|
71
|
+
it('an EU visitor makes no request to any platform', async () => {
|
|
72
|
+
const b = browser('Europe/Berlin')
|
|
73
|
+
const { startTags } = await import('./index')
|
|
74
|
+
startTags({ key: 'pk-x' })
|
|
75
|
+
await tick()
|
|
76
|
+
expect(b.requests).toHaveLength(1)
|
|
77
|
+
expect(b.requests[0]).toContain('/v1/project/tags')
|
|
78
|
+
expect(b.scripts).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('a US visitor is presumed to allow, and every tag loads', async () => {
|
|
82
|
+
const b = browser('America/New_York')
|
|
83
|
+
const { startTags } = await import('./index')
|
|
84
|
+
startTags({ key: 'pk-x' })
|
|
85
|
+
await tick()
|
|
86
|
+
expect(b.scripts.map((s) => new URL(s).hostname).sort()).toEqual(
|
|
87
|
+
['analytics.tiktok.com', 'connect.facebook.net', 'snap.licdn.com', 'static.ads-twitter.com', 'www.googletagmanager.com'].sort(),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('after accepting', () => {
|
|
93
|
+
it('loads Google, Meta, LinkedIn, X and TikTok with no reload', async () => {
|
|
94
|
+
const b = browser('Europe/Berlin')
|
|
95
|
+
const { startTags, acceptAll } = await import('./index')
|
|
96
|
+
startTags({ key: 'pk-x' })
|
|
97
|
+
await tick()
|
|
98
|
+
acceptAll()
|
|
99
|
+
await tick()
|
|
100
|
+
expect(b.scripts).toHaveLength(5)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('sets Consent Mode v2 denied before any Google script, then updates it', async () => {
|
|
104
|
+
const b = browser('Europe/Berlin')
|
|
105
|
+
const { startTags, acceptAll } = await import('./index')
|
|
106
|
+
startTags({ key: 'pk-x' })
|
|
107
|
+
await tick()
|
|
108
|
+
expect(b.scripts).toEqual([])
|
|
109
|
+
acceptAll()
|
|
110
|
+
await tick()
|
|
111
|
+
const layer = (g.window as { dataLayer: IArguments[] }).dataLayer.map((a) => Array.from(a))
|
|
112
|
+
const dflt = layer.findIndex((a) => a[0] === 'consent' && a[1] === 'default')
|
|
113
|
+
const js = layer.findIndex((a) => a[0] === 'js')
|
|
114
|
+
expect(dflt).toBeGreaterThanOrEqual(0)
|
|
115
|
+
expect(dflt).toBeLessThan(js)
|
|
116
|
+
expect(layer[dflt][2]).toMatchObject({ analytics_storage: 'denied', ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied' })
|
|
117
|
+
const update = layer.find((a) => a[0] === 'consent' && a[1] === 'update')
|
|
118
|
+
expect(update?.[2]).toMatchObject({ analytics_storage: 'granted', ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'granted' })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('analytics alone loads only Google, cookieless for ads', async () => {
|
|
122
|
+
const b = browser('Europe/Berlin')
|
|
123
|
+
const { startTags, saveConsent } = await import('./index')
|
|
124
|
+
startTags({ key: 'pk-x' })
|
|
125
|
+
await tick()
|
|
126
|
+
saveConsent({ analytics: true, marketing: false, ads: false })
|
|
127
|
+
await tick()
|
|
128
|
+
expect(b.scripts.map((s) => new URL(s).hostname)).toEqual(['www.googletagmanager.com'])
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('one event, every tag', () => {
|
|
133
|
+
it('sends purchase under one id to each platform, with the names the table gives', async () => {
|
|
134
|
+
browser('America/New_York')
|
|
135
|
+
const { startTags, track } = await import('./index')
|
|
136
|
+
const captured: Array<{ name: string; props: Record<string, unknown> }> = []
|
|
137
|
+
startTags({ key: 'pk-x' })
|
|
138
|
+
await tick()
|
|
139
|
+
const w = g.window as unknown as Record<string, unknown>
|
|
140
|
+
const gtag: unknown[][] = []
|
|
141
|
+
const fbq: unknown[][] = []
|
|
142
|
+
const ttq: unknown[][] = []
|
|
143
|
+
const twq: unknown[][] = []
|
|
144
|
+
const lintrk: unknown[][] = []
|
|
145
|
+
w.gtag = (...a: unknown[]) => gtag.push(a)
|
|
146
|
+
w.fbq = (...a: unknown[]) => fbq.push(a)
|
|
147
|
+
w.ttq = { track: (...a: unknown[]) => ttq.push(a) }
|
|
148
|
+
w.twq = (...a: unknown[]) => twq.push(a)
|
|
149
|
+
w.lintrk = (...a: unknown[]) => lintrk.push(a)
|
|
150
|
+
const items = [{ item_id: 'dev', item_name: 'Dev', price: 19, quantity: 1 }]
|
|
151
|
+
track({ capture: (name: string, props: Record<string, unknown>) => captured.push({ name, props }) } as never, 'order_completed', {
|
|
152
|
+
order_id: 'ord-1', value: 19, currency: 'USD', items,
|
|
153
|
+
})
|
|
154
|
+
await tick()
|
|
155
|
+
|
|
156
|
+
expect(captured).toHaveLength(1)
|
|
157
|
+
const id = captured[0].props.event_id
|
|
158
|
+
expect(id).toBe('id-1')
|
|
159
|
+
expect(String(captured[0].props.tags).split(',').sort()).toEqual(['ga', 'gads', 'linkedin', 'meta', 'tiktok', 'x'])
|
|
160
|
+
|
|
161
|
+
const ga = gtag.find((a) => a[0] === 'event' && a[1] === 'purchase')
|
|
162
|
+
expect(ga?.[2]).toMatchObject({ transaction_id: 'ord-1', event_id: id, items, value: 19 })
|
|
163
|
+
expect(gtag.find((a) => a[1] === 'conversion')?.[2]).toMatchObject({ send_to: 'AW-1/abc', value: 19 })
|
|
164
|
+
expect(fbq.map((a) => [a[0], a[1], (a[3] as { eventID: string }).eventID])).toEqual([
|
|
165
|
+
['track', 'Purchase', id],
|
|
166
|
+
['track', 'Subscribe', id],
|
|
167
|
+
])
|
|
168
|
+
expect(ttq[0][0]).toBe('CompletePayment')
|
|
169
|
+
expect(ttq[0][2]).toEqual({ event_id: id })
|
|
170
|
+
expect(twq[0][1]).toBe('tw-o1abc-buy')
|
|
171
|
+
expect(lintrk[0]).toEqual(['track', { conversion_id: 9876 }])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('a test order is debug traffic', async () => {
|
|
175
|
+
browser('America/New_York')
|
|
176
|
+
const { startTags, mirror } = await import('./index')
|
|
177
|
+
startTags({ key: 'pk-x' })
|
|
178
|
+
await tick()
|
|
179
|
+
const w = g.window as unknown as Record<string, unknown>
|
|
180
|
+
const gtag: unknown[][] = []
|
|
181
|
+
w.gtag = (...a: unknown[]) => gtag.push(a)
|
|
182
|
+
mirror('order_completed', { test: true, order_id: 'o', value: 19, items: [{ item_id: 'dev', price: 19 }] }, 'id-9', 'ga')
|
|
183
|
+
const sent = gtag.find((a) => a[1] === 'purchase')?.[2] as { value: number; items: Array<{ price: number }> }
|
|
184
|
+
expect(sent).toMatchObject({ debug_mode: true, value: 0 })
|
|
185
|
+
expect(sent.items[0].price).toBe(0)
|
|
186
|
+
})
|
|
187
|
+
})
|