Files
ComfyUI_frontend/src/components/TopMenuSection.test.ts
Benjamin Lu 1d5801d6ef feat: track funnel telemetry attributes (#12778)
## Summary

Adds the frontend telemetry attribution needed to analyze settings,
app-mode, and sharing funnel usage for MAR-321: re-enables three funnel
events that were disabled by default, attaches
app-mode/view-mode/dock-state context to UI click, run, and share
events, and adds a per-session `shell_layout` snapshot plus
right-side-panel toggle tracking.

## Changes

- **What**:
- Removes `setting_changed`, `template_filter_changed`, and
`ui_button_click` from the code-default `DEFAULT_DISABLED_EVENTS` lists
in the Mixpanel and PostHog providers, so these events now send by
default (see deployment note).
- `ui_button_click` now requires an `element_group`; all call sites are
tagged (`sidebar`, `queue`, `actionbar`, `breadcrumb`, `error_dialog`,
`errors_panel`, `graph_menu`, `graph_node`, `selection_toolbox`,
`node_library`, `workflow_actions`, `cloud_notification`, `app_mode`,
`top_menu`, `right_side_panel`) and the GTM provider forwards the field.
- Run events (`run_button_clicked`, GTM `run_workflow`) now carry
required `view_mode`/`is_app_mode` plus a new `dock_state`
(`docked`/`floating`), read from the `Comfy.MenuPosition.Docked`
localStorage key by a new `getActionbarDockState()` util.
- Share funnel events (`share_flow`, `share_link_opened`,
`shared_workflow_run`) now carry required `view_mode`/`is_app_mode`. A
new `useShareFlowContext()` composable dedupes the source/view-mode
context across the share dialog, URL copy field, and `useShareDialog`.
GTM `share_flow` forwards the new fields and still omits `share_id`.
- `shared_workflow_run` attribution is snapshotted onto the queued job
at queue time, so switching app/graph mode while a job runs no longer
misattributes the completion event (falls back to live values when no
snapshot exists).
- New `shell_layout` event fired once per session at graph-ready (cloud
only): `view_mode`, `is_app_mode`, `dock_state`, `actionbar_position`,
`active_sidebar_tab`, `right_side_panel_open`, `bottom_panel_open`,
`open_workflow_tabs`. Forwarded by Mixpanel and PostHog; not sent to
GTM.
- The right side panel open button (top menu) and close button now fire
`ui_button_click` (`right_side_panel_opened`/`right_side_panel_closed`),
covering the panel open-rate gap.
- **Dependencies**: None.

## Review Focus

- `view_mode`/`is_app_mode` changed from optional to required (typed as
`AppMode`) on run/share metadata — check no call sites were missed.
- The queue-time snapshot in `executionStore` (`queuedJob.viewMode ??
mode.value`) and its regression test.
- Share IDs remain limited to the providers/events that already carry
share attribution (GTM still strips `share_id`).
- `shell_layout` cadence is once per session (graph-ready idle
callback), matching the gap analysis's "session snapshot" wording.

Linear: MAR-321

Validation:
- `pnpm test:unit
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts
src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts
src/platform/telemetry/utils/getShellLayoutSnapshot.test.ts
src/platform/workflow/sharing/components/ShareWorkflowDialogContent.test.ts
src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts
src/stores/executionStore.test.ts src/components/TopMenuSection.test.ts
src/components/graph/selectionToolbox/InfoButton.test.ts
src/components/rightSidePanel/errors/useErrorActions.test.ts
src/views/GraphView.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm knip`
- `git diff --check`

## Deployment note

`telemetry_disabled_events` is currently unset in the prod/staging/test
dynamicconfig rows, so the code-default change here is what enables
these events. The remote value remains available as a kill switch, but
it **replaces** the code defaults rather than merging: if ops sets it to
re-disable an event, the list must include every event that should stay
disabled (`tab_count_tracking`, `node_search`,
`node_search_result_selected`, `help_center_*`, `workflow_created`), not
just the new ones.
2026-06-12 19:02:41 +00:00

639 lines
19 KiB
TypeScript

/* eslint-disable testing-library/no-container */
/* eslint-disable testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const mockData = vi.hoisted(() => ({
isLoggedIn: false,
isDesktop: false,
setShowConflictRedDot: (_value: boolean) => {}
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => {
return {
isLoggedIn: computed(() => mockData.isLoggedIn)
}
}
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isNightly: false,
get isDesktop() {
return mockData.isDesktop
}
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => ({
shouldShowRedDot: computed(() => true)
})
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => {
const shouldShowConflictRedDot = ref(false)
mockData.setShowConflictRedDot = (value: boolean) => {
shouldShowConflictRedDot.value = value
}
return {
useConflictAcknowledgment: () => ({
shouldShowRedDot: shouldShowConflictRedDot
})
}
}
)
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: computed(() => true),
openManager: vi.fn()
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
currentUser: null,
loading: false
}))
}))
vi.mock('@/scripts/app', () => ({
app: {
menu: {
element: document.createElement('div')
}
}
}))
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
attachTo?: HTMLElement
}
function createWrapper({
pinia = createTestingPinia({ createSpy: vi.fn }),
stubs = {},
attachTo
}: WrapperOptions = {}) {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue',
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
},
rightSidePanel: {
togglePanel: 'Toggle properties panel'
}
}
}
})
const user = userEvent.setup()
const renderOptions: Record<string, unknown> = {
global: {
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
QueueNotificationBannerHost: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template:
'<div data-testid="context-menu" :data-model="JSON.stringify(model)" />'
},
...stubs
},
directives: {
tooltip: () => {}
}
}
}
if (attachTo) {
renderOptions.container = attachTo.appendChild(
document.createElement('div')
)
}
const { container, unmount } = render(TopMenuSection, renderOptions)
return { container, unmount, user }
}
function getLegacyCommandsContainer(container: Element): HTMLElement {
const legacyContainer = container.querySelector(
'[data-testid="legacy-topbar-container"]'
)
if (!(legacyContainer instanceof HTMLElement)) {
throw new Error('Expected legacy commands container to be present')
}
return legacyContainer
}
function createJob(id: string, status: JobStatus): JobListItem {
return {
id,
status,
create_time: 0,
priority: 0
}
}
function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
function createComfyActionbarStub(actionbarTarget: HTMLElement) {
return defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
}
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
mockData.isDesktop = false
mockData.isLoggedIn = false
mockData.setShowConflictRedDot(false)
})
describe('authentication state', () => {
function createLegacyTabBarWrapper() {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
)
return createWrapper({ pinia })
}
describe('when user is logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = true
})
it('should display CurrentUserButton and not display LoginButton', () => {
const { container } = createLegacyTabBarWrapper()
expect(
container.querySelector('current-user-button-stub')
).not.toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
})
})
describe('when user is not logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = false
})
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('login-button-stub')).not.toBeNull()
expect(container.querySelector('current-user-button-stub')).toBeNull()
})
})
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('current-user-button-stub')).toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
})
})
})
})
it('shows the active jobs label with the current count', async () => {
createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
queueStore.runningTasks = [
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
]
await nextTick()
const queueButton = screen.getByTestId('queue-overlay-toggle')
expect(queueButton.textContent).toContain('3 active')
expect(screen.getByTestId('active-jobs-indicator')).toBeTruthy()
})
it('hides the active jobs indicator when no jobs are active', () => {
createWrapper()
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('tracks right side panel opens', async () => {
const { user } = createWrapper()
await user.click(
screen.getByRole('button', { name: 'Toggle properties panel' })
)
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const { container } = createWrapper({ pinia })
await nextTick()
expect(screen.getByTestId('queue-overlay-toggle')).toBeTruthy()
expect(container.querySelector('queue-progress-overlay-stub')).toBeNull()
})
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const { user } = createWrapper({ pinia })
const commandStore = useCommandStore(pinia)
await user.click(screen.getByTestId('queue-overlay-toggle'))
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.Queue.ToggleOverlay'
)
})
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const { user } = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
await user.click(screen.getByTestId('queue-overlay-toggle'))
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const { user } = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = screen.getByTestId('queue-overlay-toggle')
await user.click(toggleButton)
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
await user.click(toggleButton)
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean,
showRunProgressBar = true
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders inline progress summary when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const { container } = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-inline-progress-summary-stub')
).not.toBeNull()
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const { container } = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-inline-progress-summary-stub')
).toBeNull()
})
it('does not render inline progress summary when run progress bar is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true, false)
const { container } = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-inline-progress-summary-stub')
).toBeNull()
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activeJobId = 'job-1'
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const { unmount } = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueInlineProgressSummary: false
}
})
try {
await nextTick()
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
unmount()
actionbarTarget.remove()
}
})
})
describe(QueueNotificationBannerHost, () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders queue notification banners when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const { container } = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
})
it('renders queue notification banners when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const { container } = createWrapper({ pinia })
await nextTick()
expect(
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
})
it('renders inline summary above banners when both are visible', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const { container } = createWrapper({ pinia })
await nextTick()
const html = container.innerHTML
const inlineSummaryIndex = html.indexOf(
'queue-inline-progress-summary-stub'
)
const queueBannerIndex = html.indexOf(
'queue-notification-banner-host-stub'
)
expect(inlineSummaryIndex).toBeGreaterThan(-1)
expect(queueBannerIndex).toBeGreaterThan(-1)
expect(inlineSummaryIndex).toBeLessThan(queueBannerIndex)
})
it('does not teleport queue notification banners when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activeJobId = 'job-1'
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const { container, unmount } = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueNotificationBannerHost: true
}
})
try {
await nextTick()
expect(
actionbarTarget.querySelector('queue-notification-banner-host-stub')
).toBeNull()
expect(
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
} finally {
unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const { container } = createWrapper()
const menuEl = container.querySelector('[data-testid="context-menu"]')
const model = JSON.parse(
menuEl?.getAttribute('data-model') ?? '[]'
) as MenuItem[]
expect(model[0]?.label).toBe('Clear queue')
expect(model[0]?.disabled).toBe(true)
})
it('enables the clear queue context menu item when queued jobs exist', async () => {
const { container } = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
await nextTick()
const menuEl = container.querySelector('[data-testid="context-menu"]')
const model = JSON.parse(
menuEl?.getAttribute('data-model') ?? '[]'
) as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
it('shows manager red dot only for manager conflicts', async () => {
const { container } = createWrapper()
// Release red dot is mocked as true globally for this test file.
expect(container.querySelector('span.bg-red-500')).toBeNull()
mockData.setShowConflictRedDot(true)
await nextTick()
expect(container.querySelector('span.bg-red-500')).not.toBeNull()
})
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const rafCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
if (key === 'Comfy.RightSidePanel.IsOpen') return true
return undefined
})
const { container, unmount } = createWrapper({
pinia,
attachTo: document.body
})
try {
await nextTick()
const actionbarContainer = container.querySelector('.actionbar-container')
expect(actionbarContainer).not.toBeNull()
expect(actionbarContainer!.classList).toContain('w-0')
const legacyContainer = getLegacyCommandsContainer(container)
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
if (rafCallbacks.length > 0) {
const initialCallbacks = [...rafCallbacks]
rafCallbacks.length = 0
initialCallbacks.forEach((callback) => callback(0))
await nextTick()
}
querySpy.mockClear()
querySpy.mockReturnValue(document.createElement('div'))
for (let index = 0; index < 3; index++) {
const outer = document.createElement('div')
const inner = document.createElement('div')
inner.textContent = `legacy-${index}`
outer.appendChild(inner)
legacyContainer.appendChild(outer)
}
await vi.waitFor(() => {
expect(rafCallbacks.length).toBeGreaterThan(0)
})
expect(querySpy).not.toHaveBeenCalled()
const callbacks = [...rafCallbacks]
rafCallbacks.length = 0
callbacks.forEach((callback) => callback(0))
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer!.classList).toContain('px-2')
} finally {
unmount()
vi.unstubAllGlobals()
}
})
})