Files
ComfyUI_frontend/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts
Christian Byrne 04aee0308b feat: add SHA-256 hashed email to GTM dataLayer for sign_up/login events (#10591)
## Summary

Adds SHA-256 hashed user email to GTM dataLayer `sign_up` and `login`
events to improve Meta/LinkedIn Conversions API (CAPI) match rate via
Stape server-side tracking.

## Privacy

- Email is SHA-256 hashed client-side before being pushed to dataLayer —
the raw email never enters the analytics pipeline.
- Email is normalized (trimmed + lowercased) before hashing per
Google/Meta requirements.
- If email is absent (e.g., GitHub OAuth without public email), no
`user_data` entry is pushed.

## Testing

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10591-feat-add-SHA-256-hashed-email-to-GTM-dataLayer-for-sign_up-login-events-3306d73d36508148a321d62810698013)
by [Unito](https://www.unito.io)
2026-03-27 19:18:05 -07:00

279 lines
8.4 KiB
TypeScript

import { beforeEach, describe, expect, it } from 'vitest'
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
function createInitializedProvider(): GtmTelemetryProvider {
window.__CONFIG__ = { gtm_container_id: 'GTM-TEST123' }
return new GtmTelemetryProvider()
}
function lastDataLayerEntry(): Record<string, unknown> | undefined {
const dl = window.dataLayer as unknown[] | undefined
return dl?.[dl.length - 1] as Record<string, unknown> | undefined
}
describe('GtmTelemetryProvider', () => {
beforeEach(() => {
window.__CONFIG__ = {}
window.dataLayer = undefined
window.gtag = undefined
document.head.innerHTML = ''
})
it('injects the GTM runtime script', () => {
window.__CONFIG__ = {
gtm_container_id: 'GTM-TEST123'
}
new GtmTelemetryProvider()
const gtmScript = document.querySelector(
'script[src="https://www.googletagmanager.com/gtm.js?id=GTM-TEST123"]'
)
expect(gtmScript).not.toBeNull()
expect(window.dataLayer?.[0]).toMatchObject({
event: 'gtm.js'
})
})
it('bootstraps gtag when a GA measurement id exists', () => {
window.__CONFIG__ = {
ga_measurement_id: 'G-TEST123'
}
new GtmTelemetryProvider()
const gtagScript = document.querySelector(
'script[src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"]'
)
const dataLayer = window.dataLayer as unknown[]
expect(gtagScript).not.toBeNull()
expect(typeof window.gtag).toBe('function')
expect(dataLayer).toHaveLength(2)
expect(Array.from(dataLayer[0] as IArguments)[0]).toBe('js')
expect(Array.from(dataLayer[1] as IArguments)).toEqual([
'config',
'G-TEST123',
{
send_page_view: false
}
])
})
it('does not inject duplicate gtag scripts across repeated init', () => {
window.__CONFIG__ = {
ga_measurement_id: 'G-TEST123'
}
new GtmTelemetryProvider()
new GtmTelemetryProvider()
const gtagScripts = document.querySelectorAll(
'script[src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"]'
)
expect(gtagScripts).toHaveLength(1)
})
describe('event dispatch', () => {
it('pushes subscription modal as view_promotion', () => {
const provider = createInitializedProvider()
provider.trackSubscription('modal_opened')
expect(lastDataLayerEntry()).toMatchObject({ event: 'view_promotion' })
})
it('pushes subscribe click as select_promotion', () => {
const provider = createInitializedProvider()
provider.trackSubscription('subscribe_clicked')
expect(lastDataLayerEntry()).toMatchObject({ event: 'select_promotion' })
})
it('pushes subscription_success for subscription activation', () => {
const provider = createInitializedProvider()
provider.trackMonthlySubscriptionSucceeded()
expect(lastDataLayerEntry()).toMatchObject({
event: 'subscription_success'
})
})
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
provider.trackRunButton({ trigger_source: 'button' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'run_workflow',
trigger_source: 'button',
subscribe_to_run: false
})
})
it('pushes execution_error with truncated error', () => {
const provider = createInitializedProvider()
const longError = 'x'.repeat(200)
provider.trackExecutionError({
jobId: 'job-1',
nodeType: 'KSampler',
error: longError
})
const entry = lastDataLayerEntry()
expect(entry).toMatchObject({
event: 'execution_error',
node_type: 'KSampler'
})
expect((entry?.error as string).length).toBe(100)
})
it('pushes select_content for template events', () => {
const provider = createInitializedProvider()
provider.trackTemplate({
workflow_name: 'flux-dev',
template_category: 'image-gen'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'select_content',
content_type: 'template',
workflow_name: 'flux-dev',
template_category: 'image-gen'
})
})
it('pushes survey_opened for survey opened stage', () => {
const provider = createInitializedProvider()
provider.trackSurvey('opened')
expect(lastDataLayerEntry()).toMatchObject({ event: 'survey_opened' })
})
it('pushes survey_submitted with responses', () => {
const provider = createInitializedProvider()
provider.trackSurvey('submitted', {
familiarity: 'expert',
industry: 'gaming'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'survey_submitted',
familiarity: 'expert',
industry: 'gaming'
})
})
it('pushes email_verify_opened for opened stage', () => {
const provider = createInitializedProvider()
provider.trackEmailVerification('opened')
expect(lastDataLayerEntry()).toMatchObject({
event: 'email_verify_opened'
})
})
it('pushes email_verify_completed for completed stage', () => {
const provider = createInitializedProvider()
provider.trackEmailVerification('completed')
expect(lastDataLayerEntry()).toMatchObject({
event: 'email_verify_completed'
})
})
it('pushes search for node search (GA4 recommended)', () => {
const provider = createInitializedProvider()
provider.trackNodeSearch({ query: 'KSampler' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'search',
search_term: 'KSampler'
})
})
it('pushes select_item for node search result (GA4 recommended)', () => {
const provider = createInitializedProvider()
provider.trackNodeSearchResultSelected({
node_type: 'KSampler',
last_query: 'sampler'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'select_item',
item_id: 'KSampler',
search_term: 'sampler'
})
})
it('pushes setting_changed with setting_id', () => {
const provider = createInitializedProvider()
provider.trackSettingChanged({ setting_id: 'theme' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'setting_changed',
setting_id: 'theme'
})
})
it('pushes workflow_created with metadata', () => {
const provider = createInitializedProvider()
provider.trackWorkflowCreated({
workflow_type: 'blank',
previous_workflow_had_nodes: true
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'workflow_created',
workflow_type: 'blank',
previous_workflow_had_nodes: true
})
})
it('pushes share_flow with step and source', () => {
const provider = createInitializedProvider()
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'share_flow',
step: 'link_copied',
source: 'app_mode'
})
})
it('pushes normalized email as user_data before auth event', () => {
const provider = createInitializedProvider()
provider.trackAuth({
method: 'email',
is_new_user: true,
user_id: 'uid-123',
email: ' Test@Example.com '
})
const dl = window.dataLayer as Record<string, unknown>[]
const userData = dl.find((entry) => 'user_data' in entry)
expect(userData).toMatchObject({
user_data: { email: 'test@example.com' }
})
// Verify user_data is pushed before the sign_up event
const userDataIndex = dl.findIndex((entry) => 'user_data' in entry)
const signUpIndex = dl.findIndex(
(entry) => (entry as Record<string, unknown>).event === 'sign_up'
)
expect(userDataIndex).toBeLessThan(signUpIndex)
})
it('does not push user_data when email is absent', () => {
const provider = createInitializedProvider()
provider.trackAuth({
method: 'google',
is_new_user: false,
user_id: 'uid-456'
})
const dl = window.dataLayer as Record<string, unknown>[]
const userData = dl.find((entry) => 'user_data' in entry)
expect(userData).toBeUndefined()
})
it('does not push events when not initialized', () => {
window.__CONFIG__ = {}
const provider = new GtmTelemetryProvider()
provider.trackSubscription('modal_opened')
expect(window.dataLayer).toBeUndefined()
})
})
})