mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
7 Commits
glary/new-
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93959cb865 | ||
|
|
9e954a910e | ||
|
|
cb75f4e92d | ||
|
|
7a54e27397 | ||
|
|
5e8defb166 | ||
|
|
74caeb0b0b | ||
|
|
ced7c93e63 |
@@ -19,15 +19,26 @@ reviews:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
|
||||
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
Use only PR metadata already available in the review context:
|
||||
- the PR title
|
||||
- commit subjects in this PR
|
||||
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
|
||||
- the PR description.
|
||||
Do not rely on shell commands.
|
||||
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
|
||||
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
|
||||
Pass if at least one of the following is true:
|
||||
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes at least one file under `browser_tests/`.
|
||||
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
|
||||
Fail if all of the following are true:
|
||||
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
|
||||
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
|
||||
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
|
||||
|
||||
Pass otherwise.
|
||||
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
|
||||
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
- name: ADR compliance for entity/litegraph changes
|
||||
mode: warning
|
||||
instructions: |
|
||||
|
||||
104
browser_tests/tests/nodeOutputClearingOnRemove.spec.ts
Normal file
104
browser_tests/tests/nodeOutputClearingOnRemove.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function getNodeOutputImageCount(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<number> {
|
||||
return await comfyPage.page.evaluate(
|
||||
(id) => window.app!.nodeOutputs?.[id]?.images?.length ?? 0,
|
||||
nodeId
|
||||
)
|
||||
}
|
||||
|
||||
async function seedNodeOutput(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<void> {
|
||||
await comfyPage.page.evaluate((id) => {
|
||||
window.app!.nodeOutputs[id] = {
|
||||
images: [
|
||||
{ filename: 'seeded-preview.png', subfolder: '', type: 'output' }
|
||||
]
|
||||
}
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
test.describe('Node output cleanup on removal', { tag: '@workflow' }, () => {
|
||||
test('Deleting a node clears its outputs from the store', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Pressing delete left previews behind because nodeOutputStore did not listen to onNodeRemoved'
|
||||
})
|
||||
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
expect(node).toBeTruthy()
|
||||
const nodeId = String(node.id)
|
||||
|
||||
await seedNodeOutput(comfyPage, nodeId)
|
||||
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(0)
|
||||
})
|
||||
|
||||
test('Undoing a node addition clears outputs produced for the removed node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'Undo removed the node but left its preview rendered because the removal lifecycle did not invalidate nodeOutputStore'
|
||||
})
|
||||
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
|
||||
const addedNodeId = await comfyPage.page.evaluate(() => {
|
||||
const litegraph = window.LiteGraph
|
||||
if (!litegraph) throw new Error('LiteGraph is not available on window')
|
||||
const graph = window.app!.graph
|
||||
const registered = litegraph.registered_node_types
|
||||
const typeName = registered['LoadImage']
|
||||
? 'LoadImage'
|
||||
: registered['PreviewImage']
|
||||
? 'PreviewImage'
|
||||
: undefined
|
||||
if (!typeName) {
|
||||
throw new Error('No suitable node type registered for the test')
|
||||
}
|
||||
const node = litegraph.createNode(typeName)
|
||||
if (!node) throw new Error('Failed to create test node')
|
||||
graph.add(node)
|
||||
return String(node.id)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
|
||||
await seedNodeOutput(comfyPage, addedNodeId)
|
||||
await expect
|
||||
.poll(() => getNodeOutputImageCount(comfyPage, addedNodeId))
|
||||
.toBe(1)
|
||||
|
||||
await comfyPage.canvasOps.clickEmptySpace()
|
||||
await comfyPage.keyboard.undo()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount)
|
||||
await expect
|
||||
.poll(() => getNodeOutputImageCount(comfyPage, addedNodeId))
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -596,7 +596,8 @@ const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedNavItem, () => coordinateNavAndSort('nav'), { immediate: true })
|
||||
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
|
||||
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
|
||||
watch(sortBy, () => coordinateNavAndSort('sort'))
|
||||
|
||||
// Convert between string array and object array for MultiSelect component
|
||||
|
||||
@@ -143,6 +143,7 @@ import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { installNodeOutputClearingHooks } from '@/composables/graph/useNodeOutputClearingHooks'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
@@ -249,11 +250,16 @@ const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
|
||||
// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
|
||||
let cleanupErrorHooks: (() => void) | null = null
|
||||
let cleanupNodeOutputHooks: (() => void) | null = null
|
||||
watch(
|
||||
() => canvasStore.currentGraph,
|
||||
(graph) => {
|
||||
cleanupErrorHooks?.()
|
||||
cleanupErrorHooks = graph ? installErrorClearingHooks(graph) : null
|
||||
cleanupNodeOutputHooks?.()
|
||||
cleanupNodeOutputHooks = graph
|
||||
? installNodeOutputClearingHooks(graph)
|
||||
: null
|
||||
}
|
||||
)
|
||||
|
||||
@@ -538,6 +544,9 @@ onMounted(async () => {
|
||||
// Install error-clearing hooks on the initial graph
|
||||
if (comfyApp.canvas?.graph) {
|
||||
cleanupErrorHooks = installErrorClearingHooks(comfyApp.canvas.graph)
|
||||
cleanupNodeOutputHooks = installNodeOutputClearingHooks(
|
||||
comfyApp.canvas.graph
|
||||
)
|
||||
}
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
@@ -597,6 +606,8 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
cleanupErrorHooks?.()
|
||||
cleanupErrorHooks = null
|
||||
cleanupNodeOutputHooks?.()
|
||||
cleanupNodeOutputHooks = null
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
|
||||
208
src/composables/graph/useNodeOutputClearingHooks.test.ts
Normal file
208
src/composables/graph/useNodeOutputClearingHooks.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installNodeOutputClearingHooks } from '@/composables/graph/useNodeOutputClearingHooks'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
function seedOutputForLocator(locatorId: string) {
|
||||
app.nodeOutputs[locatorId] = {
|
||||
images: [{ filename: 'preview.png', type: 'output', subfolder: '' }]
|
||||
}
|
||||
}
|
||||
|
||||
describe('installNodeOutputClearingHooks', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
app.nodeOutputs = {}
|
||||
app.nodePreviewImages = {}
|
||||
})
|
||||
|
||||
it('removes outputs for a root-level node when it is removed from the graph', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
|
||||
const locatorId = String(node.id)
|
||||
seedOutputForLocator(locatorId)
|
||||
expect(app.nodeOutputs[locatorId]).toBeDefined()
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
|
||||
expect(app.nodeOutputs[locatorId]).toBeUndefined()
|
||||
expect(useNodeOutputStore().nodeOutputs[locatorId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes outputs for a subgraph interior node using subgraphUuid:nodeId locator', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('LoadImage')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
|
||||
const interiorLocator = `${subgraph.id}:${interiorNode.id}`
|
||||
seedOutputForLocator(interiorLocator)
|
||||
expect(app.nodeOutputs[interiorLocator]).toBeDefined()
|
||||
|
||||
installNodeOutputClearingHooks(subgraph)
|
||||
subgraph.remove(interiorNode)
|
||||
|
||||
expect(app.nodeOutputs[interiorLocator]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not affect outputs for other nodes that remain in the graph', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const removed = new LGraphNode('LoadImage')
|
||||
const kept = new LGraphNode('LoadImage')
|
||||
graph.add(removed)
|
||||
graph.add(kept)
|
||||
|
||||
const removedLocator = String(removed.id)
|
||||
const keptLocator = String(kept.id)
|
||||
seedOutputForLocator(removedLocator)
|
||||
seedOutputForLocator(keptLocator)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(removed)
|
||||
|
||||
expect(app.nodeOutputs[removedLocator]).toBeUndefined()
|
||||
expect(app.nodeOutputs[keptLocator]).toBeDefined()
|
||||
})
|
||||
|
||||
it('chains with existing onNodeRemoved callbacks', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
let calledWith: LGraphNode | undefined
|
||||
graph.onNodeRemoved = (node) => {
|
||||
calledWith = node
|
||||
}
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
seedOutputForLocator(String(node.id))
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
|
||||
expect(calledWith).toBe(node)
|
||||
expect(app.nodeOutputs[String(node.id)]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores original onNodeRemoved when cleanup is called', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const original = () => undefined
|
||||
graph.onNodeRemoved = original
|
||||
|
||||
const cleanup = installNodeOutputClearingHooks(graph)
|
||||
expect(graph.onNodeRemoved).not.toBe(original)
|
||||
|
||||
cleanup()
|
||||
expect(graph.onNodeRemoved).toBe(original)
|
||||
})
|
||||
|
||||
it('clears interior node outputs when a subgraph container is removed from the root graph', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('LoadImage')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
|
||||
const subgraphNodeLocator = String(subgraphNode.id)
|
||||
const interiorLocator = `${subgraph.id}:${interiorNode.id}`
|
||||
seedOutputForLocator(subgraphNodeLocator)
|
||||
seedOutputForLocator(interiorLocator)
|
||||
|
||||
installNodeOutputClearingHooks(rootGraph)
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(app.nodeOutputs[subgraphNodeLocator]).toBeUndefined()
|
||||
expect(app.nodeOutputs[interiorLocator]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('also prunes the active workflow change tracker output cache so undo cannot resurrect the entry', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
const locator = String(node.id)
|
||||
seedOutputForLocator(locator)
|
||||
|
||||
const trackerCache: Record<string, unknown> = {
|
||||
[locator]: { images: [{ filename: 'preview.png' }] }
|
||||
}
|
||||
vi.spyOn(useWorkflowStore(), 'activeWorkflow', 'get').mockReturnValue({
|
||||
changeTracker: { nodeOutputs: trackerCache }
|
||||
} as never)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
|
||||
expect(app.nodeOutputs[locator]).toBeUndefined()
|
||||
expect(trackerCache[locator]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the tracker cache during workflow tab switch teardown', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
const locator = String(node.id)
|
||||
seedOutputForLocator(locator)
|
||||
|
||||
const trackerCache: Record<string, unknown> = {
|
||||
[locator]: { images: [{ filename: 'preview.png' }] }
|
||||
}
|
||||
vi.spyOn(useWorkflowStore(), 'activeWorkflow', 'get').mockReturnValue({
|
||||
changeTracker: { nodeOutputs: trackerCache, _restoringState: false }
|
||||
} as never)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
try {
|
||||
graph.remove(node)
|
||||
} finally {
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
}
|
||||
|
||||
expect(trackerCache[locator]).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not throw when the removal hook fires for an already-cleared node', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
const locator = String(node.id)
|
||||
seedOutputForLocator(locator)
|
||||
|
||||
installNodeOutputClearingHooks(graph)
|
||||
graph.remove(node)
|
||||
expect(() => graph.onNodeRemoved?.(node)).not.toThrow()
|
||||
expect(app.nodeOutputs[locator]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
68
src/composables/graph/useNodeOutputClearingHooks.ts
Normal file
68
src/composables/graph/useNodeOutputClearingHooks.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { getExecutionIdForNodeInGraph } from '@/utils/graphTraversalUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
function isTabSwitchTeardown(): boolean {
|
||||
const tracker = useWorkflowStore().activeWorkflow?.changeTracker
|
||||
return ChangeTracker.isLoadingGraph && !tracker?._restoringState
|
||||
}
|
||||
|
||||
function dropTrackerCacheEntry(execId: string) {
|
||||
if (isTabSwitchTeardown()) return
|
||||
const tracked = useWorkflowStore().activeWorkflow?.changeTracker?.nodeOutputs
|
||||
if (tracked) delete tracked[execId]
|
||||
}
|
||||
|
||||
function clearInteriorOutputs(
|
||||
subgraphNode: SubgraphNode,
|
||||
execIdPrefix: string
|
||||
) {
|
||||
const subgraph: Subgraph | undefined = subgraphNode.subgraph
|
||||
if (!subgraph) return
|
||||
|
||||
const store = useNodeOutputStore()
|
||||
for (const interior of subgraph.nodes) {
|
||||
store.removeOutputsByLocatorId(`${subgraph.id}:${interior.id}`)
|
||||
const interiorExecId = `${execIdPrefix}:${interior.id}`
|
||||
dropTrackerCacheEntry(interiorExecId)
|
||||
if (interior.isSubgraphNode()) {
|
||||
clearInteriorOutputs(interior, interiorExecId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function installNodeOutputClearingHooks(graph: LGraph): () => void {
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
|
||||
graph.onNodeRemoved = function (node: LGraphNode) {
|
||||
try {
|
||||
const store = useNodeOutputStore()
|
||||
const { nodeIdToNodeLocatorId } = useWorkflowStore()
|
||||
const locatorId = isSubgraph(graph)
|
||||
? nodeIdToNodeLocatorId(node.id, graph)
|
||||
: String(node.id)
|
||||
store.removeOutputsByLocatorId(locatorId)
|
||||
|
||||
const execId = app.rootGraph
|
||||
? getExecutionIdForNodeInGraph(app.rootGraph, graph, node.id)
|
||||
: String(node.id)
|
||||
dropTrackerCacheEntry(execId)
|
||||
|
||||
if (node.isSubgraphNode()) {
|
||||
clearInteriorOutputs(node, execId)
|
||||
}
|
||||
} finally {
|
||||
originalOnNodeRemoved?.call(this, node)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
}
|
||||
}
|
||||
@@ -219,66 +219,5 @@ describe('useFeatureFlags', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('newUserDefaultTemplateTab returns dev override value', () => {
|
||||
localStorage.setItem(
|
||||
'ff:new_user_default_template_tab',
|
||||
JSON.stringify('popular')
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.newUserDefaultTemplateTab).toBe('popular')
|
||||
})
|
||||
|
||||
it('newUserDefaultTemplateTab trims whitespace', () => {
|
||||
localStorage.setItem(
|
||||
'ff:new_user_default_template_tab',
|
||||
JSON.stringify(' popular ')
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.newUserDefaultTemplateTab).toBe('popular')
|
||||
})
|
||||
|
||||
it.each(['', ' ', '\t\n'])(
|
||||
'newUserDefaultTemplateTab normalizes %j to undefined',
|
||||
(raw) => {
|
||||
localStorage.setItem(
|
||||
'ff:new_user_default_template_tab',
|
||||
JSON.stringify(raw)
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.newUserDefaultTemplateTab).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('newUserDefaultTemplateTab returns undefined when unset', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(_path, defaultValue) => defaultValue
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.newUserDefaultTemplateTab).toBeUndefined()
|
||||
})
|
||||
|
||||
it('newUserDefaultTemplateTab falls through to server feature when remoteConfig is blank', async () => {
|
||||
const { remoteConfig } =
|
||||
await import('@/platform/remoteConfig/remoteConfig')
|
||||
const previous = remoteConfig.value
|
||||
remoteConfig.value = { new_user_default_template_tab: ' ' }
|
||||
vi.mocked(api.getServerFeature).mockImplementation((path) => {
|
||||
if (path === ServerFeatureFlag.NEW_USER_DEFAULT_TEMPLATE_TAB)
|
||||
return 'popular'
|
||||
return undefined
|
||||
})
|
||||
|
||||
try {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.newUserDefaultTemplateTab).toBe('popular')
|
||||
} finally {
|
||||
remoteConfig.value = previous
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,8 +27,7 @@ export enum ServerFeatureFlag {
|
||||
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
|
||||
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
NEW_USER_DEFAULT_TEMPLATE_TAB = 'new_user_default_template_tab'
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,22 +163,6 @@ export function useFeatureFlags() {
|
||||
ServerFeatureFlag.SHOW_SIGNIN_BUTTON,
|
||||
undefined
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Template category id shown by default when the template selector
|
||||
* opens for a new user during onboarding. Used for A/B testing the
|
||||
* onboarding tab via PostHog feature flags. Returns `undefined` when
|
||||
* unset so callers fall back to the built-in default.
|
||||
*/
|
||||
get newUserDefaultTemplateTab(): string | undefined {
|
||||
const remote = remoteConfig.value.new_user_default_template_tab?.trim()
|
||||
const value = resolveFlag<string | undefined>(
|
||||
ServerFeatureFlag.NEW_USER_DEFAULT_TEMPLATE_TAB,
|
||||
remote ? remote : undefined,
|
||||
undefined
|
||||
)
|
||||
const normalized = value?.trim()
|
||||
return normalized ? normalized : undefined
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -16,10 +16,6 @@ const mockTelemetry = vi.hoisted(() => ({
|
||||
trackTemplateLibraryOpened: vi.fn()
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({
|
||||
newUserDefaultTemplateTab: undefined as string | undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => mockDialogService
|
||||
}))
|
||||
@@ -36,10 +32,6 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => mockTelemetry
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/components/custom/widget/WorkflowTemplateSelectorDialog.vue',
|
||||
() => ({
|
||||
@@ -52,7 +44,6 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
|
||||
describe('useWorkflowTemplateSelectorDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFlags.newUserDefaultTemplateTab = undefined
|
||||
})
|
||||
|
||||
describe('show', () => {
|
||||
@@ -101,38 +92,6 @@ describe('useWorkflowTemplateSelectorDialog', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses feature flag override for new users when set', () => {
|
||||
mockNewUserService.isNewUser.mockReturnValue(true)
|
||||
mockFlags.newUserDefaultTemplateTab = 'popular'
|
||||
|
||||
const dialog = useWorkflowTemplateSelectorDialog()
|
||||
dialog.show()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
initialCategory: 'popular'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores feature flag override for non-new users', () => {
|
||||
mockNewUserService.isNewUser.mockReturnValue(false)
|
||||
mockFlags.newUserDefaultTemplateTab = 'popular'
|
||||
|
||||
const dialog = useWorkflowTemplateSelectorDialog()
|
||||
dialog.show()
|
||||
|
||||
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
initialCategory: 'all'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses explicit initialCategory when provided', () => {
|
||||
mockNewUserService.isNewUser.mockReturnValue(true)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { TemplateLibraryMetadata } from '@/platform/telemetry/types'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -13,16 +12,11 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const newUserService = useNewUserService()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function newUserDefaultCategory() {
|
||||
return flags.newUserDefaultTemplateTab ?? GETTING_STARTED_CATEGORY_ID
|
||||
}
|
||||
|
||||
function show(
|
||||
source: TemplateLibraryMetadata['source'] = 'command',
|
||||
options?: { initialCategory?: string; afterClose?: () => void }
|
||||
@@ -31,7 +25,7 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
|
||||
const initialCategory =
|
||||
options?.initialCategory ??
|
||||
(newUserService.isNewUser() ? newUserDefaultCategory() : 'all')
|
||||
(newUserService.isNewUser() ? GETTING_STARTED_CATEGORY_ID : 'all')
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
|
||||
@@ -104,5 +104,4 @@ export type RemoteConfig = {
|
||||
comfyhub_upload_enabled?: boolean
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
sentry_dsn?: string
|
||||
new_user_default_template_tab?: string
|
||||
}
|
||||
|
||||
@@ -26,9 +26,6 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
//@ts-expect-error Define global for the test
|
||||
global.__COMFYUI_FRONTEND_VERSION__ = '1.24.0'
|
||||
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
|
||||
describe('useNewUserService', () => {
|
||||
@@ -120,6 +117,73 @@ describe('useNewUserService', () => {
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify existing user when V1 draft store keys exist', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.Drafts') return '{}'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser()
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify existing user when V1 draft order key exists', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.DraftOrder') return '[]'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser()
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify existing user when V2 draft index has entries', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.DraftIndex.v2:personal')
|
||||
return '{"v":2,"updatedAt":1,"order":["abc"],"entries":{"abc":{"path":"workflows/Untitled.json","name":"Untitled","isTemporary":true,"updatedAt":1}}}'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser()
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify new user when V2 draft index exists but is empty', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.DraftIndex.v2:personal')
|
||||
return '{"v":2,"updatedAt":1,"order":[],"entries":{}}'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser()
|
||||
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
})
|
||||
|
||||
it('should identify new user when V2 draft index is malformed', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.DraftIndex.v2:personal') return 'not json'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser()
|
||||
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
})
|
||||
|
||||
it('should identify new user when tutorial is explicitly false', async () => {
|
||||
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': false }
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
|
||||
@@ -2,6 +2,24 @@ import { ref, shallowRef } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
function hasV2DraftHistory(raw: string | null): boolean {
|
||||
if (!raw) return false
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
order?: unknown
|
||||
entries?: unknown
|
||||
}
|
||||
const orderLength = Array.isArray(parsed.order) ? parsed.order.length : 0
|
||||
const entriesCount =
|
||||
parsed.entries && typeof parsed.entries === 'object'
|
||||
? Object.keys(parsed.entries as Record<string, unknown>).length
|
||||
: 0
|
||||
return orderLength > 0 || entriesCount > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function _useNewUserService() {
|
||||
const settingStore = useSettingStore()
|
||||
const pendingCallbacks = shallowRef<Array<() => Promise<void>>>([])
|
||||
@@ -18,12 +36,32 @@ function _useNewUserService() {
|
||||
const isNewUserSettings =
|
||||
Object.keys(settingStore.settingValues).length === 0 ||
|
||||
!settingStore.get('Comfy.TutorialCompleted')
|
||||
const hasNoWorkflow = !localStorage.getItem('workflow')
|
||||
const hasNoPreviousWorkflow = !localStorage.getItem(
|
||||
'Comfy.PreviousWorkflow'
|
||||
|
||||
// Legacy keys (pre-V1 and V1 persistence)
|
||||
const hasNoLegacyWorkflow =
|
||||
!localStorage.getItem('workflow') &&
|
||||
!localStorage.getItem('Comfy.PreviousWorkflow')
|
||||
|
||||
// V1 draft store keys
|
||||
const hasNoV1Drafts =
|
||||
!localStorage.getItem('Comfy.Workflow.Drafts') &&
|
||||
!localStorage.getItem('Comfy.Workflow.DraftOrder')
|
||||
|
||||
// V2 draft index key (scoped to personal workspace; cloud workspace id
|
||||
// comes from sessionStorage which may not be set yet at this point).
|
||||
// Check for actual draft history rather than key existence: an empty
|
||||
// index is written by `migrateV1toV2()` for genuine new users during
|
||||
// startup, so key presence alone is not evidence of prior usage.
|
||||
const hasNoV2DraftIndex = !hasV2DraftHistory(
|
||||
localStorage.getItem('Comfy.Workflow.DraftIndex.v2:personal')
|
||||
)
|
||||
|
||||
return isNewUserSettings && hasNoWorkflow && hasNoPreviousWorkflow
|
||||
return (
|
||||
isNewUserSettings &&
|
||||
hasNoLegacyWorkflow &&
|
||||
hasNoV1Drafts &&
|
||||
hasNoV2DraftIndex
|
||||
)
|
||||
}
|
||||
|
||||
async function registerInitCallback(callback: () => Promise<void>) {
|
||||
|
||||
@@ -499,6 +499,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
revokeSubgraphPreviews,
|
||||
removeNodeOutputs,
|
||||
removeNodeOutputsForNode,
|
||||
removeOutputsByLocatorId,
|
||||
snapshotOutputs,
|
||||
restoreOutputs,
|
||||
resetAllOutputsAndPreviews,
|
||||
|
||||
Reference in New Issue
Block a user