Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions src/renderer/src/customerIo/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CustomerIoSession } from '../../../shared/customerIo'
import { createMessagingClientLoader } from './adapter'
import { createMessagingController } from './controller'

const mocks = vi.hoisted(() => ({
load: vi.fn(),
pluginIdentify: vi.fn(),
analytics: {
addSourceMiddleware: vi.fn(),
register: vi.fn(),
identify: vi.fn(),
page: vi.fn(),
reset: vi.fn()
},
gist: {
currentMessages: [],
clearUserToken: vi.fn(),
setUserLocale: vi.fn(),
setCurrentRoute: vi.fn(),
dismissMessage: vi.fn()
}
}))
vi.mock('@customerio/cdp-analytics-browser', () => ({
AnalyticsBrowser: { load: mocks.load },
InAppPlugin: () => ({ identify: mocks.pluginIdentify })
}))
vi.mock('customerio-gist-web', () => ({ default: mocks.gist }))

const session: CustomerIoSession = {
userId: 'user-a',
locale: 'en',
writeKey: 'test',
siteId: 'test',
page: 'desktop/comfyui'
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}

beforeEach(() => {
vi.resetAllMocks()
mocks.load.mockReturnValue(mocks.analytics)
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({ integrations: { 'Customer.io Data Pipelines': { apiKey: 'test' } } })
)
)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
})

describe('Desktop SDK adapter ownership', () => {
it('recovers from an offline settings fetch before constructing one SDK', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('offline'))
const report = vi.fn()
const controller = createMessagingController(createMessagingClientLoader(vi.fn()), report)
await controller.update(session)
expect(mocks.load).not.toHaveBeenCalled()
expect(report).toHaveBeenCalledOnce()
await controller.update(session)
await controller.update(session)
expect(mocks.load).toHaveBeenCalledOnce()
expect(mocks.load.mock.calls[0]![0]).toMatchObject({
cdnSettings: { integrations: { 'Customer.io Data Pipelines': { apiKey: 'test' } } }
})
expect(mocks.analytics.identify).toHaveBeenCalledExactlyOnceWith('user-a', { locale: 'en' })
expect(mocks.analytics.page).toHaveBeenCalledOnce()
})

it('aborts an unavailable settings request so the next activation can recover', async () => {
vi.useFakeTimers()
let signal!: AbortSignal
vi.mocked(fetch).mockImplementationOnce((_url, options) => {
signal = options!.signal!
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
})
})
const controller = createMessagingController(createMessagingClientLoader(vi.fn()), vi.fn())
const first = controller.update(session)
await vi.advanceTimersByTimeAsync(10_000)
await first
expect(signal.aborted).toBe(true)
expect(mocks.load).not.toHaveBeenCalled()
await controller.update(session)
expect(mocks.load).toHaveBeenCalledOnce()
expect(mocks.analytics.page).toHaveBeenCalledOnce()
})

it('retains pending SDK initialization across timeout and retry', async () => {
vi.useFakeTimers()
const readiness = deferred<void>()
mocks.load.mockReturnValue({
...mocks.analytics,
then: readiness.promise.then.bind(readiness.promise)
})
const controller = createMessagingController(createMessagingClientLoader(vi.fn()), vi.fn())
const first = controller.update(session)
await vi.advanceTimersByTimeAsync(10_000)
await first
const retry = controller.update(session)
await vi.advanceTimersByTimeAsync(0)
expect(mocks.load).toHaveBeenCalledOnce()
expect(mocks.analytics.addSourceMiddleware).not.toHaveBeenCalled()
readiness.resolve()
await retry
expect(mocks.analytics.register).toHaveBeenCalledOnce()
expect(mocks.analytics.identify).toHaveBeenCalledOnce()
})

it('quarantines a rejected SDK initialization instead of creating another partial client', async () => {
mocks.load.mockImplementation(() => ({
...mocks.analytics,
then: (_resolve: unknown, reject: (error: Error) => void) => reject(new Error('SDK failed'))
}))
const report = vi.fn()
const controller = createMessagingController(createMessagingClientLoader(vi.fn()), report)
await controller.update(session)
await controller.update(session)
expect(mocks.load).toHaveBeenCalledOnce()
expect(mocks.analytics.addSourceMiddleware).not.toHaveBeenCalled()
expect(mocks.analytics.identify).not.toHaveBeenCalled()
expect(report).toHaveBeenCalledTimes(2)
})

it('rejects revoked identity and route callbacks at the in-app plugin', async () => {
let current: CustomerIoSession | null = session
await createMessagingClientLoader(vi.fn())(session, () => current)
const plugin = mocks.analytics.register.mock.calls[0]![0]
const oldEvent = { event: { userId: session.userId, name: session.page } }
current = null
await plugin.identify(oldEvent)
await plugin.page(oldEvent)
current = { ...session, userId: 'user-b' }
await plugin.identify(oldEvent)
await plugin.page(oldEvent)
expect(mocks.pluginIdentify).not.toHaveBeenCalled()
expect(mocks.gist.setCurrentRoute).not.toHaveBeenCalled()
const newEvent = { event: { userId: current.userId, name: current.page } }
await plugin.identify(newEvent)
expect(mocks.pluginIdentify).toHaveBeenCalledExactlyOnceWith(newEvent)
const route = deferred<void>()
mocks.gist.setCurrentRoute.mockReturnValue(route.promise)
const done = vi.fn()
const changing = plugin.page(newEvent).then(done)
await Promise.resolve()
expect(done).not.toHaveBeenCalled()
route.resolve()
await changing
})

