diff --git a/src/renderer/src/customerIo/adapter.test.ts b/src/renderer/src/customerIo/adapter.test.ts new file mode 100644 index 000000000..bc4cf5e0c --- /dev/null +++ b/src/renderer/src/customerIo/adapter.test.ts @@ -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() { + let resolve!: (value: T) => void + const promise = new Promise((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() + 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() + 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() + }) +}) diff --git a/src/renderer/src/customerIo/adapter.ts b/src/renderer/src/customerIo/adapter.ts new file mode 100644 index 000000000..955f11463 --- /dev/null +++ b/src/renderer/src/customerIo/adapter.ts @@ -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 | 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 { + 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( + () => {}, + () => {} + ) + } + } +} diff --git a/src/renderer/src/customerIo/controller.test.ts b/src/renderer/src/customerIo/controller.test.ts index c07c3c474..3ecb400e2 100644 --- a/src/renderer/src/customerIo/controller.test.ts +++ b/src/renderer/src/customerIo/controller.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createMessagingController, type MessagingClient } from './controller' import type { CustomerIoSession } from '../../../shared/customerIo' @@ -26,6 +26,7 @@ function deferred() { } describe('Desktop messaging lifecycle', () => { + afterEach(() => vi.useRealTimers()) it('does not load for an ineligible session and identifies before reporting the page', async () => { const sdk = client() const load = vi.fn(async () => sdk) @@ -90,8 +91,9 @@ describe('Desktop messaging lifecycle', () => { await controller.update(session) const second = { ...session, userId: 'user-b' } const changing = controller.update(second) - expect(current()).toEqual(second) + expect(current()).toBeNull() await changing + expect(current()).toEqual(second) expect(sdk.reset).toHaveBeenCalledTimes(2) expect(sdk.identify).toHaveBeenLastCalledWith(second) const logout = controller.update(null) @@ -113,3 +115,90 @@ describe('Desktop messaging lifecycle', () => { expect(sdk.page).toHaveBeenCalledOnce() }) }) + +describe('SDK ownership after a timeout', () => { + afterEach(() => vi.useRealTimers()) + + it('keeps the original loader when activation times out and is retried', async () => { + vi.useFakeTimers() + const loading = deferred() + const sdk = client() + const load = vi.fn(() => loading.promise) + const controller = createMessagingController(load, vi.fn()) + const first = controller.update(session) + await vi.advanceTimersByTimeAsync(10_000) + await first + const retry = controller.update(session) + await vi.advanceTimersByTimeAsync(0) + expect(load).toHaveBeenCalledOnce() + loading.resolve(sdk) + await retry + expect(sdk.identify).toHaveBeenCalledExactlyOnceWith(session) + }) + + it.each(['identify', 'reset'] as const)( + 'does not let a newer identity overtake a timed-out %s', + async (operation) => { + vi.useFakeTimers() + const sdk = client() + const controller = createMessagingController(async () => sdk, vi.fn()) + await controller.update(session) + const pending = deferred() + vi.mocked(sdk[operation]).mockImplementationOnce(() => pending.promise) + const changing = controller.update( + operation === 'reset' ? null : { ...session, locale: 'ja' } + ) + await vi.advanceTimersByTimeAsync(10_000) + await changing + const second = { ...session, userId: 'user-b' } + const switching = controller.update(second) + await vi.advanceTimersByTimeAsync(0) + expect(sdk.identify).not.toHaveBeenCalledWith(second) + pending.resolve() + await switching + expect(sdk.identify).toHaveBeenLastCalledWith(second) + expect(sdk.page).toHaveBeenLastCalledWith(second) + } + ) + + it('reconciles a late identify from reset and keeps messaging disabled until ownership is released', async () => { + vi.useFakeTimers() + const sdk = client() + const identifying = deferred() + vi.mocked(sdk.identify).mockImplementationOnce(() => identifying.promise) + let current!: () => CustomerIoSession | null + const controller = createMessagingController(async (_session, getSession) => { + current = getSession + return sdk + }, vi.fn()) + const first = controller.update(session) + await vi.advanceTimersByTimeAsync(10_000) + await first + expect(current()).toBeNull() + expect(sdk.dismiss).toHaveBeenCalled() + expect(sdk.page).not.toHaveBeenCalled() + identifying.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(sdk.reset).toHaveBeenCalledTimes(2) + expect(sdk.identify).toHaveBeenCalledTimes(2) + expect(sdk.page).toHaveBeenCalledExactlyOnceWith(session) + expect(current()).toEqual(session) + }) + + it('cleans up a late loader without activating a user revoked during its timeout', async () => { + vi.useFakeTimers() + const loading = deferred() + const sdk = client() + const controller = createMessagingController(() => loading.promise, vi.fn()) + const first = controller.update(session) + await vi.advanceTimersByTimeAsync(10_000) + await first + const logout = controller.update(null) + loading.resolve(sdk) + await logout + expect(sdk.identify).not.toHaveBeenCalled() + expect(sdk.page).not.toHaveBeenCalled() + expect(sdk.dismiss).toHaveBeenCalled() + expect(sdk.reset).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/customerIo/controller.ts b/src/renderer/src/customerIo/controller.ts index b1de6677b..1b10463a9 100644 --- a/src/renderer/src/customerIo/controller.ts +++ b/src/renderer/src/customerIo/controller.ts @@ -7,13 +7,16 @@ export interface MessagingClient { dismiss(): void } -async function bounded(operation: Promise): Promise { +async function bounded(operation: Promise, onTimeout: () => void): Promise { let timer: ReturnType | undefined try { - return await Promise.race([ + await Promise.race([ operation, - new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error('Customer.io operation timed out')), 10_000) + new Promise((resolve) => { + timer = setTimeout(() => { + onTimeout() + resolve() + }, 10_000) }) ]) } finally { @@ -21,7 +24,7 @@ async function bounded(operation: Promise): Promise { } } -/** Serializes identity changes while rejecting work queued for an obsolete session. */ +/** One owner of SDK operations; only the caller's wait may time out. */ export function createMessagingController( load: ( session: CustomerIoSession, @@ -31,44 +34,73 @@ export function createMessagingController( console.warn('Desktop messaging is unavailable', error) ): { update: (session: CustomerIoSession | null) => Promise } { let desired: CustomerIoSession | null = null + let permitted: CustomerIoSession | null = null let client: MessagingClient | null = null let identified: CustomerIoSession | null = null let version = 0 + let retry = false let queue = Promise.resolve() + let completion = queue function update(session: CustomerIoSession | null): Promise { - if (JSON.stringify(desired) === JSON.stringify(session)) return queue + if (!retry && JSON.stringify(desired) === JSON.stringify(session)) return completion desired = session + permitted = null + retry = false const revision = ++version - // Hide a previous user's message immediately, even if an SDK operation is pending. + let expired = false + let failed = false + const isCurrent = (): boolean => revision === version && !expired + // Revoke before waiting for a previous operation to release the SDK. if (!session || identified?.userId !== session.userId) client?.dismiss() queue = queue .then(async () => { - if (revision !== version) return + if (!isCurrent()) return if (!session) { - if (client) await bounded(client.reset()) + if (client) await client.reset() identified = null return } - client ??= await bounded(load(session, () => desired)) - if (revision !== version) return + client ??= await load(session, () => permitted) + if (!isCurrent()) return if (identified?.userId !== session.userId) { - await bounded(client.reset()) + await client.reset() identified = null } - if (revision !== version) return - await bounded(client.identify(session)) - if (revision !== version) return + if (!isCurrent()) return + permitted = session + await client.identify(session) + if (!isCurrent()) return identified = session - await bounded(client.page(session)) + await client.page(session) }) .catch((error: unknown) => { + failed = true client?.dismiss() identified = null - // A later focus, auth report, or online event can retry this same session. - if (revision === version) desired = null + if (revision === version) { + permitted = null + // A later focus, auth report, or online event can retry this session. + retry = true + } reportError(error) }) - return queue + .then(() => { + if (!isCurrent()) client?.dismiss() + // A late successful operation releases ownership. Reconcile the latest + // session from reset; never resume the expired identity/page sequence. + if (expired && !failed && revision === version) void update(desired) + }) + completion = bounded(queue, () => { + expired = true + if (revision === version) { + permitted = null + identified = null + retry = true + client?.dismiss() + } + reportError(new Error('Customer.io operation timed out')) + }) + return completion } return { update } } diff --git a/src/renderer/src/customerIo/index.ts b/src/renderer/src/customerIo/index.ts index 3c1661991..218f2abc0 100644 --- a/src/renderer/src/customerIo/index.ts +++ b/src/renderer/src/customerIo/index.ts @@ -1,108 +1,11 @@ -import { AnalyticsBrowser, InAppPlugin } from '@customerio/cdp-analytics-browser' -import Gist from 'customerio-gist-web' import type { CustomerIoSession } from '../../../shared/customerIo' import { createMessagingController } from './controller' +import { createMessagingClientLoader } from './adapter' import { setMessagingPage } from './environment' -const controller = createMessagingController(async (session, currentSession) => { - const analytics = AnalyticsBrowser.load( - { writeKey: session.writeKey }, - { - 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 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) - }) - await analytics.register( - 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) messagingGlobal.__comfyCustomerIoOpenLink?.(detail.actionValue) - } - }) - ) - // 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: () => {} - }) - let dismissal = Promise.resolve() - return { - identify: async (identity) => { - Gist.setUserLocale(identity.locale) - await analytics.identify(identity.userId, { locale: identity.locale }) - }, - page: async ({ page }) => { - 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([ - clearing, - ...messages.map((message) => - message.instanceId ? Gist.dismissMessage(message.instanceId) : Promise.resolve() - ) - ]).then( - () => {}, - () => {} - ) - } - } -}) +const controller = createMessagingController( + createMessagingClientLoader((action) => messagingGlobal.__comfyCustomerIoOpenLink?.(action)) +) const messagingGlobal = globalThis as typeof globalThis & { __comfyCustomerIo: { update: (session: CustomerIoSession | null) => Promise }