Compare commits

...

8 Commits

Author SHA1 Message Date
Claude
f3d842cc32 chore: remove GitHub Pages setup instructions and low-value comments from dashboard-deploy workflow
Manual-enable steps for GitHub Pages belong in the PR description, not
comments in the workflow config. Also trims a redundant top-of-file
description comment that just restated the job name/steps.
2026-07-17 04:49:15 +00:00
GitHub Action
d129ab23e1 [automated] Apply ESLint and Oxfmt fixes 2026-07-17 04:05:37 +00:00
Claude
4d774bfa99 fix: address review feedback — refresh dashboard on e2e failure, safer Pages concurrency, accurate report label
- Deploy job now triggers on both 'success' and 'failure' conclusions of
  CI: Tests E2E on main, so a failing run's report is still published to
  the persistent dashboard URL instead of being silently skipped.
- Switch the dashboard-deploy concurrency group to cancel-in-progress:
  false, matching GitHub's recommended Pages deploy pattern — cancelling
  a deploy-pages run mid-flight can leave a deployment in a bad state,
  and these deploys are idempotent/infrequent.
- Relabel the Playwright report link "Merged chromium E2E report" since
  the linked playwright-report-chromium artifact only covers the merged
  chromium-sharded run, not the chromium-2x/chromium-0.5x/mobile-chrome/
  cloud projects, which upload as separate artifacts.

Addresses review feedback from Christian Byrne on PR #13735.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5Z1u8a5jspDCYRF4x5WJy
2026-07-17 04:02:05 +00:00
GitHub Action
146a88480c [automated] Apply ESLint and Oxfmt fixes 2026-07-16 22:19:11 +00:00
Claude
cd57484695 feat: add CI dashboard scaffold with Storybook + Playwright report links
First step toward a centralized CI/reports dashboard published via GitHub
Pages. Adds a minimal static index page linking to the already-live
Storybook deployment and to the merged Playwright HTML report, and a new
workflow that deploys both to GitHub Pages on push to main.

Follow-up PRs will add the remaining report types (coverage, bundle size,
lint, etc.) once each has a persistent, hosted home.
2026-07-16 22:14:16 +00:00
steven-comfy
f524683f3c feat: add hidden /individual-submission page for sales-form redirects (#13697)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `/individual-submission`, a hidden `noindex` page intended as the
post-submit redirect target for the HubSpot sales form when the visitor
selects the "individual" option. The page thanks the visitor, points
them at the self-serve plans on `/cloud/pricing`, and offers a
`mailto:gtm-team@comfy.org` fallback for enterprise inquiries.

## Design

Mirrors the `terms-of-service.astro` template — `BaseLayout` +
`HeroSection` — so typography and spacing feel native to the marketing
site. Plan names (**Standard**, **Creator**, **Pro**, **Teams**) are
highlighted with the yellow-italic accent used elsewhere on the site.

## Hidden-page mechanics

Follows the pattern already used for `/terms-of-service` and
`/payment/*`:

- `noindex` meta tag on the page
- Path added to `SITEMAP_EXCLUDED_PATHNAMES` in `astro.config.ts`
- English-only, with a `/zh-CN/individual-submission` →
`/individual-submission` redirect (matches `termsOfService` handling)
- Not linked from `routes.ts`, `HeaderMain`, or `SiteFooter`

## Next step (not in this PR)

Someone with HubSpot access needs to configure the form (portal
`244637579`, form `94e05eab-1373-47f7-ab5e-d84f9e6aa262`) to redirect to
`https://comfy.org/individual-submission` when the "individual" option
is selected. Happy to add a JS-side fallback in a follow-up PR if
HubSpot's conditional redirects aren't a fit.

## Verification

- `pnpm typecheck` → 0 errors (10 pre-existing hints unrelated to this
change)
- `pnpm build` → 499 pages built; `/individual-submission/` and
`/zh-CN/individual-submission/` (redirect stub) emitted; sitemap
excludes both
- Playwright preview at 1280×900 and 390×844 — hero title, prose,
plan-name highlighting, CTA button, and mailto link all render as
expected; no console errors; `whitespace-nowrap` keeps the email address
on one line on mobile
- `/review` (oracle) on the previous revision: 0 findings; only change
since is the URL rename
- `/zh-CN/individual-submission` redirect verified in preview

## Screenshots

![Desktop view of /individual-submission: centered hero title 'Thanks
for reaching out.', body copy with Standard/Creator/Pro/Teams in yellow
italic, yellow 'See plans and pricing' CTA button, and a smaller mailto
fallback
line.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4ce277dd60743a347e517f79e6707eef6b43ec5fb8f54a92ea8c52d3858fb258/pr-images/1784158692177-e7fe7ed9-8844-4625-9823-1dd719173c73.png)

![Mobile 390px view of /individual-submission: same layout stacks
cleanly, gtm-team@comfy.org email stays on one line, no horizontal
overflow.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4ce277dd60743a347e517f79e6707eef6b43ec5fb8f54a92ea8c52d3858fb258/pr-images/1784158692523-e1af7deb-1379-4280-9bcc-0c602bb12aff.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: imick-io <michael@concreo.io>
Co-authored-by: Michael B <michael@imick.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:49:30 +00:00
Benjamin Lu
d1d55585f9 [GTM-278] Report Firebase auth state to Desktop telemetry (#13687)
## Summary

Reports each hosted Cloud renderer's declarative Firebase auth state to
Desktop. Desktop remains the sole owner of PostHog identity and
arbitrates all live `WebContents` before changing the process-global
person.

Linear: [GTM-278](https://linear.app/comfyorg/issue/GTM-278) · Parent:
[GTM-93](https://linear.app/comfyorg/issue/GTM-93)

## Changes

- reports `pending` as soon as host auth synchronization starts
- waits for Firebase initialization, then reports `signed_out` or
`signed_in` with the Firebase UID
- reports restored sessions, logout, and direct account switches through
the same declarative state contract
- makes repeated same-UID Firebase updates a no-op
- adds the optional `reportFirebaseAuthState` contract to the Desktop
bridge types
- keeps host bridge failures from interrupting renderer startup or
Firebase auth

## Ownership and privacy

- the frontend never calls PostHog `identify`, `alias`, bind, or unbind
APIs
- browser PostHog provider behavior is unchanged
- the auth-state message is local to Desktop; Desktop #1272 applies its
own telemetry-consent gate before any PostHog identify or emission
- `enable_telemetry` controls only the frontend `HostTelemetrySink`
(renderer event forwarding), so this observer deliberately does not
share that rollout flag
- no canonical `comfy_user_id`, `/api/user` lookup, Cloud API
dependency, Redis state, token redemption, or website transport change
is added

## Review and rollout order

Review [Comfy-Desktop
#1272](https://github.com/Comfy-Org/Comfy-Desktop/pull/1272) first
because it defines the trusted state-reporting API and consensus policy.
Deploy this frontend first: the method is optional and therefore a no-op
on older Desktop builds. Then release Desktop #1272, and only afterward
deploy [comfy-router
#34](https://github.com/Comfy-Org/comfy-router/pull/34).

Auth [Comfy-Desktop
#1222](https://github.com/Comfy-Org/Comfy-Desktop/pull/1222) must not
become an identity authority; with Desktop #1272 its legacy imperative
bind is inert and the post-reload Firebase report is the source of
truth.

## Testing

- `pnpm test:unit src/platform/telemetry/hostUserIdSync.test.ts
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts`
— 53 tests passed
- `pnpm typecheck`
- targeted Oxlint and ESLint
- targeted `oxfmt --check`
- `pnpm knip`
- pre-commit typecheck/lint/format hooks
- pre-push `knip`
- `git diff --check`

No user-facing UI changes.
2026-07-16 04:22:41 +00:00
Benjamin Lu
98c654df20 fix: report page views to Customer.io in-app SDK (#13560)
## Summary

Report the current URL to Customer.io after its in-app plugin
initializes and on every client-side navigation, allowing queued web
in-app messages to satisfy page rules and render.

## Changes

- **What**: Wait for the in-app plugin to register, flush an initial
URL-based `page()` call, and consume the router's existing page-view
dispatches for subsequent SPA navigation.
- **Tests**: Cover initial page reporting, plugin-registration ordering,
and client-side route changes.

## Review Focus

Customer.io intentionally receives `page()` without an explicit page
name. Passing the telemetry page title would make the in-app SDK match
page rules against that title instead of the full URL; the live campaign
requires `contains cloud.comfy.org`.

Root cause: the production SDK debugger showed a correctly identified
user and active SSE connection, but `Route: NONE` and no eligible
messages because the provider did not implement `trackPageView()`.

Complements #13556, which addresses identity stitching for existing
email-keyed Customer.io profiles.

Validation: targeted unit tests (21/21), `pnpm typecheck`, targeted
ESLint and oxfmt checks, and the pre-push `knip` gate.
2026-07-16 02:02:59 +00:00
14 changed files with 715 additions and 8 deletions

View File

@@ -0,0 +1,70 @@
# Triggered by workflow_run (not push) so this only runs after 'CI: Tests E2E' has
# finished merging its sharded blob reports into an HTML report on main — the
# 'playwright-report-chromium' artifact this job downloads doesn't exist until that
# merge-reports job completes.
name: 'CI: Dashboard Deploy'
on:
workflow_run:
workflows: ['CI: Tests E2E']
types:
- completed
concurrency:
group: dashboard-deploy-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
(github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download merged Playwright report
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
run_id: ${{ github.event.workflow_run.id }}
name: playwright-report-chromium
path: dashboard-site/playwright-report
if_no_artifact_found: warn
- name: Check Playwright report was found
id: report
run: |
if [ -f dashboard-site/playwright-report/index.html ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "No merged Playwright report artifact found for this run; skipping the dashboard deploy so the existing Pages deployment isn't wiped out." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Assemble dashboard index
if: steps.report.outputs.found == 'true'
run: cp scripts/cicd/dashboard/index.html dashboard-site/index.html
- name: Upload to GitHub Pages
if: steps.report.outputs.found == 'true'
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: dashboard-site
- name: Deploy to GitHub Pages
id: deployment
if: steps.report.outputs.found == 'true'
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

View File

@@ -10,11 +10,12 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
LOCALE_PREFIXES.flatMap((prefix) =>
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
...LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
)
)
),
'/individual-submission'
])
function isExcludedFromSitemap(page: string): boolean {
const pathname = new URL(page).pathname.replace(/\/$/, '')

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import Button from '../ui/button/Button.vue'
const { href, label } = defineProps<{
href: string
label: string
}>()
</script>
<template>
<div class="mt-2 flex justify-center">
<Button as="a" :href variant="default" size="lg">
{{ label }}
</Button>
</div>
</template>

View File

@@ -1,12 +1,25 @@
<script setup lang="ts">
const { title } = defineProps<{ title: string }>()
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { title, class: className } = defineProps<{
title: string
class?: HTMLAttributes['class']
}>()
</script>
<template>
<section
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
>
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
<h1
:class="
cn(
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
className
)
"
>
{{ title }}
</h1>
</section>

View File

@@ -0,0 +1,60 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { getRoutes } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout
title="Thanks for reaching out - Comfy"
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
noindex
>
<HeroSection title="Thanks for reaching out." class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
>
<p>
Based on what you shared, one of our self-serve plans is probably a
better fit.
<strong class="text-primary-comfy-yellow font-semibold italic">
Standard</strong
>,
<strong class="text-primary-comfy-yellow font-semibold italic">
Creator</strong
>, and
<strong class="text-primary-comfy-yellow font-semibold italic">
Pro</strong
> for individual creators, plus our new
<strong class="text-primary-comfy-yellow font-semibold italic">
Teams</strong
> plan for multiple users under shared billing.
</p>
<PlansPricingCta
href={routes.cloudPricing}
label="See plans and pricing →"
/>
<p class="text-primary-warm-gray mt-8 text-sm">
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
href="mailto:gtm-team@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>gtm-team@comfy.org</a
>
</p>
<p class="text-primary-warm-gray text-sm">
Need help with something else? Email: <a
href="mailto:support@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>support@comfy.org</a
>
</p>
</div>
</section>
</BaseLayout>

View File

@@ -0,0 +1,57 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PlansPricingCta from '../../components/individual-submission/PlansPricingCta.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
import { getRoutes } from '../../config/routes'
const routes = getRoutes('zh-CN')
---
<BaseLayout
title="感谢您的联系 - Comfy"
description="感谢您的联系。根据您提供的信息,我们的自助服务套餐之一可能更适合您。"
noindex
>
<HeroSection title="感谢您的联系。" class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
>
<p>
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
<strong class="text-primary-comfy-yellow font-semibold italic">
Standard</strong
>、
<strong class="text-primary-comfy-yellow font-semibold italic">
Creator</strong
>
<strong class="text-primary-comfy-yellow font-semibold italic">
Pro</strong
>,以及我们全新的
<strong class="text-primary-comfy-yellow font-semibold italic">
Teams</strong
> 套餐,可让多位用户共享账单。
</p>
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
<p class="text-primary-warm-gray mt-8 text-sm">
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<a
href="mailto:gtm-team@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>gtm-team@comfy.org</a
>
</p>
<p class="text-primary-warm-gray text-sm">
需要其他方面的帮助?邮箱:<a
href="mailto:support@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>support@comfy.org</a
>
</p>
</div>
</section>
</BaseLayout>

View File

@@ -40,6 +40,11 @@ export type ComfyDesktop2TelemetryProperties = Record<
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
>
export type ComfyDesktop2FirebaseAuthState =
| { status: 'pending' }
| { status: 'signed_out' }
| { status: 'signed_in'; userId: string }
export interface ComfyDesktop2TerminalBridge {
subscribe(installationId?: string): Promise<TerminalRestore>
unsubscribe(installationId?: string): Promise<void>
@@ -60,6 +65,7 @@ export interface ComfyDesktop2LogsBridge {
export interface ComfyDesktop2TelemetryBridge {
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void
}
export interface ComfyDesktop2Bridge {

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
"version": "0.1.2",
"version": "0.1.3",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",

View File

@@ -0,0 +1,126 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>ComfyUI_frontend CI Dashboard</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #1a1a1a;
--muted: #59636e;
--border: #d8dee4;
--card-bg: #f6f8fa;
--link: #0969da;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #9198a1;
--border: #30363d;
--card-bg: #161b22;
--link: #4493f8;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 2.5rem 1.5rem;
background: var(--bg);
color: var(--fg);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial,
sans-serif;
line-height: 1.5;
}
main {
max-width: 40rem;
margin: 0 auto;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
p.lede {
color: var(--muted);
margin-top: 0;
margin-bottom: 2rem;
}
ul.reports {
list-style: none;
margin: 0 0 2rem;
padding: 0;
display: grid;
gap: 0.75rem;
}
ul.reports li {
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card-bg);
padding: 1rem 1.25rem;
}
ul.reports a {
color: var(--link);
font-weight: 600;
text-decoration: none;
}
ul.reports a:hover {
text-decoration: underline;
}
ul.reports .description {
color: var(--muted);
font-size: 0.9rem;
margin-top: 0.25rem;
}
footer {
color: var(--muted);
font-size: 0.85rem;
border-top: 1px solid var(--border);
padding-top: 1rem;
}
code {
background: var(--card-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.85em;
}
</style>
</head>
<body>
<main>
<h1>ComfyUI_frontend CI Dashboard</h1>
<p class="lede">
Persistent links to CI-generated reports and tools for the
<code>main</code> branch, rebuilt on every push.
</p>
<ul class="reports">
<li>
<a href="https://main.comfy-storybook.pages.dev">Storybook</a>
<div class="description">
Component library, built and deployed from
<code>ci-tests-storybook.yaml</code>.
</div>
</li>
<li>
<a href="./playwright-report/">Playwright report</a>
<div class="description">
Merged chromium E2E report from the latest <code>main</code> run of
<code>ci-tests-e2e.yaml</code>.
</div>
</li>
</ul>
<footer>
More report types (coverage, bundle size, lint, etc.) will be added here
in follow-up PRs as they get a persistent, hosted home. This page is
intentionally minimal for now.
</footer>
</main>
</body>
</html>

View File

@@ -19,6 +19,7 @@ import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { syncHostUserIdWithFirebaseAuth } from '@/platform/telemetry/hostUserIdSync'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { isDesktop, isNightly } from '@/platform/distribution/types'
@@ -141,6 +142,10 @@ app
modules: [VueFireAuth()]
})
if (isCloud && hasHostTelemetryBridge) {
syncHostUserIdWithFirebaseAuth()
}
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,

View File

@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueModule from 'vue'
import { nextTick } from 'vue'
type MockAuthStore = {
isInitialized: boolean
currentUser: { uid: string } | null
}
const hoisted = vi.hoisted(() => ({
authStore: null as unknown as MockAuthStore
}))
vi.mock('@/stores/authStore', async () => {
const { reactive } = await vi.importActual<typeof VueModule>('vue')
hoisted.authStore = reactive<MockAuthStore>({
isInitialized: false,
currentUser: null
})
return { useAuthStore: () => hoisted.authStore }
})
import { syncHostUserIdWithFirebaseAuth } from './hostUserIdSync'
const stopHandles: Array<() => void> = []
function installTelemetryBridge() {
const reportFirebaseAuthState = vi.fn()
window.__comfyDesktop2 = {
isRemote: () => false,
Telemetry: {
capture: vi.fn(),
reportFirebaseAuthState
}
}
return { reportFirebaseAuthState }
}
function startSync(): void {
const stop = syncHostUserIdWithFirebaseAuth()
if (stop) stopHandles.push(stop)
}
describe('host user ID sync', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.authStore.isInitialized = false
hoisted.authStore.currentUser = null
})
afterEach(() => {
while (stopHandles.length) stopHandles.pop()?.()
delete window.__comfyDesktop2
})
it('waits for Firebase auth initialization before reporting a user', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
startSync()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'pending'
})
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-a'
})
})
it('reports a restored Firebase session immediately', () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }]
])
})
it('reports an initially signed-out Firebase session', () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.isInitialized = true
startSync()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_out' }]
])
})
it('reports signed out when Firebase finishes initialization', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
startSync()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_out'
})
})
it('reports account switches, logout, and subsequent login', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
hoisted.authStore.currentUser = { uid: 'firebase-user-b' }
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-b'
})
hoisted.authStore.currentUser = null
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_out'
})
hoisted.authStore.currentUser = { uid: 'firebase-user-c' }
await nextTick()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }],
[{ status: 'signed_in', userId: 'firebase-user-b' }],
[{ status: 'signed_out' }],
[{ status: 'signed_in', userId: 'firebase-user-c' }]
])
})
it('does not report again when Firebase replaces the user object with the same UID', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
await nextTick()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }]
])
})
it('does not let a host reporting failure interrupt Firebase state sync', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
reportFirebaseAuthState.mockImplementationOnce(() => {
throw new Error('host unavailable')
})
expect(() => startSync()).not.toThrow()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-a'
})
})
})

View File

@@ -0,0 +1,49 @@
import { watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useAuthStore } from '@/stores/authStore'
function safelyReportFirebaseAuthState(report: () => void): void {
try {
report()
} catch {
// A host bridge failure must not block renderer startup or Firebase auth.
}
}
/**
* Keep the Desktop main-process telemetry identity aligned with Firebase auth.
* Must run after Pinia and VueFire are installed.
*/
export function syncHostUserIdWithFirebaseAuth(): WatchStopHandle | undefined {
const telemetry = window.__comfyDesktop2?.Telemetry
if (!telemetry) return
// Register this Cloud renderer before Firebase resolves. Desktop may host
// multiple Cloud main frames whose isolated browser partitions have
// different auth states, so main owns all cross-WebContents arbitration.
safelyReportFirebaseAuthState(() =>
telemetry.reportFirebaseAuthState?.({ status: 'pending' })
)
const authStore = useAuthStore()
return watch(
() =>
authStore.isInitialized
? (authStore.currentUser?.uid ?? null)
: undefined,
(userId) => {
if (userId === undefined) return
safelyReportFirebaseAuthState(() =>
telemetry.reportFirebaseAuthState?.(
userId === null
? { status: 'signed_out' }
: { status: 'signed_in', userId }
)
)
},
{ immediate: true }
)
}

View File

@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => {
const analytics = {
identify: vi.fn().mockResolvedValue(undefined),
page: vi.fn(),
track: vi.fn().mockResolvedValue(undefined),
reset: vi.fn(),
register: vi.fn().mockResolvedValue(undefined)
@@ -108,6 +109,89 @@ describe('CustomerIoTelemetryProvider', () => {
expect(hoisted.analytics.register).toHaveBeenCalled()
})
it('reports the current page after registering the in-app plugin', async () => {
const provider = createProvider()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(hoisted.analytics.page).toHaveBeenCalledWith()
expect(hoisted.analytics.register.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.analytics.page.mock.invocationCallOrder[0]
)
})
it('queues page views until the in-app plugin is registered', async () => {
let resolveRegistration: (() => void) | undefined
const registration = new Promise<void>((resolve) => {
resolveRegistration = resolve
})
hoisted.analytics.register.mockReturnValue(registration)
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
expect(hoisted.analytics.page).not.toHaveBeenCalled()
resolveRegistration?.()
await vi.waitFor(() =>
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
)
})
it('reports client-side route changes', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
expect(hoisted.analytics.page).not.toHaveBeenCalled()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(hoisted.analytics.page).toHaveBeenCalledWith()
})
it('continues tracking events and page views when the in-app plugin fails to register', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const registrationError = new Error('in-app setup failed')
hoisted.analytics.register.mockRejectedValue(registrationError)
const provider = createProvider()
provider.trackWorkflowExecution()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'execution_start',
SOURCE
)
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(consoleError).toHaveBeenCalledWith(
'Failed to initialize Customer.io in-app plugin:',
registrationError
)
provider.trackAddApiCreditButtonClicked()
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'app:add_api_credit_button_clicked',
SOURCE
)
)
provider.trackPageView('settings', {
path: 'https://cloud.comfy.org/settings'
})
expect(hoisted.analytics.page).toHaveBeenCalledTimes(2)
})
it('does not initialize without a write key', async () => {
const provider = createProvider({ customer_io: { site_id: SITE_ID } })
await vi.dynamicImportSettled()

View File

@@ -8,6 +8,7 @@ import { TelemetryEvents } from '../../types'
import type {
AuthMetadata,
ExecutionSuccessMetadata,
PageViewMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
TelemetryEventProperties,
@@ -41,7 +42,9 @@ interface CustomerIoIdentity {
export class CustomerIoTelemetryProvider implements TelemetryProvider {
private analytics: AnalyticsBrowser | null = null
private isEnabled = true
private isPageViewTrackingReady = false
private eventQueue: QueuedEvent[] = []
private pageViewQueued = false
private identifiedUser: CustomerIoIdentity | null = null
private sessionIdentity: CustomerIoIdentity | null = null
private operationQueue: Promise<void> = Promise.resolve()
@@ -61,7 +64,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
void import('@customerio/cdp-analytics-browser')
.then(({ AnalyticsBrowser, InAppPlugin }) => {
const analytics = AnalyticsBrowser.load({ writeKey })
void analytics.register(
const inAppRegistration = analytics.register(
InAppPlugin({
siteId,
events: null,
@@ -97,6 +100,17 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
})
void this.flushQueue()
void inAppRegistration
.catch((error) => {
console.error(
'Failed to initialize Customer.io in-app plugin:',
error
)
})
.finally(() => {
this.isPageViewTrackingReady = true
this.flushPageView()
})
})
.catch((error) => {
console.error('Failed to load Customer.io:', error)
@@ -198,6 +212,29 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
})
}
private sendPageView(): void {
void this.analytics?.page()?.catch((error) => {
console.error('Failed to track Customer.io page view:', error)
})
}
private flushPageView(): void {
if (!this.isPageViewTrackingReady || !this.pageViewQueued) {
return
}
this.pageViewQueued = false
this.sendPageView()
}
trackPageView(_pageName: string, _properties?: PageViewMetadata): void {
if (!this.isEnabled) return
if (!this.isPageViewTrackingReady) {
this.pageViewQueued = true
return
}
this.sendPageView()
}
trackAuth(metadata: AuthMetadata): void {
const identity = metadata.user_id
? {