mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-13 01:38:10 +00:00
Compare commits
2 Commits
main
...
version-bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b36542a4d | ||
|
|
62a61ac594 |
@@ -15,9 +15,7 @@ test.describe('Download page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Download Comfy Desktop — Run AI on Your Hardware'
|
||||
)
|
||||
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
|
||||
})
|
||||
|
||||
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
|
||||
|
||||
@@ -51,6 +51,20 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -137,8 +137,7 @@ export const TestIds = {
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red',
|
||||
convertSubgraph: 'convert-to-subgraph-button',
|
||||
bypass: 'bypass-button'
|
||||
convertSubgraph: 'convert-to-subgraph-button'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
|
||||
@@ -309,6 +309,50 @@ 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
|
||||
|
||||
@@ -129,18 +129,23 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// A group + a KSampler node
|
||||
await comfyPage.workflow.loadWorkflow('groups/single_group')
|
||||
const bypass = comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass)
|
||||
|
||||
// Select group + node should show bypass button
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(bypass).toBeVisible()
|
||||
await comfyPage.keyboard.delete()
|
||||
await comfyPage.page.keyboard.press('Control+A')
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'.selection-toolbox *[data-testid="bypass-button"]'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
// (Only empty group is selected) should hide bypass button
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(bypass).toBeHidden()
|
||||
// Deselect node (Only group is selected) should hide bypass button
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'.selection-toolbox *[data-testid="bypass-button"]'
|
||||
)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test.describe('Color Picker', () => {
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
|
||||
|
||||
const CREATE_GROUP_HOTKEY = 'Control+g'
|
||||
|
||||
@@ -219,40 +217,4 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Bypassing a group bypasses contents', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('.')
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
|
||||
const toggleBypass = () =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass).click()
|
||||
const bypassCount = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.nodes.filter((node) => node.mode === 4).length
|
||||
)
|
||||
expect(await bypassCount()).toBe(0)
|
||||
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
|
||||
await expect.poll(groupCount, 'create group').toBe(1)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await ksampler.select()
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'setup bypass of single node').toBe(1)
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.click(groupPos.x, groupPos.y)
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'all nodes are set to bypassed').toBe(7)
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'all nodes are unbypassed').toBe(0)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await ksampler.select()
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.13",
|
||||
"version": "1.46.14",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -87,14 +87,6 @@ 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>
|
||||
@@ -118,9 +110,6 @@ function createWrapper({
|
||||
activeJobsShort: '{count} active | {count} active',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
},
|
||||
rightSidePanel: {
|
||||
togglePanel: 'Toggle properties panel'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,19 +266,6 @@ 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)
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="openRightSidePanel"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
@@ -148,7 +148,6 @@ 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'
|
||||
@@ -283,14 +282,6 @@ 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)
|
||||
|
||||
@@ -222,8 +222,7 @@ watch(visible, async (newVisible) => {
|
||||
*/
|
||||
useEventListener(dragHandleRef, 'mousedown', () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'actionbar_run_handle_drag_start',
|
||||
element_group: 'actionbar'
|
||||
button_id: 'actionbar_run_handle_drag_start'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -131,8 +131,7 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
|
||||
tooltip: t('menu.onChangeTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_on_change_selected',
|
||||
element_group: 'queue'
|
||||
button_id: 'queue_mode_option_run_on_change_selected'
|
||||
})
|
||||
queueMode.value = 'change'
|
||||
}
|
||||
@@ -146,8 +145,7 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
|
||||
tooltip: t('menu.instantTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_instant_selected',
|
||||
element_group: 'queue'
|
||||
button_id: 'queue_mode_option_run_instant_selected'
|
||||
})
|
||||
queueMode.value = 'instant-idle'
|
||||
}
|
||||
@@ -239,8 +237,7 @@ const queuePrompt = async (e: Event) => {
|
||||
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted',
|
||||
element_group: 'queue'
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -88,8 +88,7 @@ const home = computed(() => ({
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected',
|
||||
element_group: 'breadcrumb'
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
@@ -104,8 +103,7 @@ const items = computed(() => {
|
||||
key: `subgraph-${subgraph.id}`,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_item_selected',
|
||||
element_group: 'breadcrumb'
|
||||
button_id: 'breadcrumb_subgraph_item_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -40,8 +40,7 @@ function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source,
|
||||
element_group: 'workflow_actions'
|
||||
button_id: source
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,7 @@ const reportOpen = ref(false)
|
||||
*/
|
||||
const showReport = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_show_report_clicked',
|
||||
element_group: 'error_dialog'
|
||||
button_id: 'error_dialog_show_report_clicked'
|
||||
})
|
||||
reportOpen.value = true
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_find_existing_issues_clicked',
|
||||
element_group: 'error_dialog'
|
||||
button_id: 'error_dialog_find_existing_issues_clicked'
|
||||
})
|
||||
const query = encodeURIComponent(queryString.value)
|
||||
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`
|
||||
|
||||
@@ -218,8 +218,7 @@ onMounted(() => {
|
||||
*/
|
||||
const onMinimapToggleClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'graph_menu_minimap_toggle_clicked',
|
||||
element_group: 'graph_menu'
|
||||
button_id: 'graph_menu_minimap_toggle_clicked'
|
||||
})
|
||||
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
|
||||
}
|
||||
@@ -229,8 +228,7 @@ const onMinimapToggleClick = () => {
|
||||
*/
|
||||
const onLinkVisibilityToggleClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'graph_menu_hide_links_toggle_clicked',
|
||||
element_group: 'graph_menu'
|
||||
button_id: 'graph_menu_hide_links_toggle_clicked'
|
||||
})
|
||||
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
|
||||
}
|
||||
|
||||
@@ -101,7 +101,6 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
|
||||
const {
|
||||
hasAnySelection,
|
||||
hasGroupedNodesSelection,
|
||||
hasMultipleSelection,
|
||||
isSingleNode,
|
||||
isSingleSubgraph,
|
||||
@@ -119,10 +118,7 @@ const showSubgraphButtons = computed(() => isSingleSubgraph.value)
|
||||
|
||||
const showBypass = computed(
|
||||
() =>
|
||||
isSingleNode.value ||
|
||||
isSingleSubgraph.value ||
|
||||
hasMultipleSelection.value ||
|
||||
hasGroupedNodesSelection.value
|
||||
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
|
||||
)
|
||||
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
|
||||
const showMaskEditor = computed(() => isSingleImageNode.value)
|
||||
|
||||
@@ -65,8 +65,7 @@ describe('InfoButton', () => {
|
||||
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
|
||||
button_id: 'selection_toolbox_node_info_opened',
|
||||
element_group: 'selection_toolbox'
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@ const onInfoClick = () => {
|
||||
if (!openNodeInfo()) return
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'selection_toolbox_node_info_opened',
|
||||
element_group: 'selection_toolbox'
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,6 @@ 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'
|
||||
@@ -107,10 +106,6 @@ const isSingleSubgraphNode = computed(() => {
|
||||
})
|
||||
|
||||
function closePanel() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'right_side_panel_closed',
|
||||
element_group: 'right_side_panel'
|
||||
})
|
||||
rightSidePanelStore.closePanel()
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,7 @@ describe('useErrorActions', () => {
|
||||
openGitHubIssues()
|
||||
|
||||
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'error_tab_github_issues_clicked',
|
||||
element_group: 'errors_panel'
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
mocks.staticUrls.githubIssues,
|
||||
@@ -124,8 +123,7 @@ describe('useErrorActions', () => {
|
||||
findOnGitHub('CUDA out of memory')
|
||||
|
||||
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'error_tab_find_existing_issues_clicked',
|
||||
element_group: 'errors_panel'
|
||||
button_id: 'error_tab_find_existing_issues_clicked'
|
||||
})
|
||||
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
|
||||
@@ -9,8 +9,7 @@ export function useErrorActions() {
|
||||
|
||||
function openGitHubIssues() {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked',
|
||||
element_group: 'errors_panel'
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
@@ -26,8 +25,7 @@ export function useErrorActions() {
|
||||
|
||||
function findOnGitHub(errorMessage: string) {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_find_existing_issues_clicked',
|
||||
element_group: 'errors_panel'
|
||||
button_id: 'error_tab_find_existing_issues_clicked'
|
||||
})
|
||||
const query = encodeURIComponent(errorMessage + ' is:issue')
|
||||
window.open(
|
||||
|
||||
@@ -5,9 +5,12 @@ 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'
|
||||
@@ -71,7 +74,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
const NodeSearchContentStub = defineComponent({
|
||||
name: 'NodeSearchContent',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
filters: { type: Array, default: () => [] },
|
||||
defaultRootFilter: { type: String, default: null }
|
||||
},
|
||||
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
|
||||
setup(_, { emit }) {
|
||||
@@ -79,7 +83,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
emit('addNode', nodeDef, dragEvent)
|
||||
return {}
|
||||
},
|
||||
template: '<div data-testid="search-content-v2"></div>'
|
||||
template:
|
||||
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
@@ -276,4 +281,75 @@ 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<div v-if="useSearchBoxV2" role="search" class="relative">
|
||||
<NodeSearchContent
|
||||
:filters="nodeFilters"
|
||||
:default-root-filter="defaultRootFilter"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
@@ -77,6 +78,8 @@ 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'
|
||||
@@ -88,6 +91,7 @@ let disconnectOnReset = false
|
||||
const settingStore = useSettingStore()
|
||||
const searchBoxStore = useSearchBoxStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
|
||||
|
||||
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
|
||||
@@ -103,6 +107,13 @@ 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]
|
||||
@@ -127,7 +138,6 @@ function clearFilters() {
|
||||
function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -230,6 +231,48 @@ 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({
|
||||
|
||||
@@ -142,8 +142,9 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
[RootCategory.Custom]: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
const { filters, defaultRootFilter = null } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
defaultRootFilter?: RootCategoryId | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -194,8 +195,12 @@ function onSearchFocus() {
|
||||
if (isMobile.value) isSidebarOpen.value = false
|
||||
}
|
||||
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<RootCategoryId | null>(null)
|
||||
const rootFilter = ref<RootCategoryId | null>(
|
||||
defaultRootFilter === RootCategory.Essentials &&
|
||||
!nodeAvailability.value.essential
|
||||
? null
|
||||
: defaultRootFilter
|
||||
)
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
|
||||
@@ -150,8 +150,7 @@ const telemetry = useTelemetry()
|
||||
|
||||
function onLogoMenuClick(event: MouseEvent) {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_comfy_menu_opened',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_comfy_menu_opened'
|
||||
})
|
||||
menuRef.value?.toggle(event)
|
||||
}
|
||||
@@ -218,8 +217,7 @@ const extraMenuItems = computed(() => [
|
||||
icon: 'icon-[lucide--settings]',
|
||||
command: () => {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_settings_menu_opened',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_settings_menu_opened'
|
||||
})
|
||||
showSettings()
|
||||
}
|
||||
@@ -331,8 +329,7 @@ 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'}`,
|
||||
element_group: 'sidebar'
|
||||
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -138,23 +138,19 @@ const onTabClick = async (item: SidebarTabExtension) => {
|
||||
|
||||
if (isNodeLibraryTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_node_library_selected',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_tab_node_library_selected'
|
||||
})
|
||||
else if (isModelLibraryTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_model_library_selected',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_tab_model_library_selected'
|
||||
})
|
||||
else if (isWorkflowsTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_workflows_selected',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_tab_workflows_selected'
|
||||
})
|
||||
else if (isAssetsTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_assets_media_selected',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_tab_assets_media_selected'
|
||||
})
|
||||
|
||||
await commandStore.commands
|
||||
|
||||
@@ -21,8 +21,7 @@ const bottomPanelStore = useBottomPanelStore()
|
||||
*/
|
||||
const toggleConsole = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_bottom_panel_console_toggled',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_bottom_panel_console_toggled'
|
||||
})
|
||||
bottomPanelStore.toggleBottomPanel()
|
||||
}
|
||||
|
||||
@@ -30,8 +30,7 @@ const tooltipText = computed(
|
||||
const showSettingsDialog = () => {
|
||||
command.function()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_settings_button_clicked',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_settings_button_clicked'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,8 +37,7 @@ const tooltipText = computed(
|
||||
*/
|
||||
const toggleShortcutsPanel = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_shortcuts_panel_toggled',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_shortcuts_panel_toggled'
|
||||
})
|
||||
bottomPanelStore.togglePanel('shortcuts')
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ const isSmall = computed(
|
||||
*/
|
||||
const openTemplates = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_templates_dialog_opened',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_templates_dialog_opened'
|
||||
})
|
||||
useWorkflowTemplateSelectorDialog().show('sidebar')
|
||||
}
|
||||
|
||||
@@ -118,8 +118,7 @@ const toggleBookmark = async () => {
|
||||
|
||||
const onHelpClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'node_library_help_button',
|
||||
element_group: 'node_library'
|
||||
button_id: 'node_library_help_button'
|
||||
})
|
||||
props.openNodeHelp(nodeDef.value)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
CreateTopupResponse,
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse,
|
||||
@@ -19,9 +16,7 @@ export interface SubscriptionInfo {
|
||||
tier: SubscriptionTier | null
|
||||
duration: SubscriptionDuration | null
|
||||
planSlug: string | null
|
||||
/** ISO 8601 */
|
||||
renewalDate: string | null
|
||||
/** ISO 8601 */
|
||||
endDate: string | null
|
||||
isCancelled: boolean
|
||||
hasFunds: boolean
|
||||
@@ -49,9 +44,6 @@ export interface BillingActions {
|
||||
) => Promise<PreviewSubscribeResponse | null>
|
||||
manageSubscription: () => Promise<void>
|
||||
cancelSubscription: () => Promise<void>
|
||||
resubscribe: () => Promise<void>
|
||||
/** `amountCents` must be a whole-dollar multiple of 100. */
|
||||
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
|
||||
fetchPlans: () => Promise<void>
|
||||
/**
|
||||
* Ensures billing is initialized and subscription is active.
|
||||
@@ -73,12 +65,16 @@ export interface BillingState {
|
||||
currentPlanSlug: ComputedRef<string | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
/**
|
||||
* Convenience computed for checking if subscription is active.
|
||||
* Equivalent to `subscription.value?.isActive ?? false`
|
||||
*/
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
/**
|
||||
* Whether the current billing context has a FREE tier subscription.
|
||||
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
|
||||
*/
|
||||
isFreeTier: ComputedRef<boolean>
|
||||
billingStatus: ComputedRef<BillingStatus | null>
|
||||
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
|
||||
tier: ComputedRef<SubscriptionTier | null>
|
||||
renewalDate: ComputedRef<string | null>
|
||||
}
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
|
||||
@@ -5,17 +5,13 @@ import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn()
|
||||
}))
|
||||
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
|
||||
() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const original = await importOriginal()
|
||||
@@ -54,9 +50,8 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
subscriptionTier: { value: 'PRO' },
|
||||
subscriptionDuration: { value: 'MONTHLY' },
|
||||
subscriptionStatus: {
|
||||
value: { renewal_date: '2025-01-01T00:00:00Z', end_date: null }
|
||||
},
|
||||
formattedRenewalDate: { value: 'Jan 1, 2025' },
|
||||
formattedEndDate: { value: '' },
|
||||
isCancelled: { value: false },
|
||||
fetchStatus: vi.fn().mockResolvedValue(undefined),
|
||||
manageSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -75,12 +70,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
purchaseCredits: mockPurchaseCredits
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
balance: { amount_micros: 5000000 },
|
||||
@@ -140,7 +129,7 @@ describe('useBillingContext', () => {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: '2025-01-01T00:00:00Z',
|
||||
renewalDate: 'Jan 1, 2025',
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
@@ -184,13 +173,6 @@ describe('useBillingContext', () => {
|
||||
await expect(manageSubscription()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('converts topup cents to whole dollars for the legacy credit endpoint', async () => {
|
||||
const { topup } = useBillingContext()
|
||||
await topup(500)
|
||||
|
||||
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('provides isActiveSubscription convenience computed', () => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
|
||||
@@ -122,15 +122,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
const subscriptionStatus = computed(() =>
|
||||
toValue(activeContext.value.subscriptionStatus)
|
||||
)
|
||||
const tier = computed(() => toValue(activeContext.value.tier))
|
||||
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
|
||||
|
||||
function getMaxSeats(tierKey: TierKey): number {
|
||||
if (type.value === 'legacy') return 1
|
||||
|
||||
@@ -227,14 +218,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.cancelSubscription()
|
||||
}
|
||||
|
||||
async function resubscribe() {
|
||||
return activeContext.value.resubscribe()
|
||||
}
|
||||
|
||||
async function topup(amountCents: number) {
|
||||
return activeContext.value.topup(amountCents)
|
||||
}
|
||||
|
||||
async function fetchPlans() {
|
||||
return activeContext.value.fetchPlans()
|
||||
}
|
||||
@@ -258,10 +241,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
getMaxSeats,
|
||||
|
||||
initialize,
|
||||
@@ -271,8 +250,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
resubscribe,
|
||||
topup,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
@@ -27,7 +24,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
isActiveSubscription: legacyIsActiveSubscription,
|
||||
subscriptionTier,
|
||||
subscriptionDuration,
|
||||
subscriptionStatus: legacySubscriptionStatus,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
isCancelled,
|
||||
fetchStatus: legacyFetchStatus,
|
||||
manageSubscription: legacyManageSubscription,
|
||||
@@ -36,7 +34,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
} = useSubscription()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
@@ -55,8 +52,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
tier: subscriptionTier.value,
|
||||
duration: subscriptionDuration.value,
|
||||
planSlug: null, // Legacy doesn't use plan slugs
|
||||
renewalDate: legacySubscriptionStatus.value?.renewal_date ?? null,
|
||||
endDate: legacySubscriptionStatus.value?.end_date ?? null,
|
||||
renewalDate: formattedRenewalDate.value || null,
|
||||
endDate: formattedEndDate.value || null,
|
||||
isCancelled: isCancelled.value,
|
||||
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
|
||||
}
|
||||
@@ -78,18 +75,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
}
|
||||
})
|
||||
|
||||
// Legacy has no coarse billing_status concept (workspace-only).
|
||||
const billingStatus = computed<BillingStatus | null>(() => null)
|
||||
const subscriptionStatus = computed<BillingSubscriptionStatus | null>(() => {
|
||||
if (isCancelled.value) return 'canceled'
|
||||
if (legacyIsActiveSubscription.value) return 'active'
|
||||
return null
|
||||
})
|
||||
const tier = computed(() => subscriptionTier.value)
|
||||
const renewalDate = computed(
|
||||
() => legacySubscriptionStatus.value?.renewal_date ?? null
|
||||
)
|
||||
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
const plans = computed(() => [])
|
||||
const currentPlanSlug = computed(() => null)
|
||||
@@ -167,16 +152,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
await legacyManageSubscription()
|
||||
}
|
||||
|
||||
async function resubscribe(): Promise<void> {
|
||||
// Legacy has no resubscribe endpoint; resubscribing is a fresh checkout.
|
||||
await legacySubscribe()
|
||||
}
|
||||
|
||||
async function topup(amountCents: number): Promise<void> {
|
||||
// Facade standardizes on cents; legacy /customers/credit takes dollars.
|
||||
await authActions.purchaseCredits(amountCents / 100)
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
// Plans are hardcoded in the UI for legacy subscriptions
|
||||
@@ -204,10 +179,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
@@ -217,8 +188,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
resubscribe,
|
||||
topup,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { uniq } from 'es-toolkit'
|
||||
|
||||
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { collectFromNodes } from '@/utils/graphTraversalUtil'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling selected LiteGraph items filtering and operations.
|
||||
@@ -73,13 +71,7 @@ export function useSelectedLiteGraphItems() {
|
||||
* the prior null-tolerance for callers wired to early-firing commands.
|
||||
*/
|
||||
const getSelectedNodesShallow = (): LGraphNode[] =>
|
||||
uniq(
|
||||
[...(canvasStore.canvas?.selectedItems ?? [])].flatMap((item) => {
|
||||
if (isLGraphNode(item)) return [item]
|
||||
if (isLGraphGroup(item)) return [...item.children].filter(isLGraphNode)
|
||||
return []
|
||||
})
|
||||
)
|
||||
Array.from(canvasStore.canvas?.selectedItems ?? []).filter(isLGraphNode)
|
||||
|
||||
/**
|
||||
* Get only the selected nodes (LGraphNode instances) from the canvas.
|
||||
|
||||
@@ -7,12 +7,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import {
|
||||
isImageNode,
|
||||
isLGraphGroup,
|
||||
isLGraphNode,
|
||||
isLoad3dNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
export interface NodeSelectionState {
|
||||
@@ -46,11 +41,6 @@ export function useSelectionState() {
|
||||
const hasAnySelection = computed(() => selectedItems.value.length > 0)
|
||||
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
|
||||
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
|
||||
const hasGroupedNodesSelection = computed(() =>
|
||||
selectedItems.value.some(
|
||||
(item) => isLGraphGroup(item) && [...item.children].some(isLGraphNode)
|
||||
)
|
||||
)
|
||||
|
||||
const isSingleNode = computed(
|
||||
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
|
||||
@@ -122,7 +112,6 @@ export function useSelectionState() {
|
||||
openNodeInfo,
|
||||
hasAny3DNodeSelected,
|
||||
hasAnySelection,
|
||||
hasGroupedNodesSelection,
|
||||
hasSingleSelection,
|
||||
hasMultipleSelection,
|
||||
isSingleNode,
|
||||
|
||||
@@ -9,26 +9,16 @@ 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(() => getWorkflowMode(workflowStore.activeWorkflow))
|
||||
const mode = computed(
|
||||
() =>
|
||||
workflowStore.activeWorkflow?.activeMode ??
|
||||
workflowStore.activeWorkflow?.initialMode ??
|
||||
'graph'
|
||||
)
|
||||
|
||||
const isBuilderMode = computed(
|
||||
() => isSelectMode.value || isArrangeMode.value
|
||||
@@ -39,7 +29,9 @@ export function useAppMode() {
|
||||
() => isSelectInputsMode.value || isSelectOutputsMode.value
|
||||
)
|
||||
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
|
||||
const isAppMode = computed(() => isAppModeValue(mode.value))
|
||||
const isAppMode = computed(
|
||||
() => mode.value === 'app' || mode.value === 'builder:arrange'
|
||||
)
|
||||
const isGraphMode = computed(
|
||||
() => mode.value === 'graph' || isSelectMode.value
|
||||
)
|
||||
|
||||
@@ -38,8 +38,7 @@ export function useHelpCenter() {
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_help_center_toggled',
|
||||
element_group: 'sidebar'
|
||||
button_id: 'sidebar_help_center_toggled'
|
||||
})
|
||||
helpCenterStore.toggle()
|
||||
}
|
||||
|
||||
@@ -16825,7 +16825,7 @@
|
||||
},
|
||||
"replacement_mode": {
|
||||
"name": "replacement_mode",
|
||||
"tooltip": "False = mask_video has black bg (Animation Mode). True = white bg (Replacement Mode). Set the matching replacement_mode on WanSCAILToVideo. reference_image_mask is always black-bg regardless."
|
||||
"tooltip": "False = Animation Mode (pose_video_mask has black background, reference_image_mask has white background). True = Replacement Mode (pose_video_mask has white background, reference_image_mask has black background)."
|
||||
},
|
||||
"ref_track_data": {
|
||||
"name": "ref_track_data",
|
||||
|
||||
@@ -88,23 +88,20 @@ const { t } = useI18n()
|
||||
onMounted(() => {
|
||||
// Impression event — uses trackUiButtonClicked as no dedicated impression tracker exists
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'cloud_notification_modal_impression',
|
||||
element_group: 'cloud_notification'
|
||||
button_id: 'cloud_notification_modal_impression'
|
||||
})
|
||||
})
|
||||
|
||||
function onDismiss() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'cloud_notification_continue_locally_clicked',
|
||||
element_group: 'cloud_notification'
|
||||
button_id: 'cloud_notification_continue_locally_clicked'
|
||||
})
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
function onExplore() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'cloud_notification_explore_cloud_clicked',
|
||||
element_group: 'cloud_notification'
|
||||
button_id: 'cloud_notification_explore_cloud_clicked'
|
||||
})
|
||||
|
||||
const params = new URLSearchParams({
|
||||
|
||||
@@ -147,8 +147,7 @@ describe('OAuthConsentView', () => {
|
||||
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
csrfToken: 'csrf-token',
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace',
|
||||
expectedRedirectUri: 'http://127.0.0.1:50632/cb'
|
||||
workspaceId: 'personal-workspace'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -283,8 +283,7 @@ async function submit(decision: 'allow' | 'deny') {
|
||||
oauthRequestId: challenge.value.oauth_request_id,
|
||||
csrfToken: challenge.value.csrf_token,
|
||||
decision,
|
||||
workspaceId,
|
||||
expectedRedirectUri: challenge.value.redirect_uri
|
||||
workspaceId
|
||||
})
|
||||
clearOAuthRequestId()
|
||||
} catch (error) {
|
||||
|
||||
@@ -220,111 +220,6 @@ describe('submitOAuthConsentDecision', () => {
|
||||
).rejects.toThrow('redirect_url')
|
||||
})
|
||||
|
||||
it('navigates to a reverse-DNS custom-scheme redirect_url (native clients)', async () => {
|
||||
// RFC 8252 native-app callback — the comfy-ios client returns the
|
||||
// authorization code via org.comfy.ios://oauth-callback. The backend
|
||||
// has already validated the URL byte-identically against the client's
|
||||
// registered redirect_uris.
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({
|
||||
redirect_url: 'org.comfy.ios://oauth-callback?code=xyz&state=s'
|
||||
})
|
||||
)
|
||||
const originalLocation = globalThis.location
|
||||
const hrefSetter = vi.fn()
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new Proxy(originalLocation, {
|
||||
set(_target, prop, value) {
|
||||
if (prop === 'href') {
|
||||
hrefSetter(value)
|
||||
return true
|
||||
}
|
||||
return Reflect.set(originalLocation, prop, value)
|
||||
},
|
||||
get(_target, prop) {
|
||||
return Reflect.get(originalLocation, prop)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await submitOAuthConsentDecision({
|
||||
oauthRequestId: validChallenge.oauth_request_id,
|
||||
csrfToken: validChallenge.csrf_token,
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace',
|
||||
expectedRedirectUri: 'org.comfy.ios://oauth-callback'
|
||||
})
|
||||
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
'org.comfy.ios://oauth-callback?code=xyz&state=s'
|
||||
)
|
||||
expect(hrefSetter).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it.for([
|
||||
[
|
||||
'org.comfy.ios://oauth-callback?code=xyz',
|
||||
undefined,
|
||||
'unsafe scheme',
|
||||
'custom scheme with no expectedRedirectUri is unbindable, falls back to the http(s)-only rule'
|
||||
],
|
||||
[
|
||||
'com.evil.app://oauth-callback?code=xyz',
|
||||
'org.comfy.ios://oauth-callback',
|
||||
'does not match',
|
||||
'bound challenge, different scheme: wrong-client redirect'
|
||||
],
|
||||
[
|
||||
'org.comfy.ios://oauth-callback/../steal?code=xyz',
|
||||
'org.comfy.ios://oauth-callback',
|
||||
'does not match',
|
||||
'bound challenge, same scheme but different path'
|
||||
],
|
||||
[
|
||||
'javascript:alert(1)',
|
||||
'javascript:alert(1)',
|
||||
'unsafe scheme',
|
||||
'executable schemes are rejected even if the challenge claims them'
|
||||
],
|
||||
[
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'data:text/html,x',
|
||||
'unsafe scheme',
|
||||
'data: scheme rejected even if the challenge claims it'
|
||||
],
|
||||
[
|
||||
'blob:https://cloud.comfy.org/abc',
|
||||
undefined,
|
||||
'unsafe scheme',
|
||||
'blob: scheme is unsafe'
|
||||
]
|
||||
] as const)(
|
||||
'rejects redirect_url %s (registration %s, expects %s): %s',
|
||||
async ([redirectUrl, expectedRedirectUri, expectedError]) => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({ redirect_url: redirectUrl })
|
||||
)
|
||||
|
||||
await expect(
|
||||
submitOAuthConsentDecision({
|
||||
oauthRequestId: validChallenge.oauth_request_id,
|
||||
csrfToken: validChallenge.csrf_token,
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace',
|
||||
expectedRedirectUri
|
||||
})
|
||||
).rejects.toThrow(expectedError)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects an unsafe redirect_url scheme', async () => {
|
||||
// Defense in depth: even though the cloud backend is trusted, never
|
||||
// hand the browser off to a non-http(s) URL.
|
||||
|
||||
@@ -40,33 +40,12 @@ export type OAuthConsentDecisionParams = {
|
||||
csrfToken: string
|
||||
decision: 'allow' | 'deny'
|
||||
workspaceId: string
|
||||
/**
|
||||
* The challenge's registered `redirect_uri`. When present, the
|
||||
* post-consent navigation must match it (scheme, authority, path) —
|
||||
* the server only appends `code`/`state` query params to the
|
||||
* registered URI, so any other destination is rejected. When absent
|
||||
* (challenges from backends that don't surface it yet), only http(s)
|
||||
* redirects are navigable.
|
||||
*/
|
||||
expectedRedirectUri?: string
|
||||
}
|
||||
|
||||
export type OAuthConsentDecision = (
|
||||
params: OAuthConsentDecisionParams
|
||||
) => Promise<void>
|
||||
|
||||
// Schemes that execute in our origin if navigated. Never navigable,
|
||||
// regardless of what the backend returns. Everything else is governed
|
||||
// by binding to the challenge's registered redirect_uri — no per-client
|
||||
// scheme knowledge lives in the frontend.
|
||||
const EXECUTABLE_SCHEMES: ReadonlySet<string> = new Set([
|
||||
'javascript:',
|
||||
'data:',
|
||||
'blob:',
|
||||
'vbscript:',
|
||||
'about:'
|
||||
])
|
||||
|
||||
export class OAuthApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -139,8 +118,7 @@ export async function submitOAuthConsentDecision({
|
||||
oauthRequestId,
|
||||
csrfToken,
|
||||
decision,
|
||||
workspaceId,
|
||||
expectedRedirectUri
|
||||
workspaceId
|
||||
}: OAuthConsentDecisionParams): Promise<void> {
|
||||
const response = await fetch('/oauth/authorize', {
|
||||
method: 'POST',
|
||||
@@ -166,56 +144,13 @@ export async function submitOAuthConsentDecision({
|
||||
throw new Error('OAuth consent response did not include redirect_url')
|
||||
}
|
||||
|
||||
// Defense in depth at this sink. Two risks: schemes that execute in our
|
||||
// origin (always rejected, below), and the OS routing the authorization
|
||||
// code + state to whichever installed app claims an arbitrary custom
|
||||
// scheme. For the latter we hold the navigation to the redirect the
|
||||
// backend registered for THIS auth request (the challenge's
|
||||
// redirect_uri): the server only ever appends code/state query params
|
||||
// to the registered URI, so scheme, authority, and path must match
|
||||
// exactly. No per-client scheme list lives in the frontend — new native
|
||||
// clients need only their backend registration.
|
||||
const parseTarget = () => {
|
||||
try {
|
||||
return new URL(redirectUrl, globalThis.location.origin)
|
||||
} catch (err) {
|
||||
throw new Error('OAuth consent redirect_url is not a valid URL', {
|
||||
cause: err
|
||||
})
|
||||
}
|
||||
}
|
||||
const target = parseTarget()
|
||||
if (EXECUTABLE_SCHEMES.has(target.protocol)) {
|
||||
throw new Error('OAuth consent redirect_url has an unsafe scheme')
|
||||
}
|
||||
if (expectedRedirectUri) {
|
||||
const parseExpected = () => {
|
||||
try {
|
||||
return new URL(expectedRedirectUri)
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'OAuth consent challenge redirect_uri is not a valid URL',
|
||||
{ cause: err }
|
||||
)
|
||||
}
|
||||
}
|
||||
const expected = parseExpected()
|
||||
const matchesRegistration =
|
||||
target.protocol === expected.protocol &&
|
||||
target.host === expected.host &&
|
||||
target.pathname === expected.pathname
|
||||
if (!matchesRegistration) {
|
||||
throw new Error(
|
||||
'OAuth consent redirect_url does not match the registered redirect_uri'
|
||||
)
|
||||
}
|
||||
} else if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
||||
// Challenges that don't surface redirect_uri can't be bound; hold the
|
||||
// pre-existing http(s)-only line for them.
|
||||
// Defense in depth: even though the cloud backend is trusted, never hand
|
||||
// the browser off to a non-http(s) scheme. javascript:/data: URLs would
|
||||
// execute in our origin.
|
||||
const target = new URL(redirectUrl, globalThis.location.origin)
|
||||
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
||||
throw new Error('OAuth consent redirect_url has an unsafe scheme')
|
||||
}
|
||||
|
||||
// Navigate the parsed URL, not the raw string, so the value validated
|
||||
// above is byte-for-byte the value the browser receives.
|
||||
globalThis.location.href = target.href
|
||||
globalThis.location.href = redirectUrl
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
PageVisibilityMetadata,
|
||||
SettingChangedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
ShellLayoutMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -197,10 +196,6 @@ 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))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
mode: { value: 'app' },
|
||||
isAppMode: { value: true }
|
||||
})
|
||||
}))
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
|
||||
|
||||
@@ -25,7 +18,6 @@ describe('GtmTelemetryProvider', () => {
|
||||
window.dataLayer = undefined
|
||||
window.gtag = undefined
|
||||
document.head.innerHTML = ''
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('injects the GTM runtime script', () => {
|
||||
@@ -192,15 +184,11 @@ 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,
|
||||
view_mode: 'app',
|
||||
is_app_mode: true,
|
||||
dock_state: 'floating'
|
||||
subscribe_to_run: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -335,33 +323,16 @@ 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',
|
||||
view_mode: 'app',
|
||||
is_app_mode: true
|
||||
source: 'app_mode'
|
||||
})
|
||||
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({
|
||||
|
||||
@@ -29,8 +29,6 @@ import type {
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { getActionbarDockState } from '../../utils/getActionbarDockState'
|
||||
|
||||
/**
|
||||
* Google Tag Manager telemetry provider.
|
||||
@@ -187,14 +185,9 @@ 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',
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value,
|
||||
dock_state: getActionbarDockState()
|
||||
trigger_source: options?.trigger_source ?? 'unknown'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,9 +287,7 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.pushEvent('share_flow', {
|
||||
step: metadata.step,
|
||||
source: metadata.source,
|
||||
view_mode: metadata.view_mode,
|
||||
is_app_mode: metadata.is_app_mode
|
||||
source: metadata.source
|
||||
})
|
||||
}
|
||||
|
||||
@@ -342,8 +333,7 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
|
||||
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
|
||||
this.pushEvent('ui_button_click', {
|
||||
button_id: metadata.button_id,
|
||||
element_group: metadata.element_group
|
||||
button_id: metadata.button_id
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
mode: { value: 'graph' },
|
||||
mode: { value: 'workflow' },
|
||||
isAppMode: { value: false }
|
||||
})
|
||||
}))
|
||||
@@ -60,9 +60,7 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ShellLayoutMetadata,
|
||||
SurveyResponses,
|
||||
TemplateFilterMetadata,
|
||||
TemplateLibraryClosedMetadata,
|
||||
TemplateLibraryMetadata,
|
||||
TemplateMetadata,
|
||||
@@ -76,10 +74,6 @@ const waitForMixpanelInit = () =>
|
||||
|
||||
type ConfigWindow = { __CONFIG__?: { mixpanel_token?: string } }
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('MixpanelTelemetryProvider — without configured token', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -171,44 +165,6 @@ 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',
|
||||
@@ -329,21 +285,7 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
default_view: 'graph'
|
||||
}
|
||||
const enterLinearMetadata: EnterLinearMetadata = {}
|
||||
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 shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
|
||||
const authMetadata: AuthMetadata = {}
|
||||
|
||||
it.for<
|
||||
@@ -409,11 +351,6 @@ 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),
|
||||
@@ -454,7 +391,6 @@ 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,
|
||||
@@ -467,9 +403,8 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
subscribe_to_run: true,
|
||||
workflow_type: 'custom',
|
||||
trigger_source: 'button',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false,
|
||||
dock_state: 'floating'
|
||||
view_mode: 'workflow',
|
||||
is_app_mode: false
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -489,8 +424,6 @@ 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'
|
||||
})
|
||||
|
||||
@@ -510,9 +443,7 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
TelemetryEvents.SHARE_FLOW,
|
||||
{
|
||||
step: 'link_copied',
|
||||
source: 'app_mode',
|
||||
view_mode: 'app',
|
||||
is_app_mode: true
|
||||
source: 'app_mode'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
ShareFlowMetadata,
|
||||
ShellLayoutMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -48,7 +47,6 @@ 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 = [
|
||||
@@ -57,10 +55,13 @@ 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.WORKFLOW_CREATED,
|
||||
TelemetryEvents.UI_BUTTON_CLICKED
|
||||
] as const satisfies TelemetryEventName[]
|
||||
|
||||
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
|
||||
@@ -296,8 +297,7 @@ 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,
|
||||
dock_state: getActionbarDockState()
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
@@ -397,10 +397,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -276,50 +276,25 @@ describe('PostHogTelemetryProvider', () => {
|
||||
|
||||
provider.trackShareLinkOpened({
|
||||
share_id: 'share-1',
|
||||
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
|
||||
is_authenticated: true
|
||||
})
|
||||
provider.trackSharedWorkflowRun({
|
||||
job_id: 'job-1',
|
||||
share_id: 'share-1',
|
||||
view_mode: 'app',
|
||||
is_app_mode: true
|
||||
share_id: 'share-1'
|
||||
})
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.SHARE_LINK_OPENED,
|
||||
{
|
||||
share_id: 'share-1',
|
||||
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
|
||||
is_authenticated: true
|
||||
}
|
||||
)
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.SHARED_WORKFLOW_RUN,
|
||||
{
|
||||
job_id: 'job-1',
|
||||
share_id: 'share-1',
|
||||
view_mode: 'app',
|
||||
is_app_mode: true
|
||||
share_id: 'share-1'
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -469,71 +444,6 @@ 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', () => {
|
||||
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
ShellLayoutMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -46,7 +45,6 @@ import type {
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { getActionbarDockState } from '../../utils/getActionbarDockState'
|
||||
import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
@@ -56,10 +54,13 @@ 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.WORKFLOW_CREATED,
|
||||
TelemetryEvents.UI_BUTTON_CLICKED
|
||||
] as const satisfies TelemetryEventName[]
|
||||
|
||||
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
|
||||
@@ -394,8 +395,7 @@ 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,
|
||||
dock_state: getActionbarDockState()
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
@@ -497,10 +497,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* 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'
|
||||
@@ -71,9 +70,8 @@ export interface RunButtonProperties {
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
view_mode: AppMode
|
||||
is_app_mode: boolean
|
||||
dock_state: ActionbarDockState
|
||||
view_mode?: string
|
||||
is_app_mode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,12 +120,8 @@ 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
|
||||
*/
|
||||
@@ -203,15 +197,11 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,20 +243,6 @@ 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
|
||||
*/
|
||||
@@ -351,8 +327,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -522,9 +498,6 @@ 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
|
||||
@@ -620,9 +593,6 @@ 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',
|
||||
@@ -685,7 +655,6 @@ export type TelemetryEventProperties =
|
||||
| TemplateLibraryClosedMetadata
|
||||
| PageVisibilityMetadata
|
||||
| TabCountMetadata
|
||||
| ShellLayoutMetadata
|
||||
| NodeSearchMetadata
|
||||
| NodeSearchResultMetadata
|
||||
| SearchQueryMetadata
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { ActionbarDockState } from '@/platform/telemetry/types'
|
||||
|
||||
export function getActionbarDockState(): ActionbarDockState {
|
||||
return localStorage.getItem('Comfy.MenuPosition.Docked') === 'false'
|
||||
? 'floating'
|
||||
: 'docked'
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 shareFlowContext = useShareFlowContext()
|
||||
const { isAppMode } = useAppMode()
|
||||
const copied = refAutoReset(false, 2000)
|
||||
|
||||
async function handleCopy() {
|
||||
@@ -44,7 +44,7 @@ async function handleCopy() {
|
||||
copied.value = true
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_copied',
|
||||
...shareFlowContext.value,
|
||||
source: isAppMode.value ? 'app_mode' : 'graph_mode',
|
||||
share_id: shareId
|
||||
})
|
||||
}
|
||||
|
||||
@@ -387,8 +387,6 @@ describe('ShareWorkflowDialogContent', () => {
|
||||
expect(mockTrackShareFlow).toHaveBeenCalledWith({
|
||||
step: 'link_created',
|
||||
source: 'graph_mode',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false,
|
||||
share_id: 'test-123'
|
||||
})
|
||||
})
|
||||
@@ -409,8 +407,6 @@ describe('ShareWorkflowDialogContent', () => {
|
||||
expect(mockTrackShareFlow).toHaveBeenCalledWith({
|
||||
step: 'link_copied',
|
||||
source: 'graph_mode',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false,
|
||||
share_id: 'copy-123'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
@@ -223,7 +223,11 @@ const publishDialog = useComfyHubPublishDialog()
|
||||
const shareService = useWorkflowShareService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const shareFlowContext = useShareFlowContext()
|
||||
const { isAppMode } = useAppMode()
|
||||
|
||||
function getShareSource() {
|
||||
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
|
||||
}
|
||||
|
||||
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
|
||||
type DialogMode = 'shareLink' | 'publishToHub'
|
||||
@@ -351,7 +355,7 @@ async function refreshDialogState() {
|
||||
dialogState.value = 'unsaved'
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'save_prompted',
|
||||
...shareFlowContext.value
|
||||
source: getShareSource()
|
||||
})
|
||||
if (workflow) {
|
||||
workflowName.value = stripJsonExtension(workflow.filename)
|
||||
@@ -436,7 +440,7 @@ const {
|
||||
acknowledged.value = false
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_created',
|
||||
...shareFlowContext.value,
|
||||
source: getShareSource(),
|
||||
share_id: result.shareId
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
|
||||
import { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
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 shareFlowContext = useShareFlowContext()
|
||||
const { isAppMode } = useAppMode()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
@@ -54,10 +54,14 @@ export function useShareDialog() {
|
||||
share()
|
||||
}
|
||||
|
||||
function getShareSource() {
|
||||
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
|
||||
}
|
||||
|
||||
function showShareDialog() {
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'dialog_opened',
|
||||
...shareFlowContext.value
|
||||
source: getShareSource()
|
||||
})
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
}))
|
||||
}
|
||||
@@ -38,13 +38,6 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
mode: { value: 'graph' },
|
||||
isAppMode: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackShareLinkOpened: mockTrackShareLinkOpened
|
||||
@@ -262,9 +255,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
)
|
||||
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
|
||||
share_id: 'share-id-1',
|
||||
is_authenticated: false,
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false
|
||||
is_authenticated: false
|
||||
})
|
||||
expect(preservedQueryMocks.capturePreservedQuery).toHaveBeenCalledWith(
|
||||
'share_auth',
|
||||
@@ -290,9 +281,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
expect(loaded).toBe('loaded')
|
||||
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
|
||||
share_id: 'share-id-1',
|
||||
is_authenticated: true,
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false
|
||||
is_authenticated: true
|
||||
})
|
||||
expect(preservedQueryMocks.capturePreservedQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -46,7 +45,6 @@ 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 {
|
||||
@@ -148,9 +146,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
|
||||
useTelemetry()?.trackShareLinkOpened({
|
||||
share_id: shareParam,
|
||||
is_authenticated: isLoggedIn.value,
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value
|
||||
is_authenticated: isLoggedIn.value
|
||||
})
|
||||
if (!isLoggedIn.value) {
|
||||
capturePreservedQuery(
|
||||
|
||||
@@ -196,13 +196,9 @@ export interface PreviewSubscribeResponse {
|
||||
new_plan: PreviewPlanInfo
|
||||
}
|
||||
|
||||
export type BillingSubscriptionStatus =
|
||||
| 'active'
|
||||
| 'scheduled'
|
||||
| 'ended'
|
||||
| 'canceled'
|
||||
type BillingSubscriptionStatus = 'active' | 'scheduled' | 'ended' | 'canceled'
|
||||
|
||||
export type BillingStatus =
|
||||
type BillingStatus =
|
||||
| 'awaiting_payment_method'
|
||||
| 'pending_payment'
|
||||
| 'paid'
|
||||
@@ -237,7 +233,7 @@ interface CreateTopupRequest {
|
||||
|
||||
type TopupStatus = 'pending' | 'completed' | 'failed'
|
||||
|
||||
export interface CreateTopupResponse {
|
||||
interface CreateTopupResponse {
|
||||
billing_op_id: string
|
||||
topup_id: string
|
||||
status: TopupStatus
|
||||
|
||||
@@ -371,6 +371,7 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
@@ -403,8 +404,7 @@ const {
|
||||
manageSubscription,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
getMaxSeats,
|
||||
resubscribe
|
||||
getMaxSeats
|
||||
} = useBillingContext()
|
||||
|
||||
const { showCancelSubscriptionDialog } = useDialogService()
|
||||
@@ -415,12 +415,13 @@ const isResubscribing = ref(false)
|
||||
async function handleResubscribe() {
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await resubscribe()
|
||||
await workspaceApi.resubscribe()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.resubscribeSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to resubscribe'
|
||||
|
||||
@@ -161,6 +161,7 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -176,7 +177,7 @@ const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { fetchBalance, topup } = useBillingContext()
|
||||
const { fetchBalance } = useBillingContext()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
@@ -256,8 +257,7 @@ async function handleBuy() {
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
|
||||
const amountCents = payAmount.value * 100
|
||||
const response = await topup(amountCents)
|
||||
if (!response) return
|
||||
const response = await workspaceApi.createTopup(amountCents)
|
||||
|
||||
if (response.status === 'completed') {
|
||||
toast.add({
|
||||
|
||||
@@ -91,12 +91,10 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
previewSubscribe: mockPreviewSubscribe,
|
||||
plans: computed(() => mockPlans.value),
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance,
|
||||
resubscribe: mockResubscribe
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
// Shields the test from the real workspaceApi → @/scripts/api → app.ts import chain
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { resubscribe: mockResubscribe }
|
||||
}))
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview'
|
||||
@@ -34,14 +35,8 @@ export function useSubscriptionCheckout(emit: {
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const {
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
plans,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
resubscribe
|
||||
} = useBillingContext()
|
||||
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
|
||||
@@ -175,12 +170,13 @@ export function useSubscriptionCheckout(emit: {
|
||||
async function handleResubscribe() {
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await resubscribe()
|
||||
await workspaceApi.resubscribe()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.resubscribeSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
emit('close', true)
|
||||
} catch (error) {
|
||||
const message =
|
||||
|
||||
@@ -11,9 +11,7 @@ const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
subscribe: vi.fn(),
|
||||
previewSubscribe: vi.fn(),
|
||||
getPaymentPortalUrl: vi.fn(),
|
||||
cancelSubscription: vi.fn(),
|
||||
resubscribe: vi.fn(),
|
||||
createTopup: vi.fn()
|
||||
cancelSubscription: vi.fn()
|
||||
}))
|
||||
|
||||
const mockBillingPlans = vi.hoisted(() => ({
|
||||
@@ -624,90 +622,6 @@ describe('useWorkspaceBilling', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resubscribe', () => {
|
||||
it('refreshes status and balance after a successful resubscribe', async () => {
|
||||
mockWorkspaceApi.resubscribe.mockResolvedValue(undefined)
|
||||
mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus)
|
||||
mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance)
|
||||
|
||||
const billing = setupBilling()
|
||||
await billing.resubscribe()
|
||||
|
||||
expect(mockWorkspaceApi.resubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockWorkspaceApi.getBillingBalance).toHaveBeenCalledTimes(1)
|
||||
expect(billing.subscription.value?.tier).toBe('CREATOR')
|
||||
expect(billing.balance.value?.amountMicros).toBe(5_000_000)
|
||||
expect(billing.error.value).toBeNull()
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets error, rethrows, and skips the refresh when the API call fails', async () => {
|
||||
mockWorkspaceApi.resubscribe.mockRejectedValue(
|
||||
new Error('reactivation failed')
|
||||
)
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.resubscribe()).rejects.toThrow('reactivation failed')
|
||||
expect(billing.error.value).toBe('reactivation failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to a generic error message for non-Error rejections', async () => {
|
||||
mockWorkspaceApi.resubscribe.mockRejectedValue('boom')
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.resubscribe()).rejects.toBe('boom')
|
||||
expect(billing.error.value).toBe('Failed to resubscribe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('topup', () => {
|
||||
const topupResponse = {
|
||||
billing_op_id: 'op-topup',
|
||||
topup_id: 'topup-1',
|
||||
status: 'completed' as const,
|
||||
amount_cents: 500
|
||||
}
|
||||
|
||||
it('returns the createTopup response without refreshing status or balance', async () => {
|
||||
mockWorkspaceApi.createTopup.mockResolvedValue(topupResponse)
|
||||
|
||||
const billing = setupBilling()
|
||||
const result = await billing.topup(500)
|
||||
|
||||
expect(mockWorkspaceApi.createTopup).toHaveBeenCalledWith(500)
|
||||
expect(result).toBe(topupResponse)
|
||||
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
|
||||
expect(billing.error.value).toBeNull()
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets error and rethrows when the API call fails', async () => {
|
||||
mockWorkspaceApi.createTopup.mockRejectedValue(new Error('card declined'))
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.topup(500)).rejects.toThrow('card declined')
|
||||
expect(billing.error.value).toBe('card declined')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to a generic error message for non-Error rejections', async () => {
|
||||
mockWorkspaceApi.createTopup.mockRejectedValue('boom')
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.topup(500)).rejects.toBe('boom')
|
||||
expect(billing.error.value).toBe('Failed to top up credits')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plans / currentPlanSlug / fetchPlans', () => {
|
||||
it('prefers the plan slug from status over the billingPlans fallback', async () => {
|
||||
mockBillingPlans.currentPlanSlug.value = 'plans-fallback'
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse,
|
||||
CreateTopupResponse,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
@@ -71,13 +70,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
}
|
||||
})
|
||||
|
||||
const billingStatus = computed(() => statusData.value?.billing_status ?? null)
|
||||
const subscriptionStatus = computed(
|
||||
() => statusData.value?.subscription_status ?? null
|
||||
)
|
||||
const tier = computed(() => statusData.value?.subscription_tier ?? null)
|
||||
const renewalDate = computed(() => statusData.value?.renewal_date ?? null)
|
||||
|
||||
const plans = computed(() => billingPlans.plans.value)
|
||||
const currentPlanSlug = computed(
|
||||
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
|
||||
@@ -270,34 +262,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
}
|
||||
}
|
||||
|
||||
async function resubscribe(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await workspaceApi.resubscribe()
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to resubscribe'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function topup(amountCents: number): Promise<CreateTopupResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await workspaceApi.createTopup(amountCents)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to top up credits'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
@@ -339,10 +303,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
@@ -352,8 +312,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
resubscribe,
|
||||
topup,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
|
||||
@@ -57,8 +57,7 @@ async function runButtonClick(e: Event) {
|
||||
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted',
|
||||
element_group: 'app_mode'
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
})
|
||||
}
|
||||
await commandStore.execute(commandId, {
|
||||
|
||||
@@ -674,8 +674,7 @@ const handleToggleAdvanced = () => {
|
||||
|
||||
const handleEnterSubgraph = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'graph_node_open_subgraph_clicked',
|
||||
element_group: 'graph_node'
|
||||
button_id: 'graph_node_open_subgraph_clicked'
|
||||
})
|
||||
const graph = app.rootGraph
|
||||
if (!graph) {
|
||||
|
||||
@@ -103,8 +103,7 @@ export const useDialogService = () => {
|
||||
size: 'lg',
|
||||
onClose: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_closed',
|
||||
element_group: 'error_dialog'
|
||||
button_id: 'error_dialog_closed'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -170,8 +169,7 @@ export const useDialogService = () => {
|
||||
size: 'lg',
|
||||
onClose: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_closed',
|
||||
element_group: 'error_dialog'
|
||||
button_id: 'error_dialog_closed'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,25 +29,6 @@ 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'
|
||||
|
||||
@@ -1147,9 +1128,7 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
})
|
||||
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
|
||||
job_id: 'job-1',
|
||||
share_id: 'share-1',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false
|
||||
share_id: 'share-1'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1169,56 +1148,7 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
|
||||
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
|
||||
job_id: 'job-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
|
||||
share_id: 'share-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,12 +2,6 @@ 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'
|
||||
@@ -66,12 +60,6 @@ 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(
|
||||
@@ -99,7 +87,6 @@ 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)
|
||||
@@ -323,9 +310,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (queuedJob.shareId) {
|
||||
telemetry?.trackSharedWorkflowRun({
|
||||
job_id: jobId,
|
||||
share_id: queuedJob.shareId,
|
||||
view_mode: queuedJob.viewMode ?? mode.value,
|
||||
is_app_mode: queuedJob.isAppMode ?? isAppMode.value
|
||||
share_id: queuedJob.shareId
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -609,9 +594,6 @@ 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)
|
||||
|
||||
@@ -68,7 +68,6 @@ 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'
|
||||
@@ -352,11 +351,6 @@ 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)()
|
||||
|
||||
Reference in New Issue
Block a user