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
12 changes: 10 additions & 2 deletions docs/customerio-messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
170 changes: 170 additions & 0 deletions e2e/customerio-host.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>): Promise<void> {
// 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(
/<meta\s+http-equiv="Content-Security-Policy"[^>]*>/
)?.[0]
expect(csp).toBeTruthy()
await mkdir(join(directory, 'profile'))
await mkdir(join(directory, 'renderer'))
await writeFile(
join(directory, 'renderer/panel.html'),
`<html><head>${csp}</head><body><h1>Desktop launcher fixture</h1></body></html>`
)
const env: Record<string, string> = {
...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 })
}
}
54 changes: 54 additions & 0 deletions e2e/support/customerIoHostDependencies.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (identities[index]) return
await new Promise<void>((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<void> {
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
}
Loading
Loading