@duffcloudservices/cms 0.12.0 → 0.13.1
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/README.md +244 -8
- package/dist/chunk-A5F4C72F.js +500 -0
- package/dist/chunk-A5F4C72F.js.map +1 -0
- package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
- package/dist/chunk-HVSF23P7.js.map +1 -0
- package/dist/editor/editorBridge.d.ts +13 -1
- package/dist/editor/editorBridge.js +75 -5
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
- package/dist/index.d.ts +365 -22
- package/dist/index.js +421 -21
- package/dist/index.js.map +1 -1
- package/dist/installSeoHead-kWQwObez.d.ts +627 -0
- package/dist/plugins/index.d.ts +90 -6
- package/dist/plugins/index.js +530 -49
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo/index.d.ts +763 -4
- package/dist/seo/index.js +2 -2
- package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
- package/package.json +17 -6
- package/src/components/DcsCallButton.test.ts +58 -0
- package/src/components/DcsCallButton.vue +19 -4
- package/src/components/LiteMediaEmbed.vue +3 -3
- package/src/components/ManagedImage.test.ts +34 -0
- package/src/components/ManagedImage.vue +5 -0
- package/src/components/PreviewRibbon.vue +25 -6
- package/src/components/raw-source-imports.test.ts +44 -0
- package/src/composables/useConversionTracking.test.ts +492 -0
- package/src/composables/useConversionTracking.ts +770 -0
- package/src/composables/useReleaseNotes.ts +7 -1
- package/src/composables/useSEO.applyHead.test.ts +150 -0
- package/src/composables/useSEO.ts +63 -17
- package/src/composables/useSiteVersion.ts +4 -1
- package/src/composables/useSiteVisitorSession.test.ts +56 -0
- package/src/composables/useSiteVisitorSession.ts +39 -3
- package/src/composables/useTextContent.ts +9 -1
- package/dist/chunk-DAYLLSEE.js +0 -3
- package/dist/chunk-DAYLLSEE.js.map +0 -1
- package/dist/chunk-F3EIWEZD.js.map +0 -1
- package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CONVERSION_CAPTURE_VERSION,
|
|
5
|
+
CONVERSION_INTERACTION_TYPES,
|
|
6
|
+
attachConversionSink,
|
|
7
|
+
classifyHref,
|
|
8
|
+
createConversionTracker,
|
|
9
|
+
hashTarget,
|
|
10
|
+
isConversionType,
|
|
11
|
+
redactHref,
|
|
12
|
+
type ConversionEvent,
|
|
13
|
+
} from './useConversionTracking'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* These tests protect three properties, in order of how expensive they are to lose:
|
|
17
|
+
*
|
|
18
|
+
* 1. Conversion events survive a DEFERRED telemetry SDK (fleet P3 interlock). A click
|
|
19
|
+
* captured before any sink exists must still arrive once one attaches.
|
|
20
|
+
* 2. Classification order is stable — booking beats same-origin and social, protocol
|
|
21
|
+
* beats everything. A silent reclassification turns booking revenue into `internal`.
|
|
22
|
+
* 3. Delegated capture catches links the site never hand-wired. This is the whole reason
|
|
23
|
+
* the module exists: KEPT's `BookPTAppointmentClicked` died on 2026-06-03 because one
|
|
24
|
+
* branch of the header had no handler, and nothing failed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const HOST = 'kineticenergypt.com'
|
|
28
|
+
|
|
29
|
+
function setHost(host: string) {
|
|
30
|
+
// jsdom's location is not writable; stub the pieces the module reads.
|
|
31
|
+
Object.defineProperty(window, 'location', {
|
|
32
|
+
configurable: true,
|
|
33
|
+
value: { hostname: host, pathname: '/' } as Location,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function anchor(html: string): HTMLAnchorElement {
|
|
38
|
+
document.body.innerHTML = html
|
|
39
|
+
const el = document.body.querySelector('a')
|
|
40
|
+
if (!el) throw new Error('fixture has no anchor')
|
|
41
|
+
return el
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// jsdom cannot navigate; without this every anchor click logs "Not implemented:
|
|
45
|
+
// navigation to another Document". Bubble-phase, so the tracker's capture-phase
|
|
46
|
+
// listener still sees the click exactly as a browser would.
|
|
47
|
+
const swallowNavigation = (event: Event) => event.preventDefault()
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
setHost(HOST)
|
|
51
|
+
document.body.innerHTML = ''
|
|
52
|
+
document.addEventListener('click', swallowNavigation)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
document.removeEventListener('click', swallowNavigation)
|
|
57
|
+
vi.restoreAllMocks()
|
|
58
|
+
document.body.innerHTML = ''
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('classifyHref — order is load-bearing', () => {
|
|
62
|
+
const opts = {
|
|
63
|
+
pageHost: HOST,
|
|
64
|
+
bookingHosts: ['stridethera.com', 'momence.com', 'vagaro.com'],
|
|
65
|
+
bookingPaths: ['/book'],
|
|
66
|
+
socialHosts: ['facebook.com', 'instagram.com'],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
it('protocol wins first: tel: and sms: are phone, mailto: is email', () => {
|
|
70
|
+
expect(classifyHref('tel:+19708798026', opts)).toBe('phone')
|
|
71
|
+
expect(classifyHref('sms:+19708798026', opts)).toBe('phone')
|
|
72
|
+
expect(classifyHref('mailto:frontdesk@kineticenergypt.com', opts)).toBe('email')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('booking vendors are booking, including subdomains', () => {
|
|
76
|
+
expect(
|
|
77
|
+
classifyHref('https://app.stridethera.com/patient-scheduling/1feed7e0', opts),
|
|
78
|
+
).toBe('booking')
|
|
79
|
+
expect(classifyHref('https://www.vagaro.com/us04/justposhesthetics/services', opts)).toBe(
|
|
80
|
+
'booking',
|
|
81
|
+
)
|
|
82
|
+
expect(classifyHref('https://momence.com/appointments/x', opts)).toBe('booking')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('booking beats same-origin: a self-hosted /book path is revenue, not internal', () => {
|
|
86
|
+
expect(classifyHref('/book/pt', opts)).toBe('booking')
|
|
87
|
+
expect(classifyHref('/gym', opts)).toBe('internal')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('social, external and internal fall out in that order', () => {
|
|
91
|
+
expect(classifyHref('https://www.instagram.com/kinetic_energy_pt', opts)).toBe('social')
|
|
92
|
+
expect(classifyHref('https://example.com/whatever', opts)).toBe('external')
|
|
93
|
+
expect(classifyHref(`https://${HOST}/staff`, opts)).toBe('internal')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('non-navigational anchors are buttons, not conversions', () => {
|
|
97
|
+
expect(classifyHref('#', opts)).toBe('button')
|
|
98
|
+
expect(classifyHref('javascript:void(0)', opts)).toBe('button')
|
|
99
|
+
expect(classifyHref('', opts)).toBe('button')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('capture — delegated, so un-wired links are still counted', () => {
|
|
104
|
+
it('captures a booking link that has no click handler of its own', () => {
|
|
105
|
+
const events: ConversionEvent[] = []
|
|
106
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
107
|
+
tracker.start()
|
|
108
|
+
|
|
109
|
+
// Deliberately mirrors the KEPT header CTA that lost its handler.
|
|
110
|
+
const el = anchor(
|
|
111
|
+
`<a href="https://app.stridethera.com/patient-scheduling/1feed7e0" target="_blank">Book Online</a>`,
|
|
112
|
+
)
|
|
113
|
+
el.click()
|
|
114
|
+
|
|
115
|
+
expect(events).toHaveLength(1)
|
|
116
|
+
expect(events[0].name).toBe('site_interaction')
|
|
117
|
+
expect(events[0].properties.interaction_type).toBe('booking')
|
|
118
|
+
expect(events[0].properties.label).toBe('Book Online')
|
|
119
|
+
expect(events[0].properties.href_host).toBe('app.stridethera.com')
|
|
120
|
+
expect(events[0].properties.capture_version).toBe(CONVERSION_CAPTURE_VERSION)
|
|
121
|
+
|
|
122
|
+
tracker.stop()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('captures a tel: tap — the signal no DCS site measured before', () => {
|
|
126
|
+
const events: ConversionEvent[] = []
|
|
127
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
128
|
+
tracker.start()
|
|
129
|
+
|
|
130
|
+
anchor(`<a href="tel:+19708798026">(970) 879-8026</a>`).click()
|
|
131
|
+
|
|
132
|
+
expect(events).toHaveLength(1)
|
|
133
|
+
expect(events[0].properties.interaction_type).toBe('phone')
|
|
134
|
+
tracker.stop()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('captures clicks on a child element of the link', () => {
|
|
138
|
+
const events: ConversionEvent[] = []
|
|
139
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
140
|
+
tracker.start()
|
|
141
|
+
|
|
142
|
+
document.body.innerHTML = `<a href="https://www.vagaro.com/us04/x/services"><span id="inner">Book an Appointment</span></a>`
|
|
143
|
+
;(document.getElementById('inner') as HTMLElement).click()
|
|
144
|
+
|
|
145
|
+
expect(events).toHaveLength(1)
|
|
146
|
+
expect(events[0].properties.interaction_type).toBe('booking')
|
|
147
|
+
tracker.stop()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('strips query and fragment from the destination', () => {
|
|
151
|
+
const events: ConversionEvent[] = []
|
|
152
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
153
|
+
tracker.start()
|
|
154
|
+
|
|
155
|
+
anchor(
|
|
156
|
+
`<a href="https://www.vagaro.com/services?email=someone%40example.com&name=Jane#top">Book</a>`,
|
|
157
|
+
).click()
|
|
158
|
+
|
|
159
|
+
expect(events[0].properties.href).not.toContain('example.com')
|
|
160
|
+
expect(events[0].properties.href).not.toContain('?')
|
|
161
|
+
expect(events[0].properties.href).not.toContain('#')
|
|
162
|
+
tracker.stop()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('ignores clicks that are not on a link or button', () => {
|
|
166
|
+
const events: ConversionEvent[] = []
|
|
167
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
168
|
+
tracker.start()
|
|
169
|
+
|
|
170
|
+
document.body.innerHTML = `<p id="text">just prose</p>`
|
|
171
|
+
;(document.getElementById('text') as HTMLElement).click()
|
|
172
|
+
|
|
173
|
+
expect(events).toHaveLength(0)
|
|
174
|
+
tracker.stop()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('collapses a duplicate fire of the same click', () => {
|
|
178
|
+
const events: ConversionEvent[] = []
|
|
179
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
180
|
+
tracker.start()
|
|
181
|
+
|
|
182
|
+
const el = anchor(`<a href="tel:+19708798026">Call</a>`)
|
|
183
|
+
el.click()
|
|
184
|
+
el.click()
|
|
185
|
+
|
|
186
|
+
expect(events).toHaveLength(1)
|
|
187
|
+
tracker.stop()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('a throwing sink never breaks the page', () => {
|
|
191
|
+
const tracker = createConversionTracker({
|
|
192
|
+
sink: () => {
|
|
193
|
+
throw new Error('transport down')
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
tracker.start()
|
|
197
|
+
|
|
198
|
+
expect(() => anchor(`<a href="tel:+1">Call</a>`).click()).not.toThrow()
|
|
199
|
+
tracker.stop()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('INTERLOCK — survives a deferred sink (fleet P3, App Insights deferral)', () => {
|
|
204
|
+
it('buffers clicks captured before any sink exists, then flushes on attach', () => {
|
|
205
|
+
const tracker = createConversionTracker() // no sink: SDK not loaded yet
|
|
206
|
+
tracker.start()
|
|
207
|
+
|
|
208
|
+
anchor(`<a href="https://www.vagaro.com/a/services">Book an Appointment</a>`).click()
|
|
209
|
+
anchor(`<a href="tel:+12488815213">(248) 881-5213</a>`).click()
|
|
210
|
+
|
|
211
|
+
// Nothing has been delivered — but nothing has been lost either.
|
|
212
|
+
expect(tracker.buffered()).toHaveLength(2)
|
|
213
|
+
|
|
214
|
+
const events: ConversionEvent[] = []
|
|
215
|
+
tracker.attachSink((e) => events.push(e))
|
|
216
|
+
|
|
217
|
+
expect(events.map((e) => e.properties.interaction_type)).toEqual(['booking', 'phone'])
|
|
218
|
+
expect(tracker.buffered()).toHaveLength(0)
|
|
219
|
+
|
|
220
|
+
// And it stays live afterwards.
|
|
221
|
+
anchor(`<a href="https://www.vagaro.com/b/services">Book</a>`).click()
|
|
222
|
+
expect(events).toHaveLength(3)
|
|
223
|
+
|
|
224
|
+
tracker.stop()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('attachConversionSink reaches a tracker the loader has no reference to', () => {
|
|
228
|
+
const tracker = createConversionTracker()
|
|
229
|
+
tracker.start()
|
|
230
|
+
|
|
231
|
+
anchor(`<a href="https://www.vagaro.com/a/services">Book</a>`).click()
|
|
232
|
+
|
|
233
|
+
const events: ConversionEvent[] = []
|
|
234
|
+
const reached = attachConversionSink((e) => events.push(e))
|
|
235
|
+
|
|
236
|
+
expect(reached).toBeGreaterThanOrEqual(1)
|
|
237
|
+
expect(events).toHaveLength(1)
|
|
238
|
+
|
|
239
|
+
tracker.stop()
|
|
240
|
+
// stop() deregisters, so a later attach must not resurrect it.
|
|
241
|
+
expect(attachConversionSink(() => {})).toBe(0)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('the buffer is bounded, so a click-spamming bot cannot grow memory', () => {
|
|
245
|
+
const tracker = createConversionTracker({ bufferLimit: 3 })
|
|
246
|
+
tracker.start()
|
|
247
|
+
|
|
248
|
+
for (let i = 0; i < 10; i += 1) {
|
|
249
|
+
anchor(`<a href="https://www.vagaro.com/${i}/services">Book ${i}</a>`).click()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
expect(tracker.buffered()).toHaveLength(3)
|
|
253
|
+
// Oldest dropped, newest kept.
|
|
254
|
+
expect(tracker.buffered()[2].properties.label).toBe('Book 9')
|
|
255
|
+
tracker.stop()
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe('configuration', () => {
|
|
260
|
+
it('honours a custom event name (canonical dcs_conversion)', () => {
|
|
261
|
+
const events: ConversionEvent[] = []
|
|
262
|
+
const tracker = createConversionTracker({
|
|
263
|
+
eventName: 'dcs_conversion',
|
|
264
|
+
sink: (e) => events.push(e),
|
|
265
|
+
})
|
|
266
|
+
tracker.start()
|
|
267
|
+
|
|
268
|
+
anchor(`<a href="tel:+1">Call</a>`).click()
|
|
269
|
+
|
|
270
|
+
expect(events[0].name).toBe('dcs_conversion')
|
|
271
|
+
tracker.stop()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('accepts per-site booking hosts on top of the fleet defaults', () => {
|
|
275
|
+
const events: ConversionEvent[] = []
|
|
276
|
+
const tracker = createConversionTracker({
|
|
277
|
+
bookingHosts: ['bookme.example'],
|
|
278
|
+
sink: (e) => events.push(e),
|
|
279
|
+
})
|
|
280
|
+
tracker.start()
|
|
281
|
+
|
|
282
|
+
anchor(`<a href="https://bookme.example/slot">Reserve</a>`).click()
|
|
283
|
+
|
|
284
|
+
expect(events[0].properties.interaction_type).toBe('booking')
|
|
285
|
+
tracker.stop()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('stop() unbinds — no events after teardown', () => {
|
|
289
|
+
const events: ConversionEvent[] = []
|
|
290
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
291
|
+
tracker.start()
|
|
292
|
+
tracker.stop()
|
|
293
|
+
|
|
294
|
+
anchor(`<a href="tel:+1">Call</a>`).click()
|
|
295
|
+
|
|
296
|
+
expect(events).toHaveLength(0)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe('managed + bespoke form submits (C-326)', () => {
|
|
301
|
+
function form(html: string): HTMLFormElement {
|
|
302
|
+
document.body.innerHTML = html
|
|
303
|
+
const el = document.body.querySelector('form')
|
|
304
|
+
if (!el) throw new Error('fixture has no form')
|
|
305
|
+
return el
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
it('counts a submitted form as a conversion', () => {
|
|
309
|
+
const events: ConversionEvent[] = []
|
|
310
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
311
|
+
tracker.start()
|
|
312
|
+
|
|
313
|
+
form(
|
|
314
|
+
`<form><input name="email" /><button type="submit">Request a Consultation</button></form>`,
|
|
315
|
+
).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
316
|
+
|
|
317
|
+
expect(events).toHaveLength(1)
|
|
318
|
+
expect(events[0].properties.interaction_type).toBe('form_submit')
|
|
319
|
+
expect(events[0].properties.is_conversion).toBe('true')
|
|
320
|
+
expect(events[0].properties.label).toBe('Request a Consultation')
|
|
321
|
+
tracker.stop()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('DOES NOT count the click on the submit button — only the submit that follows', () => {
|
|
325
|
+
const events: ConversionEvent[] = []
|
|
326
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
327
|
+
tracker.start()
|
|
328
|
+
|
|
329
|
+
const f = form(`<form><button type="submit">Send</button></form>`)
|
|
330
|
+
const button = f.querySelector('button') as HTMLElement
|
|
331
|
+
|
|
332
|
+
// A real browser fires click, then submit. Counting both would make every lead two.
|
|
333
|
+
button.click()
|
|
334
|
+
f.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
335
|
+
|
|
336
|
+
expect(events).toHaveLength(1)
|
|
337
|
+
expect(events[0].properties.interaction_type).toBe('form_submit')
|
|
338
|
+
tracker.stop()
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('a click that never becomes a submit (validation failed) is not a conversion', () => {
|
|
342
|
+
const events: ConversionEvent[] = []
|
|
343
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
344
|
+
tracker.start()
|
|
345
|
+
|
|
346
|
+
// HTML default: a typeless <button> inside a form IS a submit control — the shape
|
|
347
|
+
// every <DcsForm> renders. Clicking it with the form invalid submits nothing.
|
|
348
|
+
const f = form(`<form><button>Send</button></form>`)
|
|
349
|
+
;(f.querySelector('button') as HTMLElement).click()
|
|
350
|
+
|
|
351
|
+
expect(events).toHaveLength(0)
|
|
352
|
+
tracker.stop()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('a non-submit button inside a form is still an ordinary button click', () => {
|
|
356
|
+
const events: ConversionEvent[] = []
|
|
357
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
358
|
+
tracker.start()
|
|
359
|
+
|
|
360
|
+
const f = form(`<form><button type="button">Add another photo</button></form>`)
|
|
361
|
+
;(f.querySelector('button') as HTMLElement).click()
|
|
362
|
+
|
|
363
|
+
expect(events).toHaveLength(1)
|
|
364
|
+
expect(events[0].properties.interaction_type).toBe('button')
|
|
365
|
+
expect(events[0].properties.is_conversion).toBe('false')
|
|
366
|
+
tracker.stop()
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('captureFormSubmits: false restores the click-only behaviour', () => {
|
|
370
|
+
const events: ConversionEvent[] = []
|
|
371
|
+
const tracker = createConversionTracker({
|
|
372
|
+
captureFormSubmits: false,
|
|
373
|
+
sink: (e) => events.push(e),
|
|
374
|
+
})
|
|
375
|
+
tracker.start()
|
|
376
|
+
|
|
377
|
+
const f = form(`<form><button type="submit">Send</button></form>`)
|
|
378
|
+
;(f.querySelector('button') as HTMLElement).click()
|
|
379
|
+
f.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
380
|
+
|
|
381
|
+
expect(events).toHaveLength(1)
|
|
382
|
+
expect(events[0].properties.interaction_type).toBe('button')
|
|
383
|
+
tracker.stop()
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
describe('DOUBLE-COUNT RAIL — one delegated listener per document', () => {
|
|
388
|
+
it('a second start() on the same document REFUSES and warns', () => {
|
|
389
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
390
|
+
const first: ConversionEvent[] = []
|
|
391
|
+
const second: ConversionEvent[] = []
|
|
392
|
+
|
|
393
|
+
const a = createConversionTracker({ sink: (e) => first.push(e) })
|
|
394
|
+
const b = createConversionTracker({ sink: (e) => second.push(e) })
|
|
395
|
+
a.start()
|
|
396
|
+
b.start()
|
|
397
|
+
|
|
398
|
+
anchor(`<a href="tel:+12483852926">Call</a>`).click()
|
|
399
|
+
|
|
400
|
+
// One click, one event. The refused tracker heard nothing.
|
|
401
|
+
expect(first).toHaveLength(1)
|
|
402
|
+
expect(second).toHaveLength(0)
|
|
403
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('REFUSED'))
|
|
404
|
+
|
|
405
|
+
a.stop()
|
|
406
|
+
b.stop()
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('after stop(), the document is free again', () => {
|
|
410
|
+
const a = createConversionTracker()
|
|
411
|
+
a.start()
|
|
412
|
+
a.stop()
|
|
413
|
+
|
|
414
|
+
const events: ConversionEvent[] = []
|
|
415
|
+
const b = createConversionTracker({ sink: (e) => events.push(e) })
|
|
416
|
+
b.start()
|
|
417
|
+
|
|
418
|
+
anchor(`<a href="tel:+1">Call</a>`).click()
|
|
419
|
+
expect(events).toHaveLength(1)
|
|
420
|
+
b.stop()
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('{ force: true } is the documented escape hatch and it really does double', () => {
|
|
424
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
425
|
+
const events: ConversionEvent[] = []
|
|
426
|
+
|
|
427
|
+
const a = createConversionTracker({ sink: (e) => events.push(e) })
|
|
428
|
+
const b = createConversionTracker({ force: true, sink: (e) => events.push(e) })
|
|
429
|
+
a.start()
|
|
430
|
+
b.start()
|
|
431
|
+
|
|
432
|
+
anchor(`<a href="tel:+1">Call</a>`).click()
|
|
433
|
+
|
|
434
|
+
// Recorded deliberately: the hatch is real, and this is the damage it does.
|
|
435
|
+
expect(events).toHaveLength(2)
|
|
436
|
+
expect(warn).not.toHaveBeenCalled()
|
|
437
|
+
a.stop()
|
|
438
|
+
b.stop()
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
describe('privacy — Do Not Track and redaction', () => {
|
|
443
|
+
it('binds nothing at all under DNT', () => {
|
|
444
|
+
Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: '1' })
|
|
445
|
+
const events: ConversionEvent[] = []
|
|
446
|
+
const tracker = createConversionTracker({ sink: (e) => events.push(e) })
|
|
447
|
+
tracker.start()
|
|
448
|
+
|
|
449
|
+
anchor(`<a href="tel:+12483852926">Call</a>`).click()
|
|
450
|
+
|
|
451
|
+
expect(events).toHaveLength(0)
|
|
452
|
+
expect(tracker.buffered()).toHaveLength(0)
|
|
453
|
+
Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: undefined })
|
|
454
|
+
tracker.stop()
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('respectDoNotTrack: false is honoured for first-party-only deployments', () => {
|
|
458
|
+
Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: '1' })
|
|
459
|
+
const events: ConversionEvent[] = []
|
|
460
|
+
const tracker = createConversionTracker({
|
|
461
|
+
respectDoNotTrack: false,
|
|
462
|
+
sink: (e) => events.push(e),
|
|
463
|
+
})
|
|
464
|
+
tracker.start()
|
|
465
|
+
|
|
466
|
+
anchor(`<a href="tel:+12483852926">Call</a>`).click()
|
|
467
|
+
|
|
468
|
+
expect(events).toHaveLength(1)
|
|
469
|
+
Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: undefined })
|
|
470
|
+
tracker.stop()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('redactHref leaves non-contact URLs intact but strips contact targets', () => {
|
|
474
|
+
expect(redactHref('https://www.vagaro.com/x/services?e=a@b.com', HOST)).toEqual({
|
|
475
|
+
href: 'https://www.vagaro.com/x/services',
|
|
476
|
+
scheme: 'https:',
|
|
477
|
+
hash: '',
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
const tel = redactHref('tel:+12483852926', HOST)
|
|
481
|
+
expect(tel.scheme).toBe('tel:')
|
|
482
|
+
expect(tel.href).not.toContain('2483852926')
|
|
483
|
+
expect(tel.hash).toBe(hashTarget('+12483852926'))
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('classifies and flags every conversion type', () => {
|
|
487
|
+
expect(CONVERSION_INTERACTION_TYPES).toEqual(['booking', 'phone', 'email', 'form_submit'])
|
|
488
|
+
expect(isConversionType('phone')).toBe(true)
|
|
489
|
+
expect(isConversionType('social')).toBe(false)
|
|
490
|
+
expect(isConversionType('internal')).toBe(false)
|
|
491
|
+
})
|
|
492
|
+
})
|