it('retries an identity skipped while offline on the next online activation', async () => {
const controller = createMessagingController(createMessagingClientLoader(vi.fn()), vi.fn())
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
await controller.update(session)
expect(mocks.analytics.identify).not.toHaveBeenCalled()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
await controller.update(session)
expect(mocks.load).toHaveBeenCalledOnce()
expect(mocks.analytics.identify).toHaveBeenCalledOnce()
expect(mocks.analytics.page).toHaveBeenCalledOnce()
})
})
157 changes: 157 additions & 0 deletions src/renderer/src/customerIo/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {
AnalyticsBrowser,
InAppPlugin,
type LegacySettings
} from '@customerio/cdp-analytics-browser'
import Gist from 'customerio-gist-web'
import type { CustomerIoSession } from '../../../shared/customerIo'
import type { MessagingClient } from './controller'

/** Retry network setup before constructing the document's one SDK instance. */
export function createMessagingClientLoader(openLink: (action: string) => void) {
let initialization: Promise<MessagingClient> | undefined
return async (session: CustomerIoSession, currentSession: () => CustomerIoSession | null) => {
if (!initialization) {
const abort = new AbortController()
const timer = setTimeout(() => abort.abort(), 10_000)
try {
const response = await fetch(
`https://cdp.customer.io/v1/projects/${encodeURIComponent(session.writeKey)}/settings`,
{ signal: abort.signal }
)
if (!response.ok) throw new Error('Customer.io settings are unavailable')
const settings = (await response.json()) as LegacySettings
// Retain this promise even on failure: a partially initialized SDK cannot
// be discarded while its listeners, buffers, or plugins are still alive.
initialization ??= initialize(session, currentSession, settings, openLink)
} finally {
clearTimeout(timer)
}
}
return initialization
}
}

async function initialize(
session: CustomerIoSession,
currentSession: () => CustomerIoSession | null,
cdnSettings: LegacySettings,
openLink: (action: string) => void
): Promise<MessagingClient> {
const analytics = AnalyticsBrowser.load(
{ writeKey: session.writeKey, cdnSettings: { ...cdnSettings } },
{
user: { persist: false },
group: { persist: false },
retryQueue: false,
disableClientPersistence: true,
integrations: {
All: false,
'Customer.io Data Pipelines': true,
// Register below with Desktop's lifecycle callbacks, even if the source
// settings also enable automatic in-app initialization.
'Customer.io In-App Plugin': { enabled: false }
}
}
)
// Await the loader itself: buffered method promises never reject if loading fails.
await analytics
await analytics.addSourceMiddleware(({ payload, next }) => {
const current = currentSession()
if (!current || payload.obj.userId !== current.userId) return
if (payload.obj.type === 'page' && payload.obj.name !== current.page) return
// A desktop file path or local workflow URL is not a useful campaign page.
payload.obj.context = {
...payload.obj.context,
page: {
path: current.page,
url: current.page,
title: 'ComfyUI Desktop',
referrer: '',
search: ''
}
}
next(payload)
})
// The SDK also writes this flag from visibilitychange. Keep Desktop's
// consent/focus gate authoritative when Chromium changes visibility later.
Object.defineProperty(Gist, 'isDocumentVisible', {
configurable: true,
get: () => currentSession() !== null && document.visibilityState !== 'hidden',
set: () => {}
})
const plugin = InAppPlugin({
siteId: session.siteId,
anonymousInApp: false,
_env: undefined,
_logging: undefined,
colorScheme: 'auto',
events: (event) => {
if (event.type !== 'in-app:message-action' || !currentSession()) return
const detail = (event as CustomEvent<{ actionValue?: string }>).detail
if (detail?.actionValue) openLink(detail.actionValue)
}
})
const identify = plugin.identify!
plugin.identify = (context) => {
const current = currentSession()
if (!current || context.event.userId !== current.userId) return context
return identify(context)
}
// The vendor page hook starts a route change without awaiting it. Keep that
// operation under the same owner as identify/reset, with a fresh session gate.
plugin.page = async (context) => {
const current = currentSession()
if (current && context.event.userId === current.userId && context.event.name === current.page) {
await Gist.setCurrentRoute(current.page)
}
return context
}
await analytics.register(plugin)
let dismissal = Promise.resolve()
return {
identify: async (identity) => {
if (!navigator.onLine) throw new Error('Customer.io is offline')
Gist.setUserLocale(identity.locale)
await analytics.identify(identity.userId, { locale: identity.locale })
},
page: async ({ page }) => {
if (!navigator.onLine) throw new Error('Customer.io is offline')
await analytics.page(page, {
url: page,
path: page,
title: 'ComfyUI Desktop',
search: '',
referrer: ''
})
},
reset: async () => {
await dismissal
await analytics.reset()
await Gist.clearUserToken()
},
dismiss: () => {
// Clear the queue token and SSE connection without waiting for an in-flight
// analytics operation. Its old-identity events are also filtered above.
const clearing = Gist.clearUserToken()
const messages = [...Gist.currentMessages]
for (const message of messages) {
const element = document.getElementById(`gist-${message.instanceId}`)
// Persistent-message dismissal waits for a view-log request. Remove its
// modal backdrop synchronously so revoked messages cannot block input.
element?.closest('#gist-overlay')?.remove()
if (element) element.style.visibility = 'hidden'
}
dismissal = Promise.all([
dismissal,
clearing,
...messages.map((message) =>
message.instanceId ? Gist.dismissMessage(message.instanceId) : Promise.resolve()
)
]).then(
() => {},
() => {}
)
}
}
}
Loading
Loading