diff --git a/docs/customerio-messaging.md b/docs/customerio-messaging.md index 6c879dee6..af96269c7 100644 --- a/docs/customerio-messaging.md +++ b/docs/customerio-messaging.md @@ -91,7 +91,7 @@ After building, run the isolated Electron fixture on the current platform: ```sh pnpm exec electron-vite build -pnpm exec playwright test e2e/customerio.test.ts --project=macos --retries=0 +pnpm exec playwright test e2e/customerio.test.ts e2e/customerio-host.test.ts --project=macos --retries=0 ``` Use `windows` or `linux` for the corresponding host. These tests run both shipped @@ -100,8 +100,16 @@ The launcher fixture uses a file URL and the production panel's CSP. It covers rendering, opened metrics, dismissal, account changes, revocation, and continued access to the workflow and its existing browser storage. +The native-host fixture additionally bundles the production coordinator and IPC +transport, mounts the shipped preloads in real WebContentsViews, and uses the +production launcher CSP. It covers launcher-to-ComfyUI handoff, clearing the +previous message, consent revocation, and out-of-order identity responses. Both +fixtures intercept vendor requests and use disposable profiles. Run Electron UI +tests serially because the native-host fixture exercises real window focus. + Unit tests cover main-process eligibility, frame validation, link handling, -authentication consensus, and asynchronous session changes. +authentication consensus, asynchronous session changes, and serialized SDK +recovery after timeouts. Release verification still needs a restricted live Customer.io campaign and packaged macOS/Windows checks. Confirm the intended profile receives a message in diff --git a/e2e/customerio-host.test.ts b/e2e/customerio-host.test.ts new file mode 100644 index 000000000..5f881a402 --- /dev/null +++ b/e2e/customerio-host.test.ts @@ -0,0 +1,170 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { build } from 'esbuild' +import { _electron, expect, test as base, type ElectronApplication } from '@playwright/test' +import type {} from './support/customerIoHostMain' + +const test = base.extend<{ host: ElectronApplication }>({ + // Playwright requires destructuring even when the fixture has no dependencies. + // eslint-disable-next-line no-empty-pattern + host: async ({}, use) => withHost(use) +}) + +// A failing lifecycle must fail CI, even when the shared project enables retries. +test.describe.configure({ retries: 0 }) + +test('native Desktop views hand messaging to local ComfyUI and revoke consent @macos @windows @linux', async ({ + host: app +}) => { + await app.evaluate(() => customerIoHostFixture.waitForIdentity(0)) + expect(await app.evaluate(() => customerIoHostFixture.messageCount('launcher'))).toBe(0) + await app.evaluate(() => customerIoHostFixture.resolveIdentity(0, 'launcher-firebase-user')) + await app.evaluate(() => customerIoHostFixture.waitForMessage('launcher')) + + const before = await app.evaluate(() => customerIoHostFixture.snapshot()) + expect(before.visible).toEqual(['launcher']) + expect(before.focused).toBe(true) + expect(before.publications.at(-1)?.session).toMatchObject({ + userId: 'launcher-firebase-user', + page: 'desktop/launcher' + }) + await app.evaluate(() => customerIoHostFixture.switchTo('comfyui')) + await app.evaluate(() => customerIoHostFixture.waitForMessage('comfyui')) + await app.evaluate(() => customerIoHostFixture.waitForClear('launcher')) + const after = await app.evaluate(() => customerIoHostFixture.snapshot()) + expect(after.visible).toEqual(['comfyui']) + expect(after.publications.slice(before.publications.length)).toMatchObject([ + { surface: 'launcher', session: null }, + { surface: 'comfyui', session: { userId: 'local-firebase-user', page: 'desktop/comfyui' } } + ]) + expect(after.publications.every(({ granted }) => granted.length <= 1)).toBe(true) + expect( + after.publications.every( + ({ surface, session, visible }) => !session || visible.includes(surface) + ) + ).toBe(true) + expect(after.queueUsers).toEqual( + expect.arrayContaining(['launcher-firebase-user', 'local-firebase-user']) + ) + + await app.evaluate(() => customerIoHostFixture.revokeConsent()) + await app.evaluate(() => customerIoHostFixture.waitForClear('comfyui')) + await app.evaluate(() => customerIoHostFixture.clickWorkflow()) + const revoked = await app.evaluate(() => customerIoHostFixture.snapshot()) + expect(revoked.publications.at(-1)).toMatchObject({ + surface: 'comfyui', + session: null, + granted: [] + }) +}) + +test('late launcher identity cannot cross a native-view transition or consent revocation @macos @windows @linux', async ({ + host: app +}) => { + await app.evaluate(() => customerIoHostFixture.waitForIdentity(0)) + await app.evaluate(() => customerIoHostFixture.switchTo('comfyui')) + await app.evaluate(() => customerIoHostFixture.waitForMessage('comfyui')) + await app.evaluate(() => customerIoHostFixture.switchTo('launcher')) + await app.evaluate(() => customerIoHostFixture.waitForIdentity(1)) + await app.evaluate(() => customerIoHostFixture.waitForClear('comfyui')) + const pending = await app.evaluate(() => customerIoHostFixture.snapshot().publications) + + // Awaiting the deferred promise drains the coordinator's identity callback: + // negative assertions do not depend on an arbitrary quiet period. + await app.evaluate(() => customerIoHostFixture.resolveIdentity(0, 'stale-firebase-user')) + expect(await app.evaluate(() => customerIoHostFixture.snapshot().publications)).toEqual(pending) + expect(await app.evaluate(() => customerIoHostFixture.messageCount('launcher'))).toBe(0) + + await app.evaluate(() => customerIoHostFixture.authChanged()) + await app.evaluate(() => customerIoHostFixture.waitForIdentity(2)) + await app.evaluate(() => customerIoHostFixture.resolveIdentity(1, 'signed-out-firebase-user')) + expect(await app.evaluate(() => customerIoHostFixture.snapshot().publications)).toEqual(pending) + await app.evaluate(() => customerIoHostFixture.revokeConsent()) + await app.evaluate(() => customerIoHostFixture.resolveIdentity(2, 'revoked-firebase-user')) + expect(await app.evaluate(() => customerIoHostFixture.snapshot().publications)).toEqual(pending) + expect(await app.evaluate(() => customerIoHostFixture.messageCount('launcher'))).toBe(0) +}) + +async function withHost(run: (app: ElectronApplication) => Promise): Promise { + // Electron loadFile leaves '~' literal while pathToFileURL percent-encodes it. + // Exercise Windows short-path spelling on every platform. + const directory = await mkdtemp(join(tmpdir(), 'comfy-customerio~host-')) + let app: ElectronApplication | undefined + try { + const main = join(directory, 'main/main.cjs') + const dependencies = resolve('e2e/support/customerIoHostDependencies.ts') + const replaced = new Set( + [ + 'src/main/settings', + 'src/main/devplatform/session', + 'src/main/lib/firebaseAuthIdentity', + 'src/main/lib/i18n', + 'src/main/lib/ipc/shared' + ].map((file) => resolve(file)) + ) + await build({ + entryPoints: [resolve('e2e/support/customerIoHostMain.ts')], + outfile: main, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'], + plugins: [ + { + name: 'hermetic-customerio-dependencies', + setup(plugin) { + plugin.onResolve({ filter: /^\./ }, (args) => { + if (replaced.has(resolve(dirname(args.importer), args.path))) + return { path: dependencies } + }) + } + } + ] + }) + const csp = (await readFile(resolve('src/renderer/panel.html'), 'utf8')).match( + /]*>/ + )?.[0] + expect(csp).toBeTruthy() + await mkdir(join(directory, 'profile')) + await mkdir(join(directory, 'renderer')) + await writeFile( + join(directory, 'renderer/panel.html'), + `${csp}

Desktop launcher fixture

` + ) + const env: Record = { + ...process.env, + COMFY_CUSTOMER_IO_ENABLED: 'true', + COMFY_CUSTOMER_IO_WRITE_KEY: 'fixture-write-key', + COMFY_CUSTOMER_IO_SITE_ID: 'fixture-site', + CUSTOMER_IO_FIXTURE_PROFILE: join(directory, 'profile'), + CUSTOMER_IO_FIXTURE_PRELOADS: resolve('out/preload') + } + delete env.ELECTRON_RENDERER_URL + // Match the existing Electron harness: Linux CI has no SUID sandbox binary. + app = await _electron.launch({ + args: process.platform === 'linux' ? [main, '--no-sandbox'] : [main], + env + }) + await app.firstWindow() + await app.evaluate(() => customerIoHostFixture.start()) + expect(await app.evaluate(() => customerIoHostFixture.snapshot().profile)).toBe( + join(directory, 'profile') + ) + expect(await app.evaluate(() => customerIoHostFixture.snapshot().focused)).toBe(true) + await run(app) + const result = await app.evaluate(() => customerIoHostFixture.snapshot()) + if (test.info().status !== test.info().expectedStatus) + console.error('Native messaging fixture state:', JSON.stringify(result)) + expect(result.errors).toEqual([]) + expect( + result.queueUsers.every((user) => + ['launcher-firebase-user', 'local-firebase-user'].includes(user) + ) + ).toBe(true) + expect(result.siteIds.every((site) => site === 'fixture-site')).toBe(true) + } finally { + await app?.close() + await rm(directory, { recursive: true, force: true }) + } +} diff --git a/e2e/support/customerIoHostDependencies.ts b/e2e/support/customerIoHostDependencies.ts new file mode 100644 index 000000000..c81437624 --- /dev/null +++ b/e2e/support/customerIoHostDependencies.ts @@ -0,0 +1,54 @@ +import { EventEmitter } from 'node:events' + +// Only external state is faked. The coordinator, document IPC, body-mode +// calculation, native views, preloads and browser SDK remain production code. +const events = new EventEmitter() +const identities: { + promise: Promise<{ userId: string; firebaseUid: string } | null> + resolve: (identity: { userId: string; firebaseUid: string } | null) => void +}[] = [] +let consent = true + +export const _runningSessions = new Set(['fixture-install']) +export const _isStopping = (): boolean => false +export const getLocale = (): string => 'ja' +export const getCustomerIoUserId = (): string => 'local-firebase-user' +export const get = (key: string): boolean => key === 'firstUseCompleted' || consent +export const setConsent = (value: boolean): void => { + consent = value +} +export const authChanged = (): void => { + events.emit('auth') +} +export const getCloudSession = () => ({ + getUserIdentity() { + const request = Promise.withResolvers<{ userId: string; firebaseUid: string } | null>() + identities.push(request) + events.emit('identity-request') + return request.promise + }, + onAuthChanged(callback: () => void) { + events.on('auth', callback) + return () => events.off('auth', callback) + } +}) + +export async function waitForIdentity(index: number): Promise { + if (identities[index]) return + await new Promise((resolve) => { + const requested = (): void => { + if (!identities[index]) return + events.off('identity-request', requested) + resolve() + } + events.on('identity-request', requested) + }) +} + +export async function resolveIdentity(index: number, firebaseUid: string): Promise { + const request = identities[index] + if (!request) throw new Error(`Identity request ${index} has not started`) + // A different canonical ID catches accidental use of identity.userId. + request.resolve({ userId: 'canonical-account-id', firebaseUid }) + await request.promise +} diff --git a/e2e/support/customerIoHostMain.ts b/e2e/support/customerIoHostMain.ts new file mode 100644 index 000000000..9dc7d5894 --- /dev/null +++ b/e2e/support/customerIoHostMain.ts @@ -0,0 +1,269 @@ +import { app, BrowserWindow, WebContentsView, protocol } from 'electron' +import { join } from 'node:path' +import { once } from 'node:events' +import { attachCustomerIoMessaging } from '../../src/main/lib/customerIoMessaging' +import { customerIoEvents } from '../../src/main/lib/customerIoEvents' +import type { ComfyWindowEntry } from '../../src/main/host/registry' +import { CUSTOMER_IO_READY, CUSTOMER_IO_STATE } from '../../src/shared/customerIo' +import type { CustomerIoSession } from '../../src/shared/customerIo' +import { + authChanged, + resolveIdentity, + setConsent, + waitForIdentity +} from './customerIoHostDependencies' + +type Surface = 'launcher' | 'comfyui' +interface Publication { + surface: Surface + session: CustomerIoSession | null + visible: Surface[] + granted: Surface[] +} + +function createFixture() { + const queueUsers: string[] = [] + const siteIds: string[] = [] + const errors: string[] = [] + const respond = (request: Request): Response => { + const url = request.url + const html = (body: string): Response => + new Response(body, { + headers: { 'content-type': 'text/html' } + }) + const json = (body: unknown): Response => + Response.json(body, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': '*', + 'access-control-allow-methods': '*' + } + }) + if (request.method === 'OPTIONS') return json({}) + if (url.startsWith('http://127.0.0.1:8188/')) + return html(``) + if (url.endsWith('/settings')) + return json({ + integrations: { 'Customer.io Data Pipelines': { apiKey: 'fixture-write-key' } }, + plan: { track: {} } + }) + if (url.includes('/api/v4/users')) { + const user = Buffer.from( + request.headers.get('x-gist-encoded-user-token') ?? '', + 'base64' + ).toString() + queueUsers.push(user) + siteIds.push(request.headers.get('x-cio-site-id') ?? '') + return json({ + inAppMessages: [ + { + messageId: user, + queueId: `queue-${user}`, + priority: 1, + properties: { + gist: { + campaignId: user, + routeRuleWeb: + user === 'launcher-firebase-user' ? 'desktop/launcher' : 'desktop/comfyui' + } + } + } + ], + inboxMessages: [] + }) + } + if (url.startsWith('https://renderer.gist.build/')) + return html('') + if (url.startsWith('https://code.gist.build/')) + return html(`

Desktop message fixture

`) + return json({}) + } + // Protocol interception covers native child views before their first request, + // including the SDK iframe and metrics. Nothing falls through to the network. + protocol.handle('http', respond) + protocol.handle('https', respond) + const window = new BrowserWindow({ width: 1100, height: 700, show: false }) + void window.loadURL('about:blank') + const view = (preload: string): WebContentsView => + new WebContentsView({ + webPreferences: { + preload: join(process.env.CUSTOMER_IO_FIXTURE_PRELOADS!, preload), + contextIsolation: true, + nodeIntegration: false, + sandbox: false + } + }) + const views = { launcher: view('index.js'), comfyui: view('comfyPreload.js') } + const publications: Publication[] = [] + const readyFrames: { surface: Surface; mainFrame: boolean; url: string }[] = [] + const grants = new Set() + const visible = (): Surface[] => + (Object.keys(views) as Surface[]).filter((surface) => views[surface].getVisible()) + for (const surface of Object.keys(views) as Surface[]) { + const nativeView = views[surface] + window.contentView.addChildView(nativeView) + nativeView.setBounds({ x: 0, y: 0, width: 1100, height: 700 }) + nativeView.setVisible(surface === 'launcher') + nativeView.webContents.on('preload-error', (_event, _path, error) => errors.push(error.message)) + nativeView.webContents.on('ipc-message', (event, channel) => { + if (channel === CUSTOMER_IO_READY) + readyFrames.push({ + surface, + mainFrame: event.senderFrame === nativeView.webContents.mainFrame, + url: nativeView.webContents.getURL() + }) + }) + } + // Observe real IPC across main-frame replacement on the initial navigation. + // The original Electron method still delivers every publication to the preload. + const prototype = Object.getPrototypeOf( + views.launcher.webContents.mainFrame + ) as Electron.WebFrameMain + const send = prototype.send + prototype.send = function (channel: string, ...args: unknown[]): void { + if (channel === CUSTOMER_IO_STATE) { + const surface = (Object.keys(views) as Surface[]).find( + (name) => views[name].webContents.mainFrame === this + ) + if (surface) { + const session = args[0] as CustomerIoSession | null + if (session) grants.add(surface) + else grants.delete(surface) + publications.push({ surface, session, visible: visible(), granted: [...grants] }) + } + } + send.call(this, channel, ...args) + } + const entry = { + window, + comfyView: views.comfyui, + panelView: views.launcher, + installationId: null, + sourceCategory: null, + activePanel: 'comfy', + firstUseMode: 'none', + comfyUrl: 'http://127.0.0.1:8188/' + } as ComfyWindowEntry + const dispose = attachCustomerIoMessaging(entry) + window.on('closed', () => { + dispose() + for (const nativeView of Object.values(views)) nativeView.webContents.close() + }) + const waitForDom = (surface: Surface, expression: string): Promise => + views[surface].webContents.executeJavaScript(`new Promise(resolve => { + const ready = () => { + if (!(${expression})) return; + observer.disconnect(); + document.removeEventListener('transitionend', ready, true); + resolve(); + }; + const observer = new MutationObserver(ready); + observer.observe(document.documentElement, {childList:true, subtree:true, attributes:true}); + document.addEventListener('transitionend', ready, true); + ready(); + })`) + return { + async start() { + await Promise.all([ + views.launcher.webContents.loadFile(join(__dirname, '../renderer/panel.html')), + views.comfyui.webContents.loadURL(entry.comfyUrl) + ]) + // Establish focus after initial native-view navigation has finished. + const focused = once(window, 'focus') + window.show() + app.focus({ steal: true }) + window.focus() + await focused + }, + switchTo(surface: Surface) { + // Exercise the host's visibility-before-refresh contract with real native + // views. No installation process or Desktop profile is needed. + entry.installationId = 'fixture-install' + entry.sourceCategory = 'local' + entry.activePanel = surface === 'comfyui' ? 'comfy' : 'quick-install' + views[surface === 'comfyui' ? 'launcher' : 'comfyui'].setVisible(false) + views[surface].setVisible(true) + entry.refreshCustomerIo!() + }, + revokeConsent() { + setConsent(false) + customerIoEvents.emit('changed') + }, + authChanged, + resolveIdentity, + waitForIdentity, + messageCount: (surface: Surface): Promise => + views[surface].webContents.executeJavaScript( + 'document.querySelectorAll("iframe.gist-message").length' + ), + async waitForMessage(surface: Surface) { + await waitForDom( + surface, + `document.querySelector('iframe.gist-message') && getComputedStyle(document.querySelector('iframe.gist-message')).opacity === '1'` + ) + const frame = views[surface].webContents.mainFrame.framesInSubtree.find((frame) => + frame.url.startsWith('https://code.gist.build/') + ) + if ( + !frame || + (await frame.executeJavaScript('document.querySelector("h2").textContent')) !== + 'Desktop message fixture' + ) + throw new Error('SDK message iframe did not render') + const count = await views[surface].webContents.executeJavaScript( + 'document.querySelectorAll("iframe.gist-message").length' + ) + if (count !== 1) throw new Error(`Expected one message iframe, received ${count}`) + }, + waitForClear: (surface: Surface) => + waitForDom(surface, `!document.querySelector('#gist-overlay')`), + async clickWorkflow() { + const contents = views.comfyui.webContents + const point = await contents.executeJavaScript(`(() => { + const button = document.getElementById('workflow'); + const bounds = button.getBoundingClientRect(); + const x = Math.floor(bounds.x + bounds.width / 2), y = Math.floor(bounds.y + bounds.height / 2); + if (document.elementFromPoint(x, y) !== button) throw new Error('Revoked message still intercepts input'); + return { x, y }; + })()`) + contents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point }) + contents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point }) + await waitForDom( + 'comfyui', + `document.getElementById('workflow').textContent === 'Workflow running'` + ) + }, + snapshot: () => ({ + publications: [...publications], + visible: visible(), + focused: window.isFocused(), + readyFrames: [...readyFrames], + urls: Object.fromEntries( + Object.entries(views).map(([surface, view]) => [surface, view.webContents.getURL()]) + ), + panelPath: join(__dirname, '../renderer/panel.html'), + profile: app.getPath('userData'), + queueUsers: [...queueUsers], + siteIds: [...siteIds], + errors: [...errors] + }) + } +} + +declare global { + var customerIoHostFixture: ReturnType +} + +// Set the disposable profile before readiness, including Chromium session data. +app.setPath('userData', process.env.CUSTOMER_IO_FIXTURE_PROFILE!) +app.setPath('sessionData', process.env.CUSTOMER_IO_FIXTURE_PROFILE!) +void app.whenReady().then(() => { + globalThis.customerIoHostFixture = createFixture() +}) diff --git a/src/main/lib/customerIoMessaging.test.ts b/src/main/lib/customerIoMessaging.test.ts index 4404735df..72e43f42c 100644 --- a/src/main/lib/customerIoMessaging.test.ts +++ b/src/main/lib/customerIoMessaging.test.ts @@ -107,6 +107,53 @@ function launcherFixture() { } describe('Desktop messaging eligibility', () => { + it('accepts equivalent percent-encoding of the bundled panel file', async () => { + const { panelContents, panelFrame, ready } = launcherFixture() + panelContents.getURL.mockReturnValue( + panelContents.getURL().replace('panel.html', '%70anel.html') + ) + ready() + await Promise.resolve() + expect(panelFrame.send).toHaveBeenLastCalledWith( + CUSTOMER_IO_STATE, + expect.objectContaining({ userId: 'launcher-person' }) + ) + }) + + it.each(['other.html', '%2Fpanel.html', '%5Cpanel.html'])( + 'rejects a different or ambiguously encoded launcher path: %s', + (path) => { + const { panelContents, ready } = launcherFixture() + panelContents.getURL.mockReturnValue(panelContents.getURL().replace('panel.html', path)) + ready() + expect(state.getIdentity).not.toHaveBeenCalled() + } + ) + + it('rejects a non-file protocol with the same opaque origin and pathname', () => { + const { panelContents, ready } = launcherFixture() + panelContents.getURL.mockReturnValue(panelContents.getURL().replace('file:', 'other:')) + ready() + expect(state.getIdentity).not.toHaveBeenCalled() + }) + + it('keeps the configured development URL origin and pathname exact', async () => { + vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173') + const { panelContents, panelFrame, ready } = launcherFixture() + for (const url of ['http://localhost:5173/%70anel.html', 'http://other.test:5173/panel.html']) { + panelContents.getURL.mockReturnValue(url) + ready() + expect(state.getIdentity).not.toHaveBeenCalled() + } + panelContents.getURL.mockReturnValue('http://localhost:5173/panel.html') + ready() + await Promise.resolve() + expect(panelFrame.send).toHaveBeenLastCalledWith( + CUSTOMER_IO_STATE, + expect.objectContaining({ userId: 'launcher-person' }) + ) + }) + it('waits for the launcher document and uses only the server-confirmed Firebase UID', async () => { const { panelContents, panelFrame, ready } = launcherFixture() expect(state.getIdentity).not.toHaveBeenCalled() diff --git a/src/main/lib/customerIoMessaging.ts b/src/main/lib/customerIoMessaging.ts index f5453644c..ed5a1e628 100644 --- a/src/main/lib/customerIoMessaging.ts +++ b/src/main/lib/customerIoMessaging.ts @@ -1,6 +1,6 @@ import { app, type WebContents } from 'electron' import { join } from 'node:path' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { computeBodyMode, type ComfyWindowEntry } from '../host/registry' import { CUSTOMER_IO_DEFAULTS, CUSTOMER_IO_PAGES } from '../../shared/customerIo' import type { CustomerIoSession } from '../../shared/customerIo' @@ -67,7 +67,15 @@ function launcherContents(entry: ComfyWindowEntry): WebContents | null { const expected = process.env.ELECTRON_RENDERER_URL ? new URL('panel.html', `${process.env.ELECTRON_RENDERER_URL.replace(/\/+$/, '')}/`) : pathToFileURL(join(__dirname, '../renderer/panel.html')) - if (url.origin !== expected.origin || url.pathname !== expected.pathname) return null + if (url.protocol !== expected.protocol || url.origin !== expected.origin) return null + // Electron loadFile and Node encode characters such as '~' differently. + // Compare the file itself without broadening the configured HTTP dev URL. + if ( + url.protocol === 'file:' + ? fileURLToPath(url) !== fileURLToPath(expected) + : url.pathname !== expected.pathname + ) + return null } catch { return null }