Compare commits

..

3 Commits

Author SHA1 Message Date
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
pythongosssss
193f23e8c2 Revert "feat: default search to essentials when graph is empty" (#12814)
Reverts Comfy-Org/ComfyUI_frontend#12377
2026-06-12 18:18:34 +00:00
AustinMroz
eaa6776559 Fix broken e2e test (#12818) 2026-06-12 18:08:47 +00:00
60 changed files with 779 additions and 1196 deletions

View File

@@ -15,7 +15,9 @@ test.describe('Download page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
await expect(page).toHaveTitle(
'Download Comfy Desktop — Run AI on Your Hardware'
)
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {

View File

@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
})
}
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
async setServerFlag(name: string, value: unknown): Promise<void> {
await this.setServerFlags({ [name]: value })
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
})
test.describe('Empty graph defaults', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlag(
'node_library_essentials_enabled',
true
)
})
test('Defaults to Essentials when graph is empty', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await searchBoxV2.open()
const essentialsBtn = searchBoxV2.rootCategoryButton(
RootCategory.Essentials
)
await expect(essentialsBtn).toBeVisible()
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
})
test('Defaults to Most Relevant when graph has nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
await searchBoxV2.open()
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
await expect(
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
).toHaveAttribute('aria-pressed', 'false')
})
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -87,6 +87,14 @@ vi.mock('@/scripts/app', () => ({
}
}))
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -110,6 +118,9 @@ function createWrapper({
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
},
rightSidePanel: {
togglePanel: 'Toggle properties panel'
}
}
}
@@ -266,6 +277,19 @@ describe('TopMenuSection', () => {
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)

View File

@@ -78,7 +78,7 @@
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
@@ -148,6 +148,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
@@ -282,6 +283,14 @@ const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
function openRightSidePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
rightSidePanelStore.togglePanel()
}
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)

View File

@@ -222,7 +222,8 @@ watch(visible, async (newVisible) => {
*/
useEventListener(dragHandleRef, 'mousedown', () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'actionbar_run_handle_drag_start'
button_id: 'actionbar_run_handle_drag_start',
element_group: 'actionbar'
})
})

View File

@@ -131,7 +131,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected'
button_id: 'queue_mode_option_run_on_change_selected',
element_group: 'queue'
})
queueMode.value = 'change'
}
@@ -145,7 +146,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
button_id: 'queue_mode_option_run_instant_selected',
element_group: 'queue'
})
queueMode.value = 'instant-idle'
}
@@ -237,7 +239,8 @@ const queuePrompt = async (e: Event) => {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'queue'
})
}

View File

@@ -88,7 +88,8 @@ const home = computed(() => ({
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
button_id: 'breadcrumb_subgraph_root_selected',
element_group: 'breadcrumb'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -103,7 +104,8 @@ const items = computed(() => {
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected'
button_id: 'breadcrumb_subgraph_item_selected',
element_group: 'breadcrumb'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -40,7 +40,8 @@ function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source
button_id: source,
element_group: 'workflow_actions'
})
}
}

View File

@@ -101,7 +101,8 @@ const reportOpen = ref(false)
*/
const showReport = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_show_report_clicked'
button_id: 'error_dialog_show_report_clicked',
element_group: 'error_dialog'
})
reportOpen.value = true
}

View File

@@ -25,7 +25,8 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
button_id: 'error_dialog_find_existing_issues_clicked',
element_group: 'error_dialog'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`

View File

@@ -218,7 +218,8 @@ onMounted(() => {
*/
const onMinimapToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_minimap_toggle_clicked'
button_id: 'graph_menu_minimap_toggle_clicked',
element_group: 'graph_menu'
})
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
}
@@ -228,7 +229,8 @@ const onMinimapToggleClick = () => {
*/
const onLinkVisibilityToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_hide_links_toggle_clicked'
button_id: 'graph_menu_hide_links_toggle_clicked',
element_group: 'graph_menu'
})
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
}

View File

@@ -65,7 +65,8 @@ describe('InfoButton', () => {
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened'
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
})
})

View File

@@ -24,7 +24,8 @@ const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
})
}
</script>

View File

@@ -14,6 +14,7 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
@@ -106,6 +107,10 @@ const isSingleSubgraphNode = computed(() => {
})
function closePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_closed',
element_group: 'right_side_panel'
})
rightSidePanelStore.closePanel()
}

View File

@@ -58,7 +58,8 @@ describe('useErrorActions', () => {
openGitHubIssues()
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_github_issues_clicked'
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
})
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
@@ -123,7 +124,8 @@ describe('useErrorActions', () => {
findOnGitHub('CUDA out of memory')
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_find_existing_issues_clicked'
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
})
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
expect(windowOpenSpy).toHaveBeenCalledWith(

View File

@@ -9,7 +9,8 @@ export function useErrorActions() {
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
@@ -25,7 +26,8 @@ export function useErrorActions() {
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(

View File

@@ -5,12 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
@@ -74,8 +71,7 @@ describe('NodeSearchBoxPopover', () => {
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] },
defaultRootFilter: { type: String, default: null }
filters: { type: Array, default: () => [] }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
@@ -83,8 +79,7 @@ describe('NodeSearchBoxPopover', () => {
emit('addNode', nodeDef, dragEvent)
return {}
},
template:
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
template: '<div data-testid="search-content-v2"></div>'
})
const pinia = createTestingPinia({
@@ -281,75 +276,4 @@ describe('NodeSearchBoxPopover', () => {
)
})
})
describe('defaultRootFilter on dialog open', () => {
function setGraphNodes(nodes: unknown[]) {
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes },
allow_searchbox: false,
setDirty: vi.fn(),
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
}
async function openSearch() {
useSearchBoxStore().visible = true
await nextTick()
}
it('defaults to Essentials when the graph is empty', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to Essentials when the canvas is not yet available', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to null when the graph has nodes', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
it('re-evaluates each time the dialog opens', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
useSearchBoxStore().visible = false
await nextTick()
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
})
})

View File

@@ -27,7 +27,6 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -78,8 +77,6 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
@@ -91,7 +88,6 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const canvasStore = useCanvasStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
@@ -107,13 +103,6 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const defaultRootFilter = ref<RootCategoryId | null>(null)
watch(visible, (isVisible) => {
if (!isVisible) return
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
? RootCategory.Essentials
: null
})
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -138,6 +127,7 @@ function clearFilters() {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import {
createMockNodeDef,
setViewport,
@@ -231,48 +230,6 @@ describe('NodeSearchContent', () => {
})
})
it('should apply defaultRootFilter when provided and category is available', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
})
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
})
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
})
it('should show only API nodes when Partner Nodes filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

@@ -142,9 +142,8 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
[RootCategory.Custom]: isCustomNode
}
const { filters, defaultRootFilter = null } = defineProps<{
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
defaultRootFilter?: RootCategoryId | null
}>()
const emit = defineEmits<{
@@ -195,12 +194,8 @@ function onSearchFocus() {
if (isMobile.value) isSidebarOpen.value = false
}
const rootFilter = ref<RootCategoryId | null>(
defaultRootFilter === RootCategory.Essentials &&
!nodeAvailability.value.essential
? null
: defaultRootFilter
)
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {

View File

@@ -150,7 +150,8 @@ const telemetry = useTelemetry()
function onLogoMenuClick(event: MouseEvent) {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_comfy_menu_opened'
button_id: 'sidebar_comfy_menu_opened',
element_group: 'sidebar'
})
menuRef.value?.toggle(event)
}
@@ -217,7 +218,8 @@ const extraMenuItems = computed(() => [
icon: 'icon-[lucide--settings]',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened'
button_id: 'sidebar_settings_menu_opened',
element_group: 'sidebar'
})
showSettings()
}
@@ -329,7 +331,8 @@ const handleNodes2ToggleClick = () => {
const onNodes2ToggleChange = async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
telemetry?.trackUiButtonClicked({
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`,
element_group: 'sidebar'
})
}
</script>

View File

@@ -138,19 +138,23 @@ const onTabClick = async (item: SidebarTabExtension) => {
if (isNodeLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_node_library_selected'
button_id: 'sidebar_tab_node_library_selected',
element_group: 'sidebar'
})
else if (isModelLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_model_library_selected'
button_id: 'sidebar_tab_model_library_selected',
element_group: 'sidebar'
})
else if (isWorkflowsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_workflows_selected'
button_id: 'sidebar_tab_workflows_selected',
element_group: 'sidebar'
})
else if (isAssetsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_assets_media_selected'
button_id: 'sidebar_tab_assets_media_selected',
element_group: 'sidebar'
})
await commandStore.commands

View File

@@ -21,7 +21,8 @@ const bottomPanelStore = useBottomPanelStore()
*/
const toggleConsole = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_bottom_panel_console_toggled'
button_id: 'sidebar_bottom_panel_console_toggled',
element_group: 'sidebar'
})
bottomPanelStore.toggleBottomPanel()
}

View File

@@ -30,7 +30,8 @@ const tooltipText = computed(
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked'
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
}
</script>

View File

@@ -37,7 +37,8 @@ const tooltipText = computed(
*/
const toggleShortcutsPanel = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_shortcuts_panel_toggled'
button_id: 'sidebar_shortcuts_panel_toggled',
element_group: 'sidebar'
})
bottomPanelStore.togglePanel('shortcuts')
}

View File

@@ -29,7 +29,8 @@ const isSmall = computed(
*/
const openTemplates = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_templates_dialog_opened'
button_id: 'sidebar_templates_dialog_opened',
element_group: 'sidebar'
})
useWorkflowTemplateSelectorDialog().show('sidebar')
}

View File

@@ -118,7 +118,8 @@ const toggleBookmark = async () => {
const onHelpClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'node_library_help_button'
button_id: 'node_library_help_button',
element_group: 'node_library'
})
props.openNodeHelp(nodeDef.value)
}

View File

@@ -9,16 +9,26 @@ export type AppMode =
| 'builder:outputs'
| 'builder:arrange'
type WorkflowModeSource = {
activeMode: AppMode | null
initialMode: AppMode | null | undefined
}
export function getWorkflowMode(
workflow: WorkflowModeSource | null | undefined
): AppMode {
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
}
export function isAppModeValue(mode: AppMode): boolean {
return mode === 'app' || mode === 'builder:arrange'
}
const enableAppBuilder = ref(true)
export function useAppMode() {
const workflowStore = useWorkflowStore()
const mode = computed(
() =>
workflowStore.activeWorkflow?.activeMode ??
workflowStore.activeWorkflow?.initialMode ??
'graph'
)
const mode = computed(() => getWorkflowMode(workflowStore.activeWorkflow))
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
@@ -29,9 +39,7 @@ export function useAppMode() {
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isAppMode = computed(() => isAppModeValue(mode.value))
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
)

View File

@@ -38,7 +38,8 @@ export function useHelpCenter() {
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_help_center_toggled'
button_id: 'sidebar_help_center_toggled',
element_group: 'sidebar'
})
helpCenterStore.toggle()
}

View File

@@ -1,175 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { IS_CONTROL_WIDGET } from './controlWidgetMarker'
import { runWidgetControl } from '@/core/graph/widgets/widgetControlSystem'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { installWidgetControlHooks } from './useWidgetControlHooks'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.WidgetControlMode' ? 'after' : undefined
})
}))
function controlFor(
store: ReturnType<typeof useWidgetValueStore>,
graph: LGraph,
targetId: string
) {
return store
.getWidgetControls(graph.rootGraph.id)
.find(([id]) => id === targetId)?.[1]
}
function addSeedNode(graph: LGraph): LGraphNode {
const node = new LGraphNode('SeedNode')
node.id = 1
const seed = node.addWidget('number', 'seed', 1, () => {}, {
min: 0,
max: 1_000_000,
step2: 1
})
const control = node.addWidget(
'combo',
'control_after_generate',
'increment',
() => {},
{ values: ['fixed', 'increment', 'decrement', 'randomize'] }
)
;(control as IBaseWidget & Record<symbol, unknown>)[IS_CONTROL_WIDGET] = true
seed.linkedWidgets = [control]
graph.add(node)
return node
}
describe('installWidgetControlHooks', () => {
let graph: LGraph
let store: ReturnType<typeof useWidgetValueStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useWidgetValueStore()
graph = new LGraph()
})
it('registers a control component for a control-target widget', () => {
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
installWidgetControlHooks(graph)
const control = controlFor(store, graph, seedId)
expect(control?.controlWidgetId).toBe(node.widgets![1].widgetId)
})
it('advances the registered target value through the control system', () => {
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
installWidgetControlHooks(graph)
runWidgetControl(graph.rootGraph.id, 'after')
expect(store.getWidget(seedId)?.value).toBe(2)
})
it('removes the control component when the node is removed', () => {
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
installWidgetControlHooks(graph)
expect(controlFor(store, graph, seedId)).toBeDefined()
graph.remove(node)
expect(controlFor(store, graph, seedId)).toBeUndefined()
})
it('registers controls for nodes added after install', () => {
installWidgetControlHooks(graph)
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
expect(controlFor(store, graph, seedId)).toBeDefined()
})
it('restores the original node callback on uninstall', () => {
const node = addSeedNode(graph)
const original = node.onConnectionsChange
const uninstall = installWidgetControlHooks(graph)
expect(node.onConnectionsChange).not.toBe(original)
uninstall()
expect(node.onConnectionsChange).toBe(original)
})
it('does not register a control for a widget with no linked control widget', () => {
const node = new LGraphNode('PlainNode')
node.id = 2
const plain = node.addWidget('number', 'steps', 20, () => {}, {
min: 1,
max: 100,
step2: 1
})
// No linkedWidgets — not a control-target widget
plain.linkedWidgets = undefined
graph.add(node)
installWidgetControlHooks(graph)
const plainId = plain.widgetId!
expect(controlFor(store, graph, plainId)).toBeUndefined()
})
it('installs on an empty graph without errors', () => {
expect(() => installWidgetControlHooks(graph)).not.toThrow()
})
it('uninstall removes the onNodeAdded and onNodeRemoved overrides', () => {
const originalAdded = graph.onNodeAdded
const originalRemoved = graph.onNodeRemoved
const uninstall = installWidgetControlHooks(graph)
expect(graph.onNodeAdded).not.toBe(originalAdded)
expect(graph.onNodeRemoved).not.toBe(originalRemoved)
uninstall()
// After uninstall the callbacks should revert to original (undefined or the
// prior value set before install)
expect(graph.onNodeAdded).toBe(originalAdded || undefined)
expect(graph.onNodeRemoved).toBe(originalRemoved || undefined)
})
it('re-syncs the control when an INPUT connection changes', () => {
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
installWidgetControlHooks(graph)
// Simulate an INPUT connection change on the node
node.onConnectionsChange?.(NodeSlotType.INPUT, 0, true, null as never, null as never)
// Control should still be registered after the re-sync
expect(controlFor(store, graph, seedId)).toBeDefined()
})
it('does not re-sync when an OUTPUT connection changes', () => {
const node = addSeedNode(graph)
const seedId = node.widgets![0].widgetId!
installWidgetControlHooks(graph)
const controlBefore = controlFor(store, graph, seedId)
// Simulate an OUTPUT connection change — should NOT trigger syncNodeControls
node.onConnectionsChange?.(NodeSlotType.OUTPUT, 0, true, null as never, null as never)
// Control state should be unchanged (same reference)
expect(controlFor(store, graph, seedId)).toEqual(controlBefore)
})
})

View File

@@ -1,165 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { IS_CONTROL_WIDGET } from '@/core/graph/widgets/control/controlWidgetMarker'
import {
computeNextControlledValue,
isValueControlMode,
isValueControlWidget
} from './valueControl'
const makeNumberWidget = (
value: number,
options: Partial<IBaseWidget['options']> = {}
): IBaseWidget =>
({
type: 'number',
name: 'seed',
value,
options
}) as unknown as IBaseWidget
const makeComboWidget = (value: string, values: string[]): IBaseWidget =>
({
type: 'combo',
name: 'choice',
value,
options: { values }
}) as unknown as IBaseWidget
describe('isValueControlMode', () => {
it('returns true for each valid mode string', () => {
expect(isValueControlMode('fixed')).toBe(true)
expect(isValueControlMode('increment')).toBe(true)
expect(isValueControlMode('increment-wrap')).toBe(true)
expect(isValueControlMode('decrement')).toBe(true)
expect(isValueControlMode('randomize')).toBe(true)
})
it('returns false for an unrecognized string', () => {
expect(isValueControlMode('after')).toBe(false)
expect(isValueControlMode('before')).toBe(false)
expect(isValueControlMode('')).toBe(false)
})
it('returns false for non-string values', () => {
expect(isValueControlMode(undefined)).toBe(false)
expect(isValueControlMode(null)).toBe(false)
expect(isValueControlMode(42)).toBe(false)
expect(isValueControlMode(true)).toBe(false)
expect(isValueControlMode({})).toBe(false)
})
})
describe('isValueControlWidget', () => {
it('returns true for a marked widget', () => {
const widget = {
[IS_CONTROL_WIDGET]: true
} as unknown as IBaseWidget
expect(isValueControlWidget(widget)).toBe(true)
})
it('returns false when the marker symbol is missing', () => {
const widget = {} as unknown as IBaseWidget
expect(isValueControlWidget(widget)).toBe(false)
})
})
describe('computeNextControlledValue (number)', () => {
it('returns undefined for fixed mode', () => {
expect(
computeNextControlledValue(makeNumberWidget(5), 'fixed')
).toBeUndefined()
})
it('increments by step2', () => {
const widget = makeNumberWidget(5, { min: 0, max: 100, step2: 2 })
expect(computeNextControlledValue(widget, 'increment')).toBe(7)
})
it('decrements by step2', () => {
const widget = makeNumberWidget(5, { min: 0, max: 100, step2: 3 })
expect(computeNextControlledValue(widget, 'decrement')).toBe(2)
})
it('clamps to max on increment', () => {
const widget = makeNumberWidget(99, { min: 0, max: 100, step2: 5 })
expect(computeNextControlledValue(widget, 'increment')).toBe(100)
})
it('clamps to min on decrement', () => {
const widget = makeNumberWidget(1, { min: 0, max: 100, step2: 5 })
expect(computeNextControlledValue(widget, 'decrement')).toBe(0)
})
it('randomizes within range using a seeded random', () => {
const widget = makeNumberWidget(0, { min: 10, max: 20, step2: 1 })
vi.spyOn(Math, 'random').mockReturnValue(0.5)
expect(computeNextControlledValue(widget, 'randomize')).toBe(15)
})
it('returns undefined when target value is not numeric', () => {
const widget = {
type: 'number',
name: 'seed',
value: 'not a number',
options: {}
} as unknown as IBaseWidget
expect(computeNextControlledValue(widget, 'increment')).toBeUndefined()
})
})
describe('computeNextControlledValue (combo)', () => {
it('cycles to the next value on increment', () => {
const widget = makeComboWidget('a', ['a', 'b', 'c'])
expect(computeNextControlledValue(widget, 'increment')).toBe('b')
})
it('clamps at the end on increment without wrap', () => {
const widget = makeComboWidget('c', ['a', 'b', 'c'])
expect(computeNextControlledValue(widget, 'increment')).toBe('c')
})
it('wraps to first value on increment-wrap past the end', () => {
const widget = makeComboWidget('c', ['a', 'b', 'c'])
expect(computeNextControlledValue(widget, 'increment-wrap')).toBe('a')
})
it('cycles to the previous value on decrement', () => {
const widget = makeComboWidget('b', ['a', 'b', 'c'])
expect(computeNextControlledValue(widget, 'decrement')).toBe('a')
})
it('randomizes by index', () => {
const widget = makeComboWidget('a', ['a', 'b', 'c', 'd'])
vi.spyOn(Math, 'random').mockReturnValue(0.6)
expect(computeNextControlledValue(widget, 'randomize')).toBe('c')
})
it('applies a substring filter', () => {
const widget = makeComboWidget('apple', ['apple', 'banana', 'apricot'])
expect(
computeNextControlledValue(widget, 'increment', { comboFilter: 'ap' })
).toBe('apricot')
})
it('applies a regex filter when wrapped in slashes', () => {
const widget = makeComboWidget('foo1', ['foo1', 'bar', 'foo2'])
expect(
computeNextControlledValue(widget, 'increment', { comboFilter: '/foo/' })
).toBe('foo2')
})
it('returns undefined when the filter eliminates all values', () => {
const widget = makeComboWidget('a', ['a', 'b'])
expect(
computeNextControlledValue(widget, 'increment', { comboFilter: 'zzz' })
).toBeUndefined()
})
afterEach(() => {
vi.restoreAllMocks()
})
})

View File

@@ -1,264 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import { runWidgetControl } from './widgetControlSystem'
const controlMode = vi.hoisted(() => ({ value: 'after' as 'before' | 'after' }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.WidgetControlMode' ? controlMode.value : undefined
})
}))
const GRAPH = 'graph'
function seedSetup(
mode: string,
{ value = 1 }: { value?: number } = {}
): { targetId: ReturnType<typeof widgetId> } {
const store = useWidgetValueStore()
const targetId = widgetId(GRAPH, '1', 'seed')
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
store.registerWidget(targetId, {
type: 'number',
value,
options: { min: 0, max: 1_000_000, step2: 1 }
})
store.registerWidget(controlId, {
type: 'combo',
value: mode,
options: {}
})
store.registerWidgetControl(targetId, { controlWidgetId: controlId })
return { targetId }
}
describe('runWidgetControl', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
controlMode.value = 'after'
})
it('increments a controlled value after queueing', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(2)
})
it('leaves the value unchanged when the mode is fixed', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('fixed')
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(1)
})
it('does not run on a target whose input is link-fed', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
store.setInputLinked(targetId, true)
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(1)
})
it('does not run during partial execution', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
runWidgetControl(GRAPH, 'after', { isPartialExecution: true })
expect(store.getWidget(targetId)?.value).toBe(1)
})
it('skips the first queue in before mode, then advances', () => {
controlMode.value = 'before'
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
runWidgetControl(GRAPH, 'before')
expect(store.getWidget(targetId)?.value).toBe(1)
runWidgetControl(GRAPH, 'before')
expect(store.getWidget(targetId)?.value).toBe(2)
})
it('ignores after-phase work when in before mode', () => {
controlMode.value = 'before'
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(1)
})
it('applies a combo filter when advancing a combo value', () => {
const store = useWidgetValueStore()
const targetId = widgetId(GRAPH, '1', 'ckpt')
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
const filterId = widgetId(GRAPH, '1', 'control_filter_list')
store.registerWidget(targetId, {
type: 'combo',
value: 'a.safetensors',
options: { values: ['a.safetensors', 'b.ckpt', 'c.safetensors'] }
})
store.registerWidget(controlId, {
type: 'combo',
value: 'increment',
options: {}
})
store.registerWidget(filterId, {
type: 'string',
value: 'safetensors',
options: {}
})
store.registerWidgetControl(targetId, {
controlWidgetId: controlId,
filterWidgetId: filterId
})
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe('c.safetensors')
})
it('only advances controls belonging to the queued graph', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
const otherTarget = widgetId('other-graph', '1', 'seed')
const otherControl = widgetId('other-graph', '1', 'control_after_generate')
store.registerWidget(otherTarget, {
type: 'number',
value: 1,
options: { min: 0, max: 1_000_000, step2: 1 }
})
store.registerWidget(otherControl, {
type: 'combo',
value: 'increment',
options: {}
})
store.registerWidgetControl(otherTarget, { controlWidgetId: otherControl })
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(2)
expect(store.getWidget(otherTarget)?.value).toBe(1)
})
it('preserves the before-mode skip across re-registration', () => {
controlMode.value = 'before'
const store = useWidgetValueStore()
const { targetId } = seedSetup('increment')
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
runWidgetControl(GRAPH, 'before')
expect(store.getWidget(targetId)?.value).toBe(1)
store.registerWidgetControl(targetId, { controlWidgetId: controlId })
runWidgetControl(GRAPH, 'before')
expect(store.getWidget(targetId)?.value).toBe(2)
})
it('skips a target whose control widget has an invalid mode string', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('not-a-valid-mode')
runWidgetControl(GRAPH, 'after')
// Value should remain unchanged since 'not-a-valid-mode' is not a ValueControlMode
expect(store.getWidget(targetId)?.value).toBe(1)
})
it('skips a target whose control widget is missing from the store', () => {
const store = useWidgetValueStore()
const targetId = widgetId(GRAPH, '1', 'seed')
const missingControlId = widgetId(GRAPH, '1', 'nonexistent_control')
store.registerWidget(targetId, {
type: 'number',
value: 5,
options: { min: 0, max: 100, step2: 1 }
})
store.registerWidgetControl(targetId, { controlWidgetId: missingControlId })
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(5)
})
it('decrements a controlled value after queueing', () => {
const store = useWidgetValueStore()
const { targetId } = seedSetup('decrement', { value: 5 })
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(targetId)?.value).toBe(4)
})
it('ignores a non-string filter widget value', () => {
const store = useWidgetValueStore()
const targetId = widgetId(GRAPH, '1', 'ckpt')
const controlId = widgetId(GRAPH, '1', 'control_after_generate')
const filterId = widgetId(GRAPH, '1', 'control_filter_list')
store.registerWidget(targetId, {
type: 'combo',
value: 'a',
options: { values: ['a', 'b', 'c'] }
})
store.registerWidget(controlId, {
type: 'combo',
value: 'increment',
options: {}
})
// Register filter with a numeric (non-string) value
store.registerWidget(filterId, {
type: 'number',
value: 42,
options: {}
})
store.registerWidgetControl(targetId, {
controlWidgetId: controlId,
filterWidgetId: filterId
})
runWidgetControl(GRAPH, 'after')
// Non-string filter is ignored, so increment advances normally from 'a' to 'b'
expect(store.getWidget(targetId)?.value).toBe('b')
})
it('advances multiple independent controls in the same graph', () => {
const store = useWidgetValueStore()
const { targetId: target1 } = seedSetup('increment', { value: 10 })
const target2 = widgetId(GRAPH, '2', 'cfg')
const control2 = widgetId(GRAPH, '2', 'control_after_generate')
store.registerWidget(target2, {
type: 'number',
value: 20,
options: { min: 0, max: 100, step2: 5 }
})
store.registerWidget(control2, {
type: 'combo',
value: 'decrement',
options: {}
})
store.registerWidgetControl(target2, { controlWidgetId: control2 })
runWidgetControl(GRAPH, 'after')
expect(store.getWidget(target1)?.value).toBe(11)
expect(store.getWidget(target2)?.value).toBe(15)
})
})

View File

@@ -88,20 +88,23 @@ const { t } = useI18n()
onMounted(() => {
// Impression event — uses trackUiButtonClicked as no dedicated impression tracker exists
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_modal_impression'
button_id: 'cloud_notification_modal_impression',
element_group: 'cloud_notification'
})
})
function onDismiss() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_continue_locally_clicked'
button_id: 'cloud_notification_continue_locally_clicked',
element_group: 'cloud_notification'
})
useDialogStore().closeDialog()
}
function onExplore() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_explore_cloud_clicked'
button_id: 'cloud_notification_explore_cloud_clicked',
element_group: 'cloud_notification'
})
const params = new URLSearchParams({

View File

@@ -21,6 +21,7 @@ import type {
PageVisibilityMetadata,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -196,6 +197,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackTabCount?.(metadata))
}
trackShellLayout(metadata: ShellLayoutMetadata): void {
this.dispatch((provider) => provider.trackShellLayout?.(metadata))
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.dispatch((provider) => provider.trackNodeSearch?.(metadata))
}

View File

@@ -1,4 +1,11 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'app' },
isAppMode: { value: true }
})
}))
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
@@ -18,6 +25,7 @@ describe('GtmTelemetryProvider', () => {
window.dataLayer = undefined
window.gtag = undefined
document.head.innerHTML = ''
localStorage.clear()
})
it('injects the GTM runtime script', () => {
@@ -184,11 +192,15 @@ describe('GtmTelemetryProvider', () => {
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
provider.trackRunButton({ trigger_source: 'button' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'run_workflow',
trigger_source: 'button',
subscribe_to_run: false
subscribe_to_run: false,
view_mode: 'app',
is_app_mode: true,
dock_state: 'floating'
})
})
@@ -323,16 +335,33 @@ describe('GtmTelemetryProvider', () => {
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode',
view_mode: 'app',
is_app_mode: true,
share_id: 'share-1'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'share_flow',
step: 'link_copied',
source: 'app_mode'
source: 'app_mode',
view_mode: 'app',
is_app_mode: true
})
expect(lastDataLayerEntry()).not.toHaveProperty('share_id')
})
it('pushes ui_button_click with element_group', () => {
const provider = createInitializedProvider()
provider.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'ui_button_click',
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
})
it('omits share_id from workflow import events', () => {
const provider = createInitializedProvider()
provider.trackWorkflowImported({

View File

@@ -29,6 +29,8 @@ import type {
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { useAppMode } from '@/composables/useAppMode'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
/**
* Google Tag Manager telemetry provider.
@@ -185,9 +187,14 @@ export class GtmTelemetryProvider implements TelemetryProvider {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const { mode, isAppMode } = useAppMode()
this.pushEvent('run_workflow', {
subscribe_to_run: options?.subscribe_to_run ?? false,
trigger_source: options?.trigger_source ?? 'unknown'
trigger_source: options?.trigger_source ?? 'unknown',
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
})
}
@@ -287,7 +294,9 @@ export class GtmTelemetryProvider implements TelemetryProvider {
trackShareFlow(metadata: ShareFlowMetadata): void {
this.pushEvent('share_flow', {
step: metadata.step,
source: metadata.source
source: metadata.source,
view_mode: metadata.view_mode,
is_app_mode: metadata.is_app_mode
})
}
@@ -333,7 +342,8 @@ export class GtmTelemetryProvider implements TelemetryProvider {
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.pushEvent('ui_button_click', {
button_id: metadata.button_id
button_id: metadata.button_id,
element_group: metadata.element_group
})
}

View File

@@ -19,7 +19,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'workflow' },
mode: { value: 'graph' },
isAppMode: { value: false }
})
}))
@@ -60,7 +60,9 @@ import type {
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ShellLayoutMetadata,
SurveyResponses,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
@@ -74,6 +76,10 @@ const waitForMixpanelInit = () =>
type ConfigWindow = { __CONFIG__?: { mixpanel_token?: string } }
beforeEach(() => {
localStorage.clear()
})
describe('MixpanelTelemetryProvider — without configured token', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -165,6 +171,44 @@ describe('MixpanelTelemetryProvider — with configured token', () => {
expect(mockMixpanel.track).not.toHaveBeenCalled()
})
it('tracks enabled funnel events by default', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
const templateFilterMetadata: TemplateFilterMetadata = {
selected_models: [],
selected_use_cases: [],
selected_runs_on: [],
sort_by: 'default',
filtered_count: 1,
total_count: 2
}
provider.trackSettingChanged({ setting_id: 'theme' })
provider.trackTemplateFilterChanged(templateFilterMetadata)
provider.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.SETTING_CHANGED,
{ setting_id: 'theme' }
)
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
templateFilterMetadata
)
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.UI_BUTTON_CLICKED,
{
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
}
)
})
it.for<
[
'opened' | 'requested' | 'completed',
@@ -285,7 +329,21 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
default_view: 'graph'
}
const enterLinearMetadata: EnterLinearMetadata = {}
const shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
const shareFlowMetadata: ShareFlowMetadata = {
step: 'dialog_opened',
view_mode: 'graph',
is_app_mode: false
}
const shellLayoutMetadata: ShellLayoutMetadata = {
view_mode: 'graph',
is_app_mode: false,
dock_state: 'docked',
actionbar_position: 'Top',
active_sidebar_tab: null,
right_side_panel_open: false,
bottom_panel_open: false,
open_workflow_tabs: 1
}
const authMetadata: AuthMetadata = {}
it.for<
@@ -351,6 +409,11 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
(p) => p.trackShareFlow(shareFlowMetadata),
TelemetryEvents.SHARE_FLOW
],
[
'trackShellLayout',
(p) => p.trackShellLayout(shellLayoutMetadata),
TelemetryEvents.SHELL_LAYOUT
],
[
'trackAuth',
(p) => p.trackAuth(authMetadata),
@@ -391,6 +454,7 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
provider.trackRunButton({
subscribe_to_run: true,
@@ -403,8 +467,9 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
subscribe_to_run: true,
workflow_type: 'custom',
trigger_source: 'button',
view_mode: 'workflow',
is_app_mode: false
view_mode: 'graph',
is_app_mode: false,
dock_state: 'floating'
})
)
})
@@ -424,6 +489,8 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode',
view_mode: 'app',
is_app_mode: true,
share_id: 'share-1'
})
@@ -443,7 +510,9 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
TelemetryEvents.SHARE_FLOW,
{
step: 'link_copied',
source: 'app_mode'
source: 'app_mode',
view_mode: 'app',
is_app_mode: true
}
)
})

View File

@@ -28,6 +28,7 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
ShareFlowMetadata,
ShellLayoutMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -47,6 +48,7 @@ import type {
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { TelemetryEvents } from '../../types'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -55,13 +57,10 @@ const DEFAULT_DISABLED_EVENTS = [
TelemetryEvents.TAB_COUNT_TRACKING,
TelemetryEvents.NODE_SEARCH,
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
TelemetryEvents.SETTING_CHANGED,
TelemetryEvents.HELP_CENTER_OPENED,
TelemetryEvents.HELP_RESOURCE_CLICKED,
TelemetryEvents.HELP_CENTER_CLOSED,
TelemetryEvents.WORKFLOW_CREATED,
TelemetryEvents.UI_BUTTON_CLICKED
TelemetryEvents.WORKFLOW_CREATED
] as const satisfies TelemetryEventName[]
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
@@ -297,7 +296,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
@@ -397,6 +397,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackShellLayout(metadata: ShellLayoutMetadata): void {
this.trackEvent(TelemetryEvents.SHELL_LAYOUT, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}

View File

@@ -276,25 +276,50 @@ describe('PostHogTelemetryProvider', () => {
provider.trackShareLinkOpened({
share_id: 'share-1',
is_authenticated: true
is_authenticated: true,
view_mode: 'graph',
is_app_mode: false
})
provider.trackShareFlow({
step: 'link_created',
source: 'app_mode',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
})
provider.trackSharedWorkflowRun({
job_id: 'job-1',
share_id: 'share-1'
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARE_LINK_OPENED,
{
share_id: 'share-1',
is_authenticated: true
is_authenticated: true,
view_mode: 'graph',
is_app_mode: false
}
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARE_FLOW,
{
step: 'link_created',
source: 'app_mode',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
}
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARED_WORKFLOW_RUN,
{
job_id: 'job-1',
share_id: 'share-1'
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
}
)
})
@@ -444,6 +469,71 @@ describe('PostHogTelemetryProvider', () => {
{}
)
})
it('captures enabled funnel events by default', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackSettingChanged({ setting_id: 'theme' })
provider.trackTemplateFilterChanged({
selected_models: [],
selected_use_cases: [],
selected_runs_on: [],
sort_by: 'default',
filtered_count: 1,
total_count: 2
})
provider.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SETTING_CHANGED,
{ setting_id: 'theme' }
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
{
selected_models: [],
selected_use_cases: [],
selected_runs_on: [],
sort_by: 'default',
filtered_count: 1,
total_count: 2
}
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.UI_BUTTON_CLICKED,
{
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
}
)
})
it('captures shell layout snapshots', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const shellLayoutMetadata = {
view_mode: 'graph',
is_app_mode: false,
dock_state: 'floating',
actionbar_position: 'Top',
active_sidebar_tab: 'node-library',
right_side_panel_open: true,
bottom_panel_open: false,
open_workflow_tabs: 2
} as const
provider.trackShellLayout(shellLayoutMetadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHELL_LAYOUT,
shellLayoutMetadata
)
})
})
describe('survey tracking', () => {

View File

@@ -28,6 +28,7 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -45,6 +46,7 @@ import type {
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
import { getExecutionContext } from '../../utils/getExecutionContext'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
@@ -54,13 +56,10 @@ const DEFAULT_DISABLED_EVENTS = [
TelemetryEvents.TAB_COUNT_TRACKING,
TelemetryEvents.NODE_SEARCH,
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
TelemetryEvents.SETTING_CHANGED,
TelemetryEvents.HELP_CENTER_OPENED,
TelemetryEvents.HELP_RESOURCE_CLICKED,
TelemetryEvents.HELP_CENTER_CLOSED,
TelemetryEvents.WORKFLOW_CREATED,
TelemetryEvents.UI_BUTTON_CLICKED
TelemetryEvents.WORKFLOW_CREATED
] as const satisfies TelemetryEventName[]
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
@@ -395,7 +394,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
@@ -497,6 +497,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackShellLayout(metadata: ShellLayoutMetadata): void {
this.trackEvent(TelemetryEvents.SHELL_LAYOUT, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}

View File

@@ -12,6 +12,7 @@
* 3. Check dist/assets/*.js files contain no tracking code
*/
import type { AppMode } from '@/composables/useAppMode'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
@@ -70,8 +71,9 @@ export interface RunButtonProperties {
has_toolkit_nodes: boolean
toolkit_node_names: string[]
trigger_source?: ExecutionTriggerSource
view_mode?: string
is_app_mode?: boolean
view_mode: AppMode
is_app_mode: boolean
dock_state: ActionbarDockState
}
/**
@@ -120,8 +122,12 @@ export interface ExecutionSuccessMetadata {
export interface SharedWorkflowRunMetadata {
job_id: string
share_id: string
view_mode: AppMode
is_app_mode: boolean
}
export type ActionbarDockState = 'docked' | 'floating'
/**
* Template metadata for workflow tracking
*/
@@ -197,11 +203,15 @@ export interface ShareFlowMetadata {
step: ShareFlowStep
source?: 'app_mode' | 'graph_mode'
share_id?: string
view_mode: AppMode
is_app_mode: boolean
}
export interface ShareLinkOpenedMetadata {
share_id: string
is_authenticated: boolean
view_mode: AppMode
is_app_mode: boolean
}
/**
@@ -243,6 +253,20 @@ export interface TabCountMetadata {
tab_count: number
}
/**
* Shell layout snapshot, sent once per session when the app is ready
*/
export interface ShellLayoutMetadata {
view_mode: AppMode
is_app_mode: boolean
dock_state: ActionbarDockState
actionbar_position: string
active_sidebar_tab: string | null
right_side_panel_open: boolean
bottom_panel_open: boolean
open_workflow_tabs: number
}
/**
* Settings change metadata
*/
@@ -327,8 +351,8 @@ export interface TemplateFilterMetadata {
* UI button click tracking metadata
*/
export interface UiButtonClickMetadata {
/** Canonical identifier for the button (e.g., "comfy_logo") */
button_id: string
element_group: string
}
/**
@@ -498,6 +522,9 @@ export interface TelemetryProvider {
// Tab tracking events
trackTabCount?(metadata: TabCountMetadata): void
// Shell layout snapshot events
trackShellLayout?(metadata: ShellLayoutMetadata): void
// Node search analytics events
trackNodeSearch?(metadata: NodeSearchMetadata): void
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
@@ -593,6 +620,9 @@ export const TelemetryEvents = {
// Tab Tracking
TAB_COUNT_TRACKING: 'app:tab_count_tracking',
// Shell Layout
SHELL_LAYOUT: 'app:shell_layout',
// Node Search Analytics
NODE_SEARCH: 'app:node_search',
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
@@ -655,6 +685,7 @@ export type TelemetryEventProperties =
| TemplateLibraryClosedMetadata
| PageVisibilityMetadata
| TabCountMetadata
| ShellLayoutMetadata
| NodeSearchMetadata
| NodeSearchResultMetadata
| SearchQueryMetadata

View File

@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { getActionbarDockState } from './getActionbarDockState'
describe('getActionbarDockState', () => {
beforeEach(() => {
localStorage.clear()
})
it('returns docked when no preference is stored', () => {
expect(getActionbarDockState()).toBe('docked')
})
it('returns docked when the stored preference is true', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'true')
expect(getActionbarDockState()).toBe('docked')
})
it('returns floating when the stored preference is false', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
expect(getActionbarDockState()).toBe('floating')
})
})

View File

@@ -0,0 +1,7 @@
import type { ActionbarDockState } from '@/platform/telemetry/types'
export function getActionbarDockState(): ActionbarDockState {
return localStorage.getItem('Comfy.MenuPosition.Docked') === 'false'
? 'floating'
: 'docked'
}

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
activeSidebarTabId: null as string | null,
rightSidePanelOpen: false,
bottomPanelVisible: false,
openWorkflows: [] as unknown[],
mode: { value: 'graph' },
isAppMode: { value: false }
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: state.mode, isAppMode: state.isAppMode })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (key: string) => state.settings[key] })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({ openWorkflows: state.openWorkflows })
}))
vi.mock('@/stores/workspace/bottomPanelStore', () => ({
useBottomPanelStore: () => ({
bottomPanelVisible: state.bottomPanelVisible
})
}))
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({ isOpen: state.rightSidePanelOpen })
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => ({
activeSidebarTabId: state.activeSidebarTabId
})
}))
import { getShellLayoutSnapshot } from './getShellLayoutSnapshot'
describe('getShellLayoutSnapshot', () => {
beforeEach(() => {
localStorage.clear()
state.settings = { 'Comfy.UseNewMenu': 'Top' }
state.activeSidebarTabId = null
state.rightSidePanelOpen = false
state.bottomPanelVisible = false
state.openWorkflows = []
state.mode.value = 'graph'
state.isAppMode.value = false
})
it('captures the default layout', () => {
expect(getShellLayoutSnapshot()).toEqual({
view_mode: 'graph',
is_app_mode: false,
dock_state: 'docked',
actionbar_position: 'Top',
active_sidebar_tab: null,
right_side_panel_open: false,
bottom_panel_open: false,
open_workflow_tabs: 0
})
})
it('captures a customized layout', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
state.activeSidebarTabId = 'node-library'
state.rightSidePanelOpen = true
state.bottomPanelVisible = true
state.openWorkflows = [{}, {}, {}]
state.mode.value = 'app'
state.isAppMode.value = true
expect(getShellLayoutSnapshot()).toEqual({
view_mode: 'app',
is_app_mode: true,
dock_state: 'floating',
actionbar_position: 'Top',
active_sidebar_tab: 'node-library',
right_side_panel_open: true,
bottom_panel_open: true,
open_workflow_tabs: 3
})
})
})

View File

@@ -0,0 +1,23 @@
import { useAppMode } from '@/composables/useAppMode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { ShellLayoutMetadata } from '../types'
import { getActionbarDockState } from './getActionbarDockState'
export function getShellLayoutSnapshot(): ShellLayoutMetadata {
const { mode, isAppMode } = useAppMode()
return {
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState(),
actionbar_position: useSettingStore().get('Comfy.UseNewMenu'),
active_sidebar_tab: useSidebarTabStore().activeSidebarTabId,
right_side_panel_open: useRightSidePanelStore().isOpen,
bottom_panel_open: useBottomPanelStore().bottomPanelVisible,
open_workflow_tabs: useWorkflowStore().openWorkflows.length
}
}

View File

@@ -26,9 +26,9 @@ import { refAutoReset } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useTelemetry } from '@/platform/telemetry'
import { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
const { url, shareId } = defineProps<{
url: string
@@ -36,7 +36,7 @@ const { url, shareId } = defineProps<{
}>()
const { copyToClipboard } = useCopyToClipboard()
const { isAppMode } = useAppMode()
const shareFlowContext = useShareFlowContext()
const copied = refAutoReset(false, 2000)
async function handleCopy() {
@@ -44,7 +44,7 @@ async function handleCopy() {
copied.value = true
useTelemetry()?.trackShareFlow({
step: 'link_copied',
source: isAppMode.value ? 'app_mode' : 'graph_mode',
...shareFlowContext.value,
share_id: shareId
})
}

View File

@@ -387,6 +387,8 @@ describe('ShareWorkflowDialogContent', () => {
expect(mockTrackShareFlow).toHaveBeenCalledWith({
step: 'link_created',
source: 'graph_mode',
view_mode: 'graph',
is_app_mode: false,
share_id: 'test-123'
})
})
@@ -407,6 +409,8 @@ describe('ShareWorkflowDialogContent', () => {
expect(mockTrackShareFlow).toHaveBeenCalledWith({
step: 'link_copied',
source: 'graph_mode',
view_mode: 'graph',
is_app_mode: false,
share_id: 'copy-123'
})
})

View File

@@ -206,7 +206,7 @@ import type {
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useAppMode } from '@/composables/useAppMode'
import { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTelemetry } from '@/platform/telemetry'
import { appendJsonExt } from '@/utils/formatUtil'
@@ -223,11 +223,7 @@ const publishDialog = useComfyHubPublishDialog()
const shareService = useWorkflowShareService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const { isAppMode } = useAppMode()
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
const shareFlowContext = useShareFlowContext()
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
type DialogMode = 'shareLink' | 'publishToHub'
@@ -355,7 +351,7 @@ async function refreshDialogState() {
dialogState.value = 'unsaved'
useTelemetry()?.trackShareFlow({
step: 'save_prompted',
source: getShareSource()
...shareFlowContext.value
})
if (workflow) {
workflowName.value = stripJsonExtension(workflow.filename)
@@ -440,7 +436,7 @@ const {
acknowledged.value = false
useTelemetry()?.trackShareFlow({
step: 'link_created',
source: getShareSource(),
...shareFlowContext.value,
share_id: result.shareId
})

View File

@@ -1,5 +1,5 @@
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -15,7 +15,7 @@ export function useShareDialog() {
const dialogStore = useDialogStore()
const { pruneLinearData } = useAppModeStore()
const workflowStore = useWorkflowStore()
const { isAppMode } = useAppMode()
const shareFlowContext = useShareFlowContext()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -54,14 +54,10 @@ export function useShareDialog() {
share()
}
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
function showShareDialog() {
useTelemetry()?.trackShareFlow({
step: 'dialog_opened',
source: getShareSource()
...shareFlowContext.value
})
dialogService.showLayoutDialog({
key: DIALOG_KEY,

View File

@@ -0,0 +1,18 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import type { ShareFlowMetadata } from '@/platform/telemetry/types'
type ShareFlowContext = Pick<
ShareFlowMetadata,
'source' | 'view_mode' | 'is_app_mode'
>
export function useShareFlowContext() {
const { mode, isAppMode } = useAppMode()
return computed<ShareFlowContext>(() => ({
source: isAppMode.value ? 'app_mode' : 'graph_mode',
view_mode: mode.value,
is_app_mode: isAppMode.value
}))
}

View File

@@ -38,6 +38,13 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'graph' },
isAppMode: { value: false }
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackShareLinkOpened: mockTrackShareLinkOpened
@@ -255,7 +262,9 @@ describe('useSharedWorkflowUrlLoader', () => {
)
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
share_id: 'share-id-1',
is_authenticated: false
is_authenticated: false,
view_mode: 'graph',
is_app_mode: false
})
expect(preservedQueryMocks.capturePreservedQuery).toHaveBeenCalledWith(
'share_auth',
@@ -281,7 +290,9 @@ describe('useSharedWorkflowUrlLoader', () => {
expect(loaded).toBe('loaded')
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
share_id: 'share-id-1',
is_authenticated: true
is_authenticated: true,
view_mode: 'graph',
is_app_mode: false
})
expect(preservedQueryMocks.capturePreservedQuery).not.toHaveBeenCalled()
})

View File

@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useTelemetry } from '@/platform/telemetry'
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
@@ -45,6 +46,7 @@ export function useSharedWorkflowUrlLoader() {
const dialogStore = useDialogStore()
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
const { isLoggedIn } = useCurrentUser()
const { mode, isAppMode } = useAppMode()
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
function isValidParameter(param: string): boolean {
@@ -146,7 +148,9 @@ export function useSharedWorkflowUrlLoader() {
useTelemetry()?.trackShareLinkOpened({
share_id: shareParam,
is_authenticated: isLoggedIn.value
is_authenticated: isLoggedIn.value,
view_mode: mode.value,
is_app_mode: isAppMode.value
})
if (!isLoggedIn.value) {
capturePreservedQuery(

View File

@@ -57,7 +57,8 @@ async function runButtonClick(e: Event) {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'app_mode'
})
}
await commandStore.execute(commandId, {

View File

@@ -674,7 +674,8 @@ const handleToggleAdvanced = () => {
const handleEnterSubgraph = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_node_open_subgraph_clicked'
button_id: 'graph_node_open_subgraph_clicked',
element_group: 'graph_node'
})
const graph = app.rootGraph
if (!graph) {

View File

@@ -103,7 +103,8 @@ export const useDialogService = () => {
size: 'lg',
onClose: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_closed'
button_id: 'error_dialog_closed',
element_group: 'error_dialog'
})
}
}
@@ -169,7 +170,8 @@ export const useDialogService = () => {
size: 'lg',
onClose: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_closed'
button_id: 'error_dialog_closed',
element_group: 'error_dialog'
})
}
}

View File

@@ -29,6 +29,25 @@ const {
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
}))
const mockAppModeState = vi.hoisted(() => ({
mode: { value: 'graph' },
isAppMode: { value: false }
}))
vi.mock('@/composables/useAppMode', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
useAppMode: () => mockAppModeState
}
})
beforeEach(() => {
mockAppModeState.mode.value = 'graph'
mockAppModeState.isAppMode.value = false
})
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -1128,7 +1147,9 @@ describe('useExecutionStore - WebSocket event handlers', () => {
})
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1'
share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
})
})
@@ -1148,7 +1169,56 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1'
share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
})
})
it('attributes shared workflow run to queue-time mode, not completion-time mode', () => {
const workflow = createQueuedWorkflow()
workflow.shareId = 'share-1'
store.storeJob({
nodes: ['a'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA')
},
workflow
})
mockAppModeState.mode.value = 'app'
mockAppModeState.isAppMode.value = true
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
})
})
it('attributes shared workflow run to the queued workflow, not the active one', () => {
const workflow = createQueuedWorkflow()
workflow.shareId = 'share-1'
workflow.activeMode = 'app'
store.storeJob({
nodes: ['a'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA')
},
workflow
})
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
})
})
})

View File

@@ -2,6 +2,12 @@ import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import type { AppMode } from '@/composables/useAppMode'
import {
getWorkflowMode,
isAppModeValue,
useAppMode
} from '@/composables/useAppMode'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -60,6 +66,12 @@ interface QueuedJob {
* `workflow.shareId`, which can gain attribution after the job was queued.
*/
shareId?: string
/**
* View-mode attribution snapshotted at queue time, so mode switches during
* the run don't misattribute completion events.
*/
viewMode?: AppMode
isAppMode?: boolean
}
function buildExecutionNodeLookup(
@@ -87,6 +99,7 @@ export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const { mode, isAppMode } = useAppMode()
const clientId = ref<string | null>(null)
const activeJobId = ref<JobId | null>(null)
@@ -310,7 +323,9 @@ export const useExecutionStore = defineStore('execution', () => {
if (queuedJob.shareId) {
telemetry?.trackSharedWorkflowRun({
job_id: jobId,
share_id: queuedJob.shareId
share_id: queuedJob.shareId,
view_mode: queuedJob.viewMode ?? mode.value,
is_app_mode: queuedJob.isAppMode ?? isAppMode.value
})
}
}
@@ -594,6 +609,9 @@ export const useExecutionStore = defineStore('execution', () => {
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.workflow = workflow
queuedJob.shareId = workflow?.shareId
const queuedMode = getWorkflowMode(workflow)
queuedJob.viewMode = queuedMode
queuedJob.isAppMode = isAppModeValue(queuedMode)
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
if (wid) {
jobIdToWorkflowId.value.set(id, wid)

View File

@@ -3,25 +3,24 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { UUID } from '@/utils/uuid'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from '@/types/widgetState'
import type { WidgetState } from './widgetValueStore'
import { useWidgetValueStore } from './widgetValueStore'
function state<T>(
function widget<T>(
nodeId: string,
name: string,
type: string,
value: T,
extra: Partial<Omit<WidgetState<T>, 'type' | 'value'>> = {}
): Omit<WidgetState<T>, 'nodeId' | 'name' | 'y'> & { y?: number } {
return { type, value, options: {}, ...extra }
extra: Partial<
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
> = {}
): WidgetState<T> {
return { nodeId, name, type, value, options: {}, ...extra }
}
describe('useWidgetValueStore', () => {
const graphA = 'graph-a' as UUID
const graphB = 'graph-b' as UUID
const seedA = widgetId(graphA, 'node-1', 'seed')
const seedB = widgetId(graphB, 'node-1', 'seed')
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -29,92 +28,62 @@ describe('useWidgetValueStore', () => {
describe('widgetState.value access', () => {
it('getWidget returns undefined for unregistered widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
})
it('widgetState.value can be read and written directly', () => {
const store = useWidgetValueStore()
const registered = store.registerWidget(seedA, state('number', 100))
expect(registered.value).toBe(100)
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
expect(state.value).toBe(100)
registered.value = 200
expect(store.getWidget(seedA)?.value).toBe(200)
state.value = 200
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
})
it('stores different value types', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'text', 'string', 'hello'))
store.registerWidget(graphA, widget('node-1', 'number', 'number', 42))
store.registerWidget(graphA, widget('node-1', 'boolean', 'toggle', true))
store.registerWidget(
widgetId(graphA, 'node-1', 'text'),
state('string', 'hello')
)
store.registerWidget(
widgetId(graphA, 'node-1', 'number'),
state('number', 42)
)
store.registerWidget(
widgetId(graphA, 'node-1', 'boolean'),
state('toggle', true)
)
store.registerWidget(
widgetId(graphA, 'node-1', 'array'),
state('combo', [1, 2, 3])
graphA,
widget('node-1', 'array', 'combo', [1, 2, 3])
)
expect(store.getWidget(widgetId(graphA, 'node-1', 'text'))?.value).toBe(
'hello'
)
expect(store.getWidget(widgetId(graphA, 'node-1', 'number'))?.value).toBe(
42
)
expect(
store.getWidget(widgetId(graphA, 'node-1', 'boolean'))?.value
).toBe(true)
expect(
store.getWidget(widgetId(graphA, 'node-1', 'array'))?.value
).toEqual([1, 2, 3])
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
1, 2, 3
])
})
})
describe('widget registration', () => {
it('registers a widget with minimal properties', () => {
const store = useWidgetValueStore()
const registered = store.registerWidget(seedA, state('number', 12345))
expect(registered.nodeId).toBe('node-1')
expect(registered.name).toBe('seed')
expect(registered.type).toBe('number')
expect(registered.value).toBe(12345)
expect(registered.disabled).toBeUndefined()
expect(registered.serialize).toBeUndefined()
expect(registered.options).toEqual({})
expect(registered.y).toBe(0)
})
it('registers explicit widget layout y', () => {
const store = useWidgetValueStore()
const registered = store.registerWidget(
seedA,
state('number', 12345, { y: 42 })
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 12345)
)
expect(registered.y).toBe(42)
})
it('registerWidget is idempotent and does not overwrite existing state', () => {
const store = useWidgetValueStore()
const first = store.registerWidget(seedA, state('number', 11))
first.value = 99
const second = store.registerWidget(seedA, state('number', 11))
expect(second).toBe(first)
expect(second.value).toBe(99)
expect(state.nodeId).toBe('node-1')
expect(state.name).toBe('seed')
expect(state.type).toBe('number')
expect(state.value).toBe(12345)
expect(state.disabled).toBeUndefined()
expect(state.serialize).toBeUndefined()
expect(state.options).toEqual({})
})
it('registers a widget with all properties', () => {
const store = useWidgetValueStore()
const registered = store.registerWidget(
seedA,
state('string', 'test', {
const state = store.registerWidget(
graphA,
widget('node-1', 'prompt', 'string', 'test', {
label: 'Prompt Text',
disabled: true,
serialize: false,
@@ -122,38 +91,34 @@ describe('useWidgetValueStore', () => {
})
)
expect(registered.label).toBe('Prompt Text')
expect(registered.disabled).toBe(true)
expect(registered.serialize).toBe(false)
expect(registered.options).toEqual({ multiline: true })
expect(state.label).toBe('Prompt Text')
expect(state.disabled).toBe(true)
expect(state.serialize).toBe(false)
expect(state.options).toEqual({ multiline: true })
})
})
describe('widget getters', () => {
it('getWidget returns widget state', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 100))
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
const registered = store.getWidget(seedA)
expect(registered).toBeDefined()
expect(registered?.name).toBe('seed')
expect(registered?.value).toBe(100)
const state = store.getWidget(graphA, 'node-1', 'seed')
expect(state).toBeDefined()
expect(state?.name).toBe('seed')
expect(state?.value).toBe(100)
})
it('getWidget returns undefined for missing widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
})
it('getNodeWidgets returns all widgets for a node', () => {
const store = useWidgetValueStore()
store.registerWidget(
widgetId(graphA, 'node-1', 'seed'),
state('number', 1)
)
store.registerWidget(
widgetId(graphA, 'node-1', 'steps'),
state('number', 20)
)
store.registerWidget(
widgetId(graphA, 'node-2', 'cfg'),
state('number', 7)
)
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
const widgets = store.getNodeWidgets(graphA, 'node-1')
expect(widgets).toHaveLength(2)
@@ -161,231 +126,54 @@ describe('useWidgetValueStore', () => {
})
})
describe('value mutation', () => {
it('setValue updates registered widgets and reports missing widgets', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 100))
expect(store.setValue(seedA, 200)).toBe(true)
expect(store.getWidget(seedA)?.value).toBe(200)
expect(store.setValue(widgetId(graphA, 'missing', 'seed'), 1)).toBe(false)
})
it('deleteWidget removes registered widgets', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 100))
expect(store.deleteWidget(seedA)).toBe(true)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.deleteWidget(seedA)).toBe(false)
})
})
describe('direct property mutation', () => {
it('disabled can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const registered = store.registerWidget(seedA, state('number', 100))
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
registered.disabled = true
expect(store.getWidget(seedA)?.disabled).toBe(true)
state.disabled = true
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
})
it('label can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const registered = store.registerWidget(seedA, state('number', 100))
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
registered.label = 'Random Seed'
expect(store.getWidget(seedA)?.label).toBe('Random Seed')
state.label = 'Random Seed'
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
'Random Seed'
)
registered.label = undefined
expect(store.getWidget(seedA)?.label).toBeUndefined()
state.label = undefined
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
})
})
describe('graph isolation', () => {
it('isolates widget states by graph', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
store.registerWidget(seedB, state('number', 2))
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
expect(store.getWidget(seedA)?.value).toBe(1)
expect(store.getWidget(seedB)?.value).toBe(2)
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
})
it('clearGraph only removes one graph namespace', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
store.registerWidget(seedB, state('number', 2))
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
store.clearGraph(graphA)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getWidget(seedB)?.value).toBe(2)
})
})
describe('setInputLinked', () => {
it('sets inputLinked to true on a registered widget', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
store.setInputLinked(seedA, true)
expect(store.getWidget(seedA)?.inputLinked).toBe(true)
})
it('sets inputLinked to false on a previously linked widget', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
store.setInputLinked(seedA, true)
store.setInputLinked(seedA, false)
expect(store.getWidget(seedA)?.inputLinked).toBe(false)
})
it('is a no-op when the widget is not registered', () => {
const store = useWidgetValueStore()
// Should not throw even for an unknown id
expect(() => store.setInputLinked(seedA, true)).not.toThrow()
})
})
describe('registerWidgetControl / getWidgetControls / deleteWidgetControl', () => {
const controlId = widgetId(graphA, 'node-1', 'control_after_generate')
it('registers a control component and returns the live state', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
const ctrl = store.registerWidgetControl(seedA, {
controlWidgetId: controlId
})
expect(ctrl.controlWidgetId).toBe(controlId)
expect(ctrl.filterWidgetId).toBeUndefined()
expect(ctrl.hasExecuted).toBe(false)
})
it('includes the control in getWidgetControls for the same graph', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
const controls = store.getWidgetControls(graphA)
expect(controls).toHaveLength(1)
expect(controls[0][0]).toBe(seedA)
expect(controls[0][1].controlWidgetId).toBe(controlId)
})
it('returns an empty array when no controls are registered', () => {
const store = useWidgetValueStore()
expect(store.getWidgetControls(graphA)).toHaveLength(0)
})
it('preserves hasExecuted when re-registering the same control and filter ids', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
const ctrl = store.registerWidgetControl(seedA, {
controlWidgetId: controlId
})
ctrl.hasExecuted = true
const ctrl2 = store.registerWidgetControl(seedA, {
controlWidgetId: controlId
})
expect(ctrl2.hasExecuted).toBe(true)
})
it('resets hasExecuted when re-registering with a different control widget id', () => {
const store = useWidgetValueStore()
const otherControlId = widgetId(graphA, 'node-1', 'other_control')
store.registerWidget(seedA, state('number', 1))
const ctrl = store.registerWidgetControl(seedA, {
controlWidgetId: controlId
})
ctrl.hasExecuted = true
const ctrl2 = store.registerWidgetControl(seedA, {
controlWidgetId: otherControlId
})
expect(ctrl2.hasExecuted).toBe(false)
})
it('stores and returns optional filterWidgetId', () => {
const store = useWidgetValueStore()
const filterId = widgetId(graphA, 'node-1', 'filter')
store.registerWidget(seedA, state('number', 1))
const ctrl = store.registerWidgetControl(seedA, {
controlWidgetId: controlId,
filterWidgetId: filterId
})
expect(ctrl.filterWidgetId).toBe(filterId)
})
it('deleteWidgetControl removes the control and returns true', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 1))
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
expect(store.deleteWidgetControl(seedA)).toBe(true)
expect(store.getWidgetControls(graphA)).toHaveLength(0)
})
it('deleteWidgetControl returns false for an unregistered target', () => {
const store = useWidgetValueStore()
expect(store.deleteWidgetControl(seedA)).toBe(false)
})
it('isolates controls across graphs', () => {
const store = useWidgetValueStore()
const controlB = widgetId(graphB, 'node-1', 'control_after_generate')
store.registerWidget(seedA, state('number', 1))
store.registerWidget(seedB, state('number', 2))
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
store.registerWidgetControl(seedB, { controlWidgetId: controlB })
expect(store.getWidgetControls(graphA)).toHaveLength(1)
expect(store.getWidgetControls(graphB)).toHaveLength(1)
})
})
describe('deleteWidget also removes the associated control', () => {
it('removes the widget control entry when the widget is deleted', () => {
const store = useWidgetValueStore()
const controlId = widgetId(graphA, 'node-1', 'control_after_generate')
store.registerWidget(seedA, state('number', 1))
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
expect(store.getWidgetControls(graphA)).toHaveLength(1)
store.deleteWidget(seedA)
expect(store.getWidgetControls(graphA)).toHaveLength(0)
})
})
describe('clearGraph also removes controls', () => {
it('removes both widget states and control entries for the cleared graph', () => {
const store = useWidgetValueStore()
const controlId = widgetId(graphA, 'node-1', 'control_after_generate')
const controlB = widgetId(graphB, 'node-1', 'control_after_generate')
store.registerWidget(seedA, state('number', 1))
store.registerWidget(seedB, state('number', 2))
store.registerWidgetControl(seedA, { controlWidgetId: controlId })
store.registerWidgetControl(seedB, { controlWidgetId: controlB })
store.clearGraph(graphA)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getWidgetControls(graphA)).toHaveLength(0)
// Other graph should be unaffected
expect(store.getWidget(seedB)?.value).toBe(2)
expect(store.getWidgetControls(graphB)).toHaveLength(1)
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
})
})
})

View File

@@ -68,6 +68,7 @@ import DesktopCloudNotificationController from '@/platform/cloud/notification/co
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { getShellLayoutSnapshot } from '@/platform/telemetry/utils/getShellLayoutSnapshot'
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -351,6 +352,11 @@ const onGraphReady = () => {
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })
}
// Shell layout snapshot, once per session (cloud only)
if (isCloud && telemetry) {
telemetry.trackShellLayout(getShellLayoutSnapshot())
}
// Setting values now available after comfyApp.setup.
// Load keybindings.
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()