Compare commits

..

1 Commits

Author SHA1 Message Date
pythongosssss
4294290ca3 test: add regression test for getCanvasCenter null guard (#8399) 2026-04-15 09:43:41 -07:00
79 changed files with 413 additions and 4874 deletions

View File

@@ -54,33 +54,6 @@ jobs:
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::error::No shard coverage.lcov files found under temp/coverage-shards"
exit 1
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY"
for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do
SHARD=$(basename "$(dirname "$f")")
SHARD_SF=$(grep -c '^SF:' "$f" || echo 0)
SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f")
echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY"
if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then
echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss"
fi
done
- name: Upload merged coverage data
if: always()
uses: actions/upload-artifact@v6

View File

@@ -1,197 +0,0 @@
{
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [627.5973510742188, 423.0972900390625],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 4,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [1],
"pos": {
"0": 447.9044189453125,
"1": 437.3822326660156
}
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [2],
"pos": {
"0": 912.5973510742188,
"1": 436.0972900390625
}
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [554.8743286132812, 100.95539093017578],
"size": [270, 262],
"flags": { "collapsed": true },
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [2]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "VAEEncode",
"pos": [685.1265869140625, 439.1734619140625],
"size": [140, 46],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": null
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [4]
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.8894351682943402,
"offset": [58.7671207025881, 137.7124650620126]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -6,7 +6,7 @@
"id": 1,
"type": "ImageCropV2",
"pos": [50, 50],
"size": [400, 550],
"size": [400, 500],
"flags": {},
"order": 0,
"mode": 0,
@@ -27,7 +27,14 @@
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [{ "x": 0, "y": 0, "width": 512, "height": 512 }]
"widgets_values": [
{
"x": 0,
"y": 0,
"width": 512,
"height": 512
}
]
}
],
"links": [],

View File

@@ -10,7 +10,6 @@ export const DefaultGraphPositions = {
textEncodeNode2: { x: 622, y: 400 },
textEncodeNodeToggler: { x: 430, y: 171 },
emptySpaceClick: { x: 35, y: 31 },
emptyCanvasClick: { x: 50, y: 500 },
// Slot positions
clipTextEncodeNode1InputSlot: { x: 427, y: 198 },
@@ -40,7 +39,6 @@ export const DefaultGraphPositions = {
textEncodeNode2: Position
textEncodeNodeToggler: Position
emptySpaceClick: Position
emptyCanvasClick: Position
clipTextEncodeNode1InputSlot: Position
clipTextEncodeNode2InputSlot: Position
clipTextEncodeNode2InputLinkPath: Position

View File

@@ -82,7 +82,6 @@ export class WorkflowHelper {
await this.comfyPage.workflowUploadInput.setInputFiles(
assetPath(`${workflowName}.json`)
)
await this.waitForWorkflowIdle()
await this.comfyPage.nextFrame()
if (test.info().tags.includes('@vue-nodes')) {
await this.comfyPage.vueNodes.waitForNodes()

View File

@@ -35,13 +35,6 @@ export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
}
export async function triggerSerialization(page: Page): Promise<void> {
await page.waitForFunction(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
const widget = node?.widgets?.find((w) => w.name === 'mask')
return typeof widget?.serializeValue === 'function'
})
await page.evaluate(async () => {
const graph = window.graph as TestGraphAccess | undefined
if (!graph) {
@@ -57,14 +50,9 @@ export async function triggerSerialization(page: Page): Promise<void> {
)
}
const widgetIndex = node.widgets?.findIndex((w) => w.name === 'mask') ?? -1
if (widgetIndex === -1) {
throw new Error('Widget "mask" not found on target node 1.')
}
const widget = node.widgets?.[widgetIndex]
const widget = node.widgets?.find((w) => w.name === 'mask')
if (!widget) {
throw new Error(`Widget index ${widgetIndex} not found on target node 1.`)
throw new Error('Widget "mask" not found on target node 1.')
}
if (typeof widget.serializeValue !== 'function') {
@@ -73,6 +61,6 @@ export async function triggerSerialization(page: Page): Promise<void> {
)
}
await widget.serializeValue(node, widgetIndex)
await widget.serializeValue(node, 0)
})
}

View File

@@ -54,7 +54,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker.captureCanvasState()
).workflow.activeWorkflow?.changeTracker.checkState()
}, value)
}

View File

@@ -30,7 +30,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
window.app!.graph!.setDirtyCanvas(true, true)
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker?.captureCanvasState()
).workflow.activeWorkflow?.changeTracker?.checkState()
})
await expect
.poll(() => comfyPage.page.title())

View File

@@ -71,7 +71,7 @@ async function waitForChangeTrackerSettled(
) {
// Visible node flags can flip before undo finishes loadGraphData() and
// updates the tracker. Poll the tracker's own settled state so we do not
// start the next transaction while captureCanvasState() is still gated.
// start the next transaction while checkState() is still gated.
await expect
.poll(() => getChangeTrackerDebugState(comfyPage))
.toMatchObject({
@@ -272,42 +272,4 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
})
test('Undo preserves viewport offset', async ({ comfyPage }) => {
// Pan to a distinct offset so we can detect drift
await comfyPage.canvasOps.pan({ x: 200, y: 150 })
const viewportBefore = await comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
})
// Make a graph change so we have something to undo
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
// Undo the collapse — viewport should be preserved
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2_000 }
)
.toEqual({
scale: expect.closeTo(viewportBefore.scale, 2),
offset: [
expect.closeTo(viewportBefore.offset[0], 0),
expect.closeTo(viewportBefore.offset[1], 0)
]
})
})
})

View File

@@ -12,7 +12,7 @@ test.describe(
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Prevents captureCanvasState from corrupting workflow state during tab switch', async ({
test('Prevents checkState from corrupting workflow state during tab switch', async ({
comfyPage
}) => {
// Tab 0: default workflow (7 nodes)
@@ -21,9 +21,9 @@ test.describe(
// Save tab 0 so it has a unique name for tab switching
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
// Register an extension that forces captureCanvasState during graph loading.
// Register an extension that forces checkState during graph loading.
// This simulates the bug scenario where a user clicks during graph loading
// which triggers a captureCanvasState call on the wrong graph, corrupting the activeState.
// which triggers a checkState call on the wrong graph, corrupting the activeState.
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestCheckStateDuringLoad',
@@ -35,7 +35,7 @@ test.describe(
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
// Simulate the user clicking during graph loading
workflow.changeTracker.captureCanvasState()
workflow.changeTracker.checkState()
}
})
})

View File

@@ -64,29 +64,3 @@ test.describe(
})
}
)
test.describe(
'Collapsed node links inside subgraph on first entry',
{ tag: ['@canvas', '@node', '@vue-nodes', '@subgraph', '@screenshot'] },
() => {
test('renders collapsed node links correctly after fitView on first subgraph entry', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-collapsed-node'
)
await comfyPage.nextFrame()
await comfyPage.vueNodes.enterSubgraph('2')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// fitView runs on first entry and re-syncs slot layouts for the
// pre-collapsed KSampler. Screenshot captures the rendered canvas
// links to guard against regressing the stale-coordinate bug.
await expect(comfyPage.canvas).toHaveScreenshot(
'subgraph-entry-collapsed-node-links.png'
)
})
}
)

View File

@@ -146,9 +146,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await ksamplerNodes[0].copy()
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyCanvasClick
})
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.nextFrame()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
@@ -176,9 +174,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
// Step 3: Click empty canvas area, paste image → creates new LoadImage
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyCanvasClick
})
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.nextFrame()
const uploadPromise2 = comfyPage.page.waitForResponse(

View File

@@ -55,30 +55,4 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).toBeHidden()
})
test('Focus mode toggle preserves properties panel width', async ({
comfyPage
}) => {
// Open the properties panel
await comfyPage.actionbar.propertiesButton.click()
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
// Record the initial panel width
const initialBox = await comfyPage.menu.propertiesPanel.root.boundingBox()
expect(initialBox).not.toBeNull()
const initialWidth = initialBox!.width
// Toggle focus mode on then off
await comfyPage.setFocusMode(true)
await comfyPage.setFocusMode(false)
// Properties panel should be visible again with the same width
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
await expect
.poll(async () => {
const box = await comfyPage.menu.propertiesPanel.root.boundingBox()
return box ? Math.abs(box.width - initialWidth) : Infinity
})
.toBeLessThan(2)
})
})

View File

@@ -170,6 +170,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.workflow.loadWorkflow(
'groupnodes/group_node_identical_nodes_hidden_inputs'
)
await comfyPage.nextFrame()
const groupNodeId = 19
const groupNodeName = 'two_VAE_decode'

View File

@@ -60,6 +60,7 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
@@ -83,6 +84,7 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
@@ -105,6 +107,7 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
// Select the outer group (cascades to children)
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')

View File

@@ -1,122 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Image Crop', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Shows empty state when no input image is connected',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('No input image connected')).toBeVisible()
await expect(node.locator('img[alt="Crop preview"]')).toHaveCount(0)
}
)
test(
'Renders bounding box coordinate inputs',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('X')).toBeVisible()
await expect(node.getByText('Y')).toBeVisible()
await expect(node.getByText('Width')).toBeVisible()
await expect(node.getByText('Height')).toBeVisible()
}
)
test(
'Renders ratio selector and lock button',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('Ratio')).toBeVisible()
await expect(node.getByRole('button', { name: /lock/i })).toBeVisible()
}
)
test(
'Lock button toggles aspect ratio lock',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const lockButton = node.getByRole('button', {
name: 'Lock aspect ratio'
})
await expect(lockButton).toBeVisible()
await lockButton.click()
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeVisible()
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
await expect(
node.getByRole('button', { name: 'Lock aspect ratio' })
).toBeVisible()
}
)
test(
'Ratio selector offers expected presets',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const trigger = node.getByRole('combobox')
await trigger.click()
const expectedRatios = ['1:1', '3:4', '4:3', '16:9', '9:16', 'Custom']
for (const label of expectedRatios) {
await expect(
comfyPage.page.getByRole('option', { name: label, exact: true })
).toBeVisible()
}
}
)
test(
'Programmatically setting widget value updates bounding box inputs',
{ tag: '@ui' },
async ({ comfyPage }) => {
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
await comfyPage.page.evaluate(
({ bounds }) => {
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
if (widget) {
widget.value = bounds
widget.callback?.(bounds)
}
},
{ bounds: newBounds }
)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeLocator('1')
const inputs = node.locator('input[inputmode="decimal"]')
await expect.poll(() => inputs.nth(0).inputValue()).toBe('50')
await expect.poll(() => inputs.nth(1).inputValue()).toBe('100')
await expect.poll(() => inputs.nth(2).inputValue()).toBe('200')
await expect.poll(() => inputs.nth(3).inputValue()).toBe('300')
}
)
})

View File

@@ -1,4 +1,3 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
@@ -28,85 +27,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}
}
async function openMaskEditorDialog(comfyPage: ComfyPage) {
const { imagePreview } = await loadImageOnNode(comfyPage)
await imagePreview.getByRole('region').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
return dialog
}
async function getMaskCanvasPixelData(page: Page) {
return page.evaluate(() => {
const canvases = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)
// The mask canvas is the 3rd canvas (index 2, z-30)
const maskCanvas = canvases[2] as HTMLCanvasElement
if (!maskCanvas) return null
const ctx = maskCanvas.getContext('2d')
if (!ctx) return null
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
let nonTransparentPixels = 0
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) nonTransparentPixels++
}
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
})
}
function pollMaskPixelCount(page: Page): Promise<number> {
return getMaskCanvasPixelData(page).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
async function drawStrokeOnPointerZone(
page: Page,
dialog: ReturnType<typeof page.locator>
) {
const pointerZone = dialog.locator(
'.maskEditor-ui-container [class*="w-[calc"]'
)
await expect(pointerZone).toBeVisible()
const box = await pointerZone.boundingBox()
if (!box) throw new Error('Pointer zone bounding box not found')
const startX = box.x + box.width * 0.3
const startY = box.y + box.height * 0.5
const endX = box.x + box.width * 0.7
const endY = box.y + box.height * 0.5
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(endX, endY, { steps: 10 })
await page.mouse.up()
return { startX, startY, endX, endY, box }
}
async function drawStrokeAndExpectPixels(
comfyPage: ComfyPage,
dialog: ReturnType<typeof comfyPage.page.locator>
) {
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
}
test(
'opens mask editor from image preview button',
{ tag: ['@smoke', '@screenshot'] },
@@ -132,7 +52,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog.getByText('Save')).toBeVisible()
await expect(dialog.getByText('Cancel')).toBeVisible()
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
}
)
@@ -159,245 +79,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
await comfyPage.expectScreenshot(
dialog,
await expect(dialog).toHaveScreenshot(
'mask-editor-dialog-from-context-menu.png'
)
}
)
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
expect(dataBefore).not.toBeNull()
expect(dataBefore!.nonTransparentPixels).toBe(0)
await drawStrokeAndExpectPixels(comfyPage, dialog)
})
test('undo reverts a brush stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await expect(undoButton).toBeVisible()
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test('redo restores an undone stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
const redoButton = dialog.locator('button[title="Redo"]')
await expect(redoButton).toBeVisible()
await redoButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
})
test('clear button removes all mask content', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const clearButton = dialog.getByRole('button', { name: 'Clear' })
await expect(clearButton).toBeVisible()
await clearButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await cancelButton.click()
await expect(dialog).toBeHidden()
})
test('invert button inverts the mask', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
expect(dataBefore).not.toBeNull()
const pixelsBefore = dataBefore!.nonTransparentPixels
const invertButton = dialog.getByRole('button', { name: 'Invert' })
await expect(invertButton).toBeVisible()
await invertButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(pixelsBefore)
})
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
await comfyPage.page.keyboard.press(modifier)
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test(
'tool panel shows all five tools',
{ tag: ['@smoke'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const toolPanel = dialog.locator('.maskEditor-ui-container')
await expect(toolPanel).toBeVisible()
// The tool panel should contain exactly 5 tool entries
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
// First tool (MaskPen) should be selected by default
const selectedTool = dialog.locator(
'.maskEditor_toolPanelContainerSelected'
)
await expect(selectedTool).toHaveCount(1)
}
)
test('switching tools updates the selected indicator', async ({
comfyPage
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
// Click the third tool (Eraser, index 2)
await toolEntries.nth(2).click()
// The third tool should now be selected
const selectedTool = dialog.locator(
'.maskEditor_toolPanelContainerSelected'
)
await expect(selectedTool).toHaveCount(1)
// Verify it's the eraser (3rd entry)
await expect(toolEntries.nth(2)).toHaveClass(/Selected/)
})
test('brush settings panel is visible with thickness controls', async ({
comfyPage
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
// The side panel should show brush settings by default
const thicknessLabel = dialog.getByText('Thickness')
await expect(thicknessLabel).toBeVisible()
const opacityLabel = dialog.getByText('Opacity').first()
await expect(opacityLabel).toBeVisible()
const hardnessLabel = dialog.getByText('Hardness')
await expect(hardnessLabel).toBeVisible()
})
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
let maskUploadCount = 0
let imageUploadCount = 0
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-mask-${maskUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-image-${imageUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeVisible()
await saveButton.click()
await expect(dialog).toBeHidden()
// The save pipeline uploads multiple layers (mask + image variants)
expect(
maskUploadCount + imageUploadCount,
'save should trigger upload calls'
).toBeGreaterThan(0)
})
test('save failure keeps dialog open', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
// Fail all upload routes
await comfyPage.page.route('**/upload/mask', (route) =>
route.fulfill({ status: 500 })
)
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill({ status: 500 })
)
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.click()
// Dialog should remain open when save fails
await expect(dialog).toBeVisible()
})
test(
'eraser tool removes mask content',
{ tag: ['@screenshot'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
// Draw a stroke with the mask pen (default tool)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
// Switch to eraser tool (3rd tool, index 2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await toolEntries.nth(2).click()
// Draw over the same area with the eraser
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
}
)
})

View File

@@ -1,7 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -17,24 +16,21 @@ function hasCanvasContent(canvas: Locator): Promise<boolean> {
})
}
function getMinimapLocators(comfyPage: ComfyPage) {
const container = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
return {
container,
canvas: comfyPage.page.getByTestId(TestIds.canvas.minimapCanvas),
viewport: comfyPage.page.getByTestId(TestIds.canvas.minimapViewport),
toggleButton: comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
),
closeButton: comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton)
}
}
async function clickMinimapAt(
overlay: Locator,
page: Page,
relX: number,
relY: number
) {
const box = await overlay.boundingBox()
expect(box, 'Minimap interaction overlay not found').toBeTruthy()
function getCanvasOffset(page: Page): Promise<[number, number]> {
return page.evaluate(() => {
const ds = window.app!.canvas.ds
return [ds.offset[0], ds.offset[1]] as [number, number]
})
// Click area — avoiding the settings button (top-left, 32×32px)
// and close button (top-right, 32×32px)
await page.mouse.click(
box!.x + box!.width * relX,
box!.y + box!.height * relY
)
}
test.describe('Minimap', { tag: '@canvas' }, () => {
@@ -46,13 +42,23 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap is visible by default', async ({ comfyPage }) => {
const { container, canvas, viewport } = getMinimapLocators(comfyPage)
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(container).toBeVisible()
await expect(canvas).toBeVisible()
await expect(viewport).toBeVisible()
await expect(minimapContainer).toBeVisible()
await expect(container).toHaveCSS('position', 'relative')
const minimapCanvas = minimapContainer.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
const minimapViewport = minimapContainer.getByTestId(
TestIds.canvas.minimapViewport
)
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'relative')
// position and z-index validation moved to the parent container of the minimap
const minimapMainContainer = comfyPage.page.locator(
@@ -63,53 +69,59 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
const { container, toggleButton } = getMinimapLocators(comfyPage)
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(toggleButton).toBeVisible()
await expect(container).toBeVisible()
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const { container, toggleButton } = getMinimapLocators(comfyPage)
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(container).toBeVisible()
await expect(minimapContainer).toBeVisible()
await toggleButton.click()
await comfyPage.nextFrame()
await expect(container).toBeHidden()
await expect(minimapContainer).toBeHidden()
await toggleButton.click()
await comfyPage.nextFrame()
await expect(container).toBeVisible()
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
const { container } = getMinimapLocators(comfyPage)
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(container).toBeVisible()
await expect(minimapContainer).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(container).toBeHidden()
await expect(minimapContainer).toBeHidden()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(container).toBeVisible()
await expect(minimapContainer).toBeVisible()
})
test('Close button hides minimap', async ({ comfyPage }) => {
const { container, toggleButton, closeButton } =
getMinimapLocators(comfyPage)
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
await expect(minimap).toBeVisible()
await expect(container).toBeVisible()
await closeButton.click()
await expect(container).toBeHidden()
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
await expect(minimap).toBeHidden()
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(toggleButton).toBeVisible()
})
@@ -117,10 +129,12 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
'Panning canvas moves minimap viewport',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimap).toBeVisible()
await comfyPage.expectScreenshot(container, 'minimap-before-pan.png')
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
@@ -129,192 +143,155 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
canvas.ds.offset[1] = -600
canvas.setDirty(true, true)
})
await comfyPage.expectScreenshot(container, 'minimap-after-pan.png')
await comfyPage.expectScreenshot(minimap, 'minimap-after-pan.png')
}
)
test(
'Viewport rectangle is visible and positioned within minimap',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
const minimapBox = await container.boundingBox()
const viewportBox = await viewport.boundingBox()
expect(minimapBox).toBeTruthy()
expect(viewportBox).toBeTruthy()
expect(viewportBox!.width).toBeGreaterThan(0)
expect(viewportBox!.height).toBeGreaterThan(0)
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
minimapBox!.y
)
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
await comfyPage.expectScreenshot(container, 'minimap-with-viewport.png')
}
)
test('Clicking on minimap pans the canvas to that position', async ({
test('Minimap canvas is non-empty for a workflow with nodes', async ({
comfyPage
}) => {
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
const offsetBefore = await getCanvasOffset(comfyPage.page)
const minimapBox = await container.boundingBox()
expect(minimapBox).toBeTruthy()
// Click the top-left quadrant — canvas should pan so that region
// becomes centered, meaning offset increases (moves right/down)
await comfyPage.page.mouse.click(
minimapBox!.x + minimapBox!.width * 0.2,
minimapBox!.y + minimapBox!.height * 0.2
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await comfyPage.nextFrame()
await expect(minimapCanvas).toBeVisible()
await expect
.poll(() => getCanvasOffset(comfyPage.page))
.not.toEqual(offsetBefore)
})
test('Dragging on minimap continuously pans the canvas', async ({
comfyPage
}) => {
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
const minimapBox = await container.boundingBox()
expect(minimapBox).toBeTruthy()
const startX = minimapBox!.x + minimapBox!.width * 0.3
const startY = minimapBox!.y + minimapBox!.height * 0.3
const endX = minimapBox!.x + minimapBox!.width * 0.7
const endY = minimapBox!.y + minimapBox!.height * 0.7
const offsetBefore = await getCanvasOffset(comfyPage.page)
// Drag from top-left toward bottom-right on the minimap
await comfyPage.page.mouse.move(startX, startY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(endX, endY, { steps: 10 })
// Mid-drag: offset should already differ from initial state
const offsetMidDrag = await getCanvasOffset(comfyPage.page)
expect(
offsetMidDrag[0] !== offsetBefore[0] ||
offsetMidDrag[1] !== offsetBefore[1]
).toBe(true)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
// Final offset should also differ (drag was not discarded on mouseup)
await expect
.poll(() => getCanvasOffset(comfyPage.page))
.not.toEqual(offsetBefore)
})
test('Minimap viewport updates when canvas is zoomed', async ({
comfyPage
}) => {
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
const viewportBefore = await viewport.boundingBox()
expect(viewportBefore).toBeTruthy()
// Zoom in significantly
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.scale = 3
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
// Viewport rectangle should shrink when zoomed in
await expect
.poll(async () => {
const box = await viewport.boundingBox()
return box?.width ?? 0
})
.toBeLessThan(viewportBefore!.width)
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
})
test('Minimap canvas is empty after all nodes are deleted', async ({
comfyPage
}) => {
const { canvas } = getMinimapLocators(comfyPage)
await expect(canvas).toBeVisible()
// Minimap should have content before deletion
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
// Remove all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('Delete')
await comfyPage.nextFrame()
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
await comfyPage.keyboard.selectAll()
await comfyPage.vueNodes.deleteSelected()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Minimap canvas should be empty — no nodes means nothing to render
await expect
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
.toBe(false)
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
})
test('Minimap re-renders after loading a different workflow', async ({
test('Clicking minimap corner pans the main canvas', async ({
comfyPage
}) => {
const { canvas } = getMinimapLocators(comfyPage)
await expect(canvas).toBeVisible()
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
await expect(minimap).toBeVisible()
// Default workflow has content
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
// Load a very different workflow
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
await comfyPage.nextFrame()
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
// Minimap should still have content (different workflow, still has nodes)
await expect
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
.toBe(true)
.poll(() =>
comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
)
.not.toStrictEqual(before)
await expect
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform))
.not.toBe(transformBefore)
})
test('Minimap viewport position reflects canvas pan state', async ({
test('Clicking minimap center after FitView causes minimal canvas movement', async ({
comfyPage
}) => {
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
await expect(minimap).toBeVisible()
const positionBefore = await viewport.boundingBox()
expect(positionBefore).toBeTruthy()
// Pan the canvas by a large amount to the right and down
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.offset[0] -= 500
canvas.ds.offset[1] -= 500
canvas.ds.offset[0] -= 1000
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
// The viewport indicator should have moved within the minimap
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await comfyPage.page.evaluate(() => {
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
})
await expect
.poll(async () => {
const box = await viewport.boundingBox()
if (!box || !positionBefore) return false
return box.x !== positionBefore.x || box.y !== positionBefore.y
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
timeout: 2000
})
.toBe(true)
.not.toBe(transformBefore)
await comfyPage.nextFrame()
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
await clickMinimapAt(overlay, comfyPage.page, 0.5, 0.5)
await comfyPage.nextFrame()
const after = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
// ~3px overlay error × ~15 canvas/minimap scale ≈ 45, rounded up
const TOLERANCE = 50
expect(
Math.abs(after.x - before.x),
`offset.x changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
expect(
Math.abs(after.y - before.y),
`offset.y changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
})
test(
'Viewport rectangle is visible and positioned within minimap',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimap).toBeVisible()
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
await expect(viewport).toBeVisible()
await expect(async () => {
const vb = await viewport.boundingBox()
const mb = await minimap.boundingBox()
expect(vb).toBeTruthy()
expect(mb).toBeTruthy()
expect(vb!.width).toBeGreaterThan(0)
expect(vb!.height).toBeGreaterThan(0)
expect(vb!.x).toBeGreaterThanOrEqual(mb!.x)
expect(vb!.y).toBeGreaterThanOrEqual(mb!.y)
expect(vb!.x + vb!.width).toBeLessThanOrEqual(mb!.x + mb!.width)
expect(vb!.y + vb!.height).toBeLessThanOrEqual(mb!.y + mb!.height)
}).toPass({ timeout: 5000 })
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
}
)
})

View File

@@ -13,6 +13,7 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
async function openMoreOptions(comfyPage: ComfyPage) {

View File

@@ -370,64 +370,4 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
})
})
test.describe('Eraser', () => {
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect
.poll(
() =>
canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cx = Math.floor(el.width / 2)
const cy = Math.floor(el.height / 2)
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
return data.every((v, i) => i % 4 !== 3 || v === 0)
}),
{ message: 'erased area should be transparent' }
)
.toBe(true)
})
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(false)
})
})
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
})
})

View File

@@ -32,6 +32,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
test('delete button removes selected node', async ({ comfyPage }) => {
@@ -68,6 +69,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
@@ -81,6 +83,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
@@ -157,6 +160,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
@@ -183,6 +187,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
@@ -224,6 +229,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
// Select the SaveImage node by panning to it
const saveImageRef = (

View File

@@ -14,6 +14,7 @@ test.describe(
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.nextFrame()
})

View File

@@ -14,6 +14,7 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea

View File

@@ -37,6 +37,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
@@ -75,6 +76,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
const backButton = breadcrumb.locator('.back-button')
@@ -86,11 +88,13 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect(backButton).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
@@ -100,6 +104,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
test.describe('Navigation Hotkeys', () => {
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
{
@@ -128,6 +133,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -153,6 +159,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -192,6 +199,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
@@ -258,6 +266,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
@@ -296,6 +305,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
@@ -311,8 +321,10 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect
.poll(() =>

View File

@@ -18,6 +18,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')

View File

@@ -102,6 +102,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -149,6 +150,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'promoted-value-sync-test'
@@ -316,6 +318,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
@@ -400,6 +403,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
@@ -451,6 +455,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// Verify promotions exist
await expect
@@ -471,6 +476,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '5', 0)
const initialNames = await getPromotedWidgetNames(comfyPage, '5')

View File

@@ -68,6 +68,7 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()

View File

@@ -40,6 +40,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
@@ -58,6 +59,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
await expect
.poll(async () => {
@@ -71,10 +73,12 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
@@ -116,6 +120,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.nextFrame()
const initialValues = await getPromotedHostWidgetValues(
comfyPage,

View File

@@ -121,9 +121,9 @@ test.describe('Workflow Persistence', () => {
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
@@ -149,6 +149,7 @@ test.describe('Workflow Persistence', () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBeGreaterThan(1)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
@@ -388,7 +389,7 @@ test.describe('Workflow Persistence', () => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — saveWorkflow called captureCanvasState on inactive tab, serializing the active graph instead'
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
})
await comfyPage.settings.setSetting(
@@ -419,13 +420,13 @@ test.describe('Workflow Persistence', () => {
.toBe(nodeCountA + 1)
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
// Trigger captureCanvasState so isModified is set
// Trigger checkState so isModified is set
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
// Switch to A via topbar tab (making B inactive)
@@ -464,7 +465,7 @@ test.describe('Workflow Persistence', () => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — saveWorkflowAs called captureCanvasState on inactive temp tab, serializing the active graph'
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
})
await comfyPage.settings.setSetting(
@@ -488,13 +489,13 @@ test.describe('Workflow Persistence', () => {
})
await comfyPage.nextFrame()
// Trigger captureCanvasState so isModified is set
// Trigger checkState so isModified is set
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)

View File

@@ -5,18 +5,14 @@ history by comparing serialized graph snapshots.
## How It Works
`captureCanvasState()` is the core method. It:
`checkState()` is the core method. It:
1. Serializes the current graph via `app.rootGraph.serialize()`
2. Deep-compares the result against the last known `activeState`
3. If different, pushes `activeState` onto `undoQueue` and replaces it
**It is not reactive.** Changes to the graph (widget values, node positions,
links, etc.) are only captured when `captureCanvasState()` is explicitly triggered.
**INVARIANT:** `captureCanvasState()` asserts that it is called on the active
workflow's tracker. Calling it on an inactive tracker logs a warning and
returns early, preventing cross-workflow data corruption.
links, etc.) are only captured when `checkState()` is explicitly triggered.
## Automatic Triggers
@@ -35,7 +31,7 @@ These are set up once in `ChangeTracker.init()`:
| Graph cleared | `api` `graphCleared` event | Full graph clear |
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
## When You Must Call `captureCanvasState()` Manually
## When You Must Call `checkState()` Manually
The automatic triggers above are designed around LiteGraph's native DOM
rendering. They **do not cover**:
@@ -54,42 +50,24 @@ rendering. They **do not cover**:
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
// After mutating the graph:
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
```
### Existing Manual Call Sites
These locations call `captureCanvasState()` directly:
These locations already call `checkState()` explicitly:
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
- `ColorPickerButton.vue` — After changing node colors
- `NodeSearchBoxPopover.vue` — After adding a node from search
- `builderViewOptions.ts` — After setting default view
- `useAppSetDefaultView.ts` — After setting default view
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
- `useGroupMenuOptions.ts` — After group operations
- `useSubgraphOperations.ts` — After subgraph enter/exit
- `useCanvasRefresh.ts` — After canvas refresh
- `useCoreCommands.ts` — After metadata/subgraph commands
- `appModeStore.ts` — After app mode transitions
`workflowService.ts` calls `captureCanvasState()` indirectly via
`deactivate()` and `prepareForSave()` (see Lifecycle Methods below).
> **Deprecated:** `checkState()` is an alias for `captureCanvasState()` kept
> for extension compatibility. Extension authors should migrate to
> `captureCanvasState()`. See the `@deprecated` JSDoc on the method.
## Lifecycle Methods
| Method | Caller | Purpose |
| ---------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots canvas into activeState, pushes undo. Asserts active tracker. |
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()` (skipped during undo/redo) + `store()`. Freezes state for tab switch. Must be called while this workflow is still active. |
| `prepareForSave()` | Save paths only | Active: calls `captureCanvasState()`. Inactive: no-op (state was frozen by `deactivate()`). |
| `store()` | Internal to `deactivate()` | Saves viewport scale/offset, node outputs, subgraph navigation. |
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs, subgraph navigation. |
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks workflow as "clean"). |
- `workflowService.ts` — After workflow service operations
## Transaction Guards
@@ -98,7 +76,7 @@ For operations that make multiple changes that should be a single undo entry:
```typescript
changeTracker.beforeChange()
// ... multiple graph mutations ...
changeTracker.afterChange() // calls captureCanvasState() when nesting count hits 0
changeTracker.afterChange() // calls checkState() when nesting count hits 0
```
The `litegraph:canvas` custom event also supports this with `before-change` /
@@ -106,12 +84,8 @@ The `litegraph:canvas` custom event also supports this with `before-change` /
## Key Invariants
- `captureCanvasState()` asserts it is called on the active workflow's tracker;
inactive trackers get an early return (and a warning log)
- `captureCanvasState()` is a no-op during `loadGraphData` (guarded by
- `checkState()` is a no-op during `loadGraphData` (guarded by
`isLoadingGraph`) to prevent cross-workflow corruption
- `captureCanvasState()` is a no-op during undo/redo (guarded by
`_restoringState`) to prevent undo history corruption
- `captureCanvasState()` is a no-op when `changeCount > 0` (inside a transaction)
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing

File diff suppressed because it is too large Load Diff

View File

@@ -171,10 +171,14 @@ const sidebarPanelVisible = computed(
)
const firstPanelVisible = computed(
() => sidebarLocation.value === 'left' || showOffsideSplitter.value
() =>
!focusMode.value &&
(sidebarLocation.value === 'left' || showOffsideSplitter.value)
)
const lastPanelVisible = computed(
() => sidebarLocation.value === 'right' || showOffsideSplitter.value
() =>
!focusMode.value &&
(sidebarLocation.value === 'right' || showOffsideSplitter.value)
)
/**
@@ -264,7 +268,6 @@ const splitterRefreshKey = computed(() => {
})
const firstPanelStyle = computed(() => {
if (focusMode.value) return { display: 'none' }
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -272,7 +275,6 @@ const firstPanelStyle = computed(() => {
})
const lastPanelStyle = computed(() => {
if (focusMode.value) return { display: 'none' }
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -291,13 +293,9 @@ const lastPanelStyle = computed(() => {
background-color: var(--p-primary-color);
}
/* Hide gutter when adjacent panel is not visible */
:deep(
[data-pc-name='splitterpanel'][style*='display: none'] + .p-splitter-gutter
),
:deep(
.p-splitter-gutter + [data-pc-name='splitterpanel'][style*='display: none']
) {
/* Hide sidebar gutter when sidebar is not visible */
:deep(.side-bar-panel[style*='display: none'] + .p-splitter-gutter),
:deep(.p-splitter-gutter + .side-bar-panel[style*='display: none']) {
display: none;
}

View File

@@ -46,7 +46,7 @@ const mockActiveWorkflow = ref<{
isTemporary: boolean
initialMode?: string
isModified?: boolean
changeTracker?: { captureCanvasState: () => void }
changeTracker?: { checkState: () => void }
} | null>({
isTemporary: true,
initialMode: 'app'

View File

@@ -49,10 +49,10 @@ describe('setWorkflowDefaultView', () => {
expect(app.rootGraph.extra.linearMode).toBe(false)
})
it('calls changeTracker.captureCanvasState', () => {
it('calls changeTracker.checkState', () => {
const workflow = createMockLoadedWorkflow()
setWorkflowDefaultView(workflow, true)
expect(workflow.changeTracker.captureCanvasState).toHaveBeenCalledOnce()
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
})
it('tracks telemetry with correct default_view', () => {

View File

@@ -9,7 +9,7 @@ export function setWorkflowDefaultView(
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.captureCanvasState()
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})

View File

@@ -31,7 +31,7 @@ function createMockWorkflow(
const changeTracker = Object.assign(
new ChangeTracker(workflow, structuredClone(defaultGraph)),
{
captureCanvasState: vi.fn() as Mock
checkState: vi.fn() as Mock
}
)

View File

@@ -125,7 +125,7 @@ const applyColor = (colorOption: ColorOption | null) => {
canvasStore.canvas?.setDirty(true, true)
currentColorOption.value = canvasColorOption
showColorPicker.value = false
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker.checkState()
}
const currentColorOption = ref<CanvasColorOption | null>(null)

View File

@@ -143,7 +143,7 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
disconnectOnReset = false
// Notify changeTracker - new step should be added
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
window.requestAnimationFrame(closeDialog)
}

View File

@@ -13,7 +13,7 @@ export function useCanvasRefresh() {
canvasStore.canvas?.setDirty(true, true)
canvasStore.canvas?.graph?.afterChange()
canvasStore.canvas?.emitAfterChange()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
return {

View File

@@ -36,7 +36,7 @@ export function useGroupMenuOptions() {
groupContext.resizeTo(groupContext.children, padding)
groupContext.graph?.change()
canvasStore.canvas?.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
})
@@ -119,7 +119,7 @@ export function useGroupMenuOptions() {
})
canvasStore.canvas?.setDirty(true, true)
groupContext.graph?.change()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
bump()
}
})

View File

@@ -23,7 +23,7 @@ export function useSelectedNodeActions() {
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodeCollapse = () => {
@@ -33,7 +33,7 @@ export function useSelectedNodeActions() {
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodePin = () => {
@@ -43,7 +43,7 @@ export function useSelectedNodeActions() {
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodeBypass = () => {

View File

@@ -47,7 +47,7 @@ export function useSelectionOperations() {
canvas.pasteFromClipboard({ connectInputs: false })
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const duplicateSelection = () => {
@@ -73,7 +73,7 @@ export function useSelectionOperations() {
canvas.pasteFromClipboard({ connectInputs: false })
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const deleteSelection = () => {
@@ -92,7 +92,7 @@ export function useSelectionOperations() {
canvas.setDirty(true, true)
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const renameSelection = async () => {
@@ -122,7 +122,7 @@ export function useSelectionOperations() {
const titledItem = item as { title: string }
titledItem.title = newTitle
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
}
return
@@ -145,7 +145,7 @@ export function useSelectionOperations() {
}
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
return
}

View File

@@ -31,7 +31,7 @@ export function useSubgraphOperations() {
canvas.select(node)
canvasStore.updateSelectedItems()
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const doUnpack = (
@@ -46,7 +46,7 @@ export function useSubgraphOperations() {
nodeOutputStore.revokeSubgraphPreviews(subgraphNode)
graph.unpackSubgraph(subgraphNode, { skipMissingNodes })
}
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const unpackSubgraph = () => {

View File

@@ -1,446 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { api } from '@/scripts/api'
import { usePainter } from './usePainter'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}))
}))
vi.mock('@vueuse/core', () => ({
useElementSize: vi.fn(() => ({
width: ref(512),
height: ref(512)
}))
}))
vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({
StrokeProcessor: vi.fn(() => ({
addPoint: vi.fn(() => []),
endStroke: vi.fn(() => [])
}))
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/updates/common/toastStore', () => {
const store = { addAlert: vi.fn() }
return { useToastStore: () => store }
})
vi.mock('@/stores/nodeOutputStore', () => {
const store = {
getNodeImageUrls: vi.fn(() => undefined),
nodeOutputs: {},
nodePreviewImages: {}
}
return { useNodeOutputStore: () => store }
})
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
fetchApi: vi.fn()
}
}))
const mockWidgets: IBaseWidget[] = []
const mockProperties: Record<string, unknown> = {}
const mockIsInputConnected = vi.fn(() => false)
const mockGetInputNode = vi.fn(() => null)
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: {
getNodeById: vi.fn(() => ({
get widgets() {
return mockWidgets
},
get properties() {
return mockProperties
},
isInputConnected: mockIsInputConnected,
getInputNode: mockGetInputNode
}))
}
}
}
}))
type PainterResult = ReturnType<typeof usePainter>
function makeWidget(name: string, value: unknown = null): IBaseWidget {
return {
name,
value,
callback: vi.fn(),
serializeValue: undefined
} as unknown as IBaseWidget
}
/**
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
*/
function mountPainter(nodeId = 'test-node', initialModelValue = '') {
let painter!: PainterResult
const canvasEl = ref<HTMLCanvasElement | null>(null)
const cursorEl = ref<HTMLElement | null>(null)
const modelValue = ref(initialModelValue)
const Wrapper = defineComponent({
setup() {
painter = usePainter(nodeId, {
canvasEl,
cursorEl,
modelValue
})
return {}
},
render() {
return null
}
})
render(Wrapper)
return { painter, canvasEl, cursorEl, modelValue }
}
describe('usePainter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
mockWidgets.length = 0
for (const key of Object.keys(mockProperties)) {
delete mockProperties[key]
}
mockIsInputConnected.mockReturnValue(false)
mockGetInputNode.mockReturnValue(null)
})
describe('syncCanvasSizeFromWidgets', () => {
it('reads width/height from widget values on initialization', () => {
mockWidgets.push(makeWidget('width', 1024), makeWidget('height', 768))
const { painter } = mountPainter()
expect(painter.canvasWidth.value).toBe(1024)
expect(painter.canvasHeight.value).toBe(768)
})
it('defaults to 512 when widgets are missing', () => {
const { painter } = mountPainter()
expect(painter.canvasWidth.value).toBe(512)
expect(painter.canvasHeight.value).toBe(512)
})
})
describe('restoreSettingsFromProperties', () => {
it('restores tool and brush settings from node properties on init', () => {
mockProperties.painterTool = 'eraser'
mockProperties.painterBrushSize = 42
mockProperties.painterBrushColor = '#ff0000'
mockProperties.painterBrushOpacity = 0.5
mockProperties.painterBrushHardness = 0.8
const { painter } = mountPainter()
expect(painter.tool.value).toBe('eraser')
expect(painter.brushSize.value).toBe(42)
expect(painter.brushColor.value).toBe('#ff0000')
expect(painter.brushOpacity.value).toBe(0.5)
expect(painter.brushHardness.value).toBe(0.8)
})
it('restores backgroundColor from bg_color widget', () => {
mockWidgets.push(makeWidget('bg_color', '#123456'))
const { painter } = mountPainter()
expect(painter.backgroundColor.value).toBe('#123456')
})
it('keeps defaults when no properties are stored', () => {
const { painter } = mountPainter()
expect(painter.tool.value).toBe('brush')
expect(painter.brushSize.value).toBe(20)
expect(painter.brushColor.value).toBe('#ffffff')
expect(painter.brushOpacity.value).toBe(1)
expect(painter.brushHardness.value).toBe(1)
})
})
describe('saveSettingsToProperties', () => {
it('persists tool settings to node properties when they change', async () => {
const { painter } = mountPainter()
painter.tool.value = 'eraser'
painter.brushSize.value = 50
painter.brushColor.value = '#00ff00'
painter.brushOpacity.value = 0.7
painter.brushHardness.value = 0.3
await nextTick()
expect(mockProperties.painterTool).toBe('eraser')
expect(mockProperties.painterBrushSize).toBe(50)
expect(mockProperties.painterBrushColor).toBe('#00ff00')
expect(mockProperties.painterBrushOpacity).toBe(0.7)
expect(mockProperties.painterBrushHardness).toBe(0.3)
})
})
describe('syncCanvasSizeToWidgets', () => {
it('syncs canvas dimensions to widgets when size changes', async () => {
const widthWidget = makeWidget('width', 512)
const heightWidget = makeWidget('height', 512)
mockWidgets.push(widthWidget, heightWidget)
const { painter } = mountPainter()
painter.canvasWidth.value = 800
painter.canvasHeight.value = 600
await nextTick()
expect(widthWidget.value).toBe(800)
expect(heightWidget.value).toBe(600)
expect(widthWidget.callback).toHaveBeenCalledWith(800)
expect(heightWidget.callback).toHaveBeenCalledWith(600)
})
})
describe('syncBackgroundColorToWidget', () => {
it('syncs background color to widget when color changes', async () => {
const bgWidget = makeWidget('bg_color', '#000000')
mockWidgets.push(bgWidget)
const { painter } = mountPainter()
painter.backgroundColor.value = '#ff00ff'
await nextTick()
expect(bgWidget.value).toBe('#ff00ff')
expect(bgWidget.callback).toHaveBeenCalledWith('#ff00ff')
})
})
describe('updateInputImageUrl', () => {
it('sets isImageInputConnected to false when input is not connected', () => {
const { painter } = mountPainter()
expect(painter.isImageInputConnected.value).toBe(false)
expect(painter.inputImageUrl.value).toBeNull()
})
it('sets isImageInputConnected to true when input is connected', () => {
mockIsInputConnected.mockReturnValue(true)
const { painter } = mountPainter()
expect(painter.isImageInputConnected.value).toBe(true)
})
})
describe('handleInputImageLoad', () => {
it('updates canvas size and widgets from loaded image dimensions', () => {
const widthWidget = makeWidget('width', 512)
const heightWidget = makeWidget('height', 512)
mockWidgets.push(widthWidget, heightWidget)
const { painter } = mountPainter()
const fakeEvent = {
target: {
naturalWidth: 1920,
naturalHeight: 1080
}
} as unknown as Event
painter.handleInputImageLoad(fakeEvent)
expect(painter.canvasWidth.value).toBe(1920)
expect(painter.canvasHeight.value).toBe(1080)
expect(widthWidget.value).toBe(1920)
expect(heightWidget.value).toBe(1080)
})
})
describe('cursor visibility', () => {
it('sets cursorVisible to true on pointer enter', () => {
const { painter } = mountPainter()
painter.handlePointerEnter()
expect(painter.cursorVisible.value).toBe(true)
})
it('sets cursorVisible to false on pointer leave', () => {
const { painter } = mountPainter()
painter.handlePointerEnter()
painter.handlePointerLeave()
expect(painter.cursorVisible.value).toBe(false)
})
})
describe('displayBrushSize', () => {
it('scales brush size by canvas display ratio', () => {
const { painter } = mountPainter()
// canvasDisplayWidth=512, canvasWidth=512 → ratio=1
// hardness=1 → effectiveRadius = radius * 1.0
// displayBrushSize = (20/2) * 1.0 * 2 * 1 = 20
expect(painter.displayBrushSize.value).toBe(20)
})
it('increases for soft brush hardness', () => {
const { painter } = mountPainter()
painter.brushHardness.value = 0
// hardness=0 → effectiveRadius = 10 * 1.5 = 15
// displayBrushSize = 15 * 2 * 1 = 30
expect(painter.displayBrushSize.value).toBe(30)
})
})
describe('activeHardness (via displayBrushSize)', () => {
it('returns 1 for eraser regardless of brushHardness', () => {
const { painter } = mountPainter()
painter.brushHardness.value = 0.3
painter.tool.value = 'eraser'
// eraser hardness=1 → displayBrushSize = 10 * 1.0 * 2 = 20
expect(painter.displayBrushSize.value).toBe(20)
})
it('uses brushHardness for brush tool', () => {
const { painter } = mountPainter()
painter.tool.value = 'brush'
painter.brushHardness.value = 0.5
// hardness=0.5 → scale=1.25 → 10*1.25*2 = 25
expect(painter.displayBrushSize.value).toBe(25)
})
})
describe('registerWidgetSerialization', () => {
it('attaches serializeValue to the mask widget on init', () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter()
expect(maskWidget.serializeValue).toBeTypeOf('function')
})
})
describe('serializeValue', () => {
it('returns empty string when canvas has no strokes', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
})
it('returns existing modelValue when not dirty', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const { modelValue } = mountPainter()
modelValue.value = 'painter/existing.png [temp]'
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
// isCanvasEmpty() is true (no strokes drawn), so returns ''
expect(result).toBe('')
})
})
describe('restoreCanvas', () => {
it('builds correct URL from modelValue on mount', () => {
const { modelValue } = mountPainter()
// Before mount, set the modelValue
// restoreCanvas is called in onMounted, so we test by observing api.apiURL calls
// With empty modelValue, restoreCanvas exits early
expect(modelValue.value).toBe('')
})
it('calls api.apiURL with parsed filename params when modelValue is set', () => {
vi.mocked(api.apiURL).mockClear()
mountPainter('test-node', 'painter/my-image.png [temp]')
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('filename=my-image.png')
)
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('subfolder=painter')
)
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('type=temp')
)
})
})
describe('handleClear', () => {
it('does not throw when canvas element is null', () => {
const { painter } = mountPainter()
expect(() => painter.handleClear()).not.toThrow()
})
})
describe('handlePointerDown', () => {
it('ignores non-primary button clicks', () => {
const { painter } = mountPainter()
const mockSetPointerCapture = vi.fn()
const event = new PointerEvent('pointerdown', {
button: 2
})
Object.defineProperty(event, 'target', {
value: {
setPointerCapture: mockSetPointerCapture
}
})
painter.handlePointerDown(event)
expect(mockSetPointerCapture).not.toHaveBeenCalled()
})
})
describe('handlePointerUp', () => {
it('ignores non-primary button releases', () => {
const { painter } = mountPainter()
const mockReleasePointerCapture = vi.fn()
const event = {
button: 2,
target: {
releasePointerCapture: mockReleasePointerCapture
}
} as unknown as PointerEvent
painter.handlePointerUp(event)
expect(mockReleasePointerCapture).not.toHaveBeenCalled()
})
})
})

View File

@@ -94,7 +94,7 @@ vi.mock('@/stores/toastStore', () => ({
}))
const mockChangeTracker = vi.hoisted(() => ({
captureCanvasState: vi.fn()
checkState: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: {
@@ -382,7 +382,7 @@ describe('useCoreCommands', () => {
expect(mockDialogService.prompt).toHaveBeenCalled()
expect(mockSubgraph.extra.BlueprintDescription).toBe('Test description')
expect(mockChangeTracker.captureCanvasState).toHaveBeenCalled()
expect(mockChangeTracker.checkState).toHaveBeenCalled()
})
it('should not set description when user cancels', async () => {
@@ -397,7 +397,7 @@ describe('useCoreCommands', () => {
await setDescCommand.function()
expect(mockSubgraph.extra.BlueprintDescription).toBeUndefined()
expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled()
expect(mockChangeTracker.checkState).not.toHaveBeenCalled()
})
})
@@ -432,7 +432,7 @@ describe('useCoreCommands', () => {
'alias2',
'alias3'
])
expect(mockChangeTracker.captureCanvasState).toHaveBeenCalled()
expect(mockChangeTracker.checkState).toHaveBeenCalled()
})
it('should trim whitespace and filter empty strings', async () => {
@@ -478,7 +478,7 @@ describe('useCoreCommands', () => {
await setAliasesCommand.function()
expect(mockSubgraph.extra.BlueprintSearchAliases).toBeUndefined()
expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled()
expect(mockChangeTracker.checkState).not.toHaveBeenCalled()
})
})
})

View File

@@ -1164,7 +1164,7 @@ export function useCoreCommands(): ComfyCommand[] {
if (description === null) return
extra.BlueprintDescription = description.trim() || undefined
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
},
{
@@ -1201,7 +1201,7 @@ export function useCoreCommands(): ComfyCommand[] {
}
extra.BlueprintSearchAliases = aliases.length > 0 ? aliases : undefined
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
},
{

View File

@@ -430,17 +430,6 @@ describe('useLoad3dViewer', () => {
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(false)
})
it('should sync hover state when mouseenter fires before init', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
viewer.handleMouseEnter()
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
})
describe('restoreInitialState', () => {

View File

@@ -86,7 +86,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
let currentModelUrl: string | null = null
let mouseOnViewer = false
const initialState = ref<Load3dViewerState>({
backgroundColor: '#282828',
@@ -305,10 +304,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isViewerMode: hasTargetDimensions
})
if (mouseOnViewer) {
load3d.updateStatusMouseOnViewer(true)
}
await useLoad3dService().copyLoad3dState(source, load3d)
const sourceCameraState = source.getCameraState()
@@ -421,10 +416,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isViewerMode: true
})
if (mouseOnViewer) {
load3d.updateStatusMouseOnViewer(true)
}
await load3d.loadModel(modelUrl)
currentModelUrl = modelUrl
restoreStandaloneConfig(modelUrl)
@@ -531,7 +522,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
* Notifies the viewer that the mouse has entered the viewer area.
*/
const handleMouseEnter = () => {
mouseOnViewer = true
load3d?.updateStatusMouseOnViewer(true)
}
@@ -539,7 +529,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
* Notifies the viewer that the mouse has left the viewer area.
*/
const handleMouseLeave = () => {
mouseOnViewer = false
load3d?.updateStatusMouseOnViewer(false)
}
@@ -738,7 +727,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
if (isStandaloneMode.value) {
saveStandaloneConfig()
}
mouseOnViewer = false
load3d?.remove()
load3d = null
sourceLoad3d = null

View File

@@ -2674,13 +2674,6 @@
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached"
},
"remoteCombo": {
"loading": "Loading...",
"loadFailed": "Failed to load options",
"itemsLoaded": "{count} item loaded... | {count} items loaded...",
"playAudioPreview": "Play audio preview",
"pauseAudioPreview": "Pause audio preview"
},
"valueControl": {
"header": {
"prefix": "Automatically update the value",

View File

@@ -140,7 +140,7 @@ export const useWorkflowService = () => {
}
if (isSelfOverwrite) {
workflow.changeTracker?.prepareForSave()
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
await saveWorkflow(workflow)
} else {
let target: ComfyWorkflow
@@ -157,7 +157,8 @@ export const useWorkflowService = () => {
app.rootGraph.extra.linearMode = isApp
target.initialMode = isApp ? 'app' : 'graph'
}
target.changeTracker?.prepareForSave()
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
await workflowStore.saveWorkflow(target)
}
@@ -173,7 +174,8 @@ export const useWorkflowService = () => {
if (workflow.isTemporary) {
await saveWorkflowAs(workflow)
} else {
workflow.changeTracker?.prepareForSave()
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory +
@@ -368,7 +370,7 @@ export const useWorkflowService = () => {
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker?.deactivate()
activeWorkflow.changeTracker.store()
if (settingStore.get('Comfy.Workflow.Persist') && activeWorkflow.path) {
const activeState = activeWorkflow.activeState
if (activeState) {

View File

@@ -1,290 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '@testing-library/vue'
import axios, { AxiosError, AxiosHeaders } from 'axios'
import type * as AxiosModule from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
RemoteComboConfig,
RemoteItemSchema
} from '@/schemas/nodeDefSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockWidget } from './widgetTestUtils'
// Preserve everything axios exports — only `default.get` is the call site we
// drive. Other modules in the import graph (e.g. workspaceApi) call
// axios.create() at module-load time, so we can't replace the default outright.
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof AxiosModule>()
return {
...actual,
default: { ...actual.default, get: vi.fn() }
}
})
// All four auth-related composables are mocked at module level so the SFC's
// imports never pull in firebase / vuefire. Their return shapes only need to
// satisfy the call sites the widget actually hits.
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: { teamWorkspacesEnabled: false } })
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
userId: undefined,
getAuthHeader: vi.fn(() => Promise.resolve(null))
})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({ getApiKey: () => null })
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
// Minimal stub: surfaces the props the widget binds (so we can assert on them)
// and exposes click affordances that emit `update:selected` for the user-action
// tests. The real FormDropdown's rendering is tested in its own suite.
const FormDropdownStub = {
name: 'FormDropdown',
props: [
'selected',
'items',
'placeholder',
'multiple',
'showSort',
'showLayoutSwitcher',
'searcher',
'layoutMode'
],
emits: ['update:selected', 'update:layoutMode'],
template: `
<div data-testid="dropdown">
<span data-testid="placeholder">{{ placeholder }}</span>
<span data-testid="items-count">{{ items.length }}</span>
<button
v-for="item in items"
:key="item.id"
:data-testid="'item-' + item.id"
@click="$emit('update:selected', new Set([item.id]))"
>
{{ item.name }}
</button>
<button
data-testid="deselect"
@click="$emit('update:selected', new Set())"
>×</button>
</div>
`
}
const baseSchema: RemoteItemSchema = {
value_field: 'id',
label_field: 'name',
preview_type: 'image'
}
function buildWidget(
remoteCombo: Partial<Omit<RemoteComboConfig, 'route' | 'item_schema'>> = {},
value: string | undefined = undefined
): SimplifiedWidget<string | undefined> {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'voice',
remote_combo: {
route: '/voices',
item_schema: baseSchema,
...remoteCombo
}
}
return createMockWidget<string | undefined>({
name: 'voice',
type: 'COMBO',
value,
spec
})
}
function renderWidget(
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined = undefined
) {
return render(RichComboWidget, {
props: {
widget,
modelValue: modelValue ?? widget.value
},
global: {
plugins: [createTestingPinia(), i18n],
stubs: { FormDropdown: FormDropdownStub }
}
})
}
function mockAxiosResponseOnce(data: unknown) {
vi.mocked(axios.get).mockResolvedValueOnce({ data })
}
function mockAxiosErrorOnce(status: number) {
vi.mocked(axios.get).mockRejectedValueOnce(
new AxiosError(`HTTP ${status}`, 'ERR_BAD_RESPONSE', undefined, undefined, {
status,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
})
)
}
function mockAxiosNetworkErrorOnce() {
vi.mocked(axios.get).mockRejectedValueOnce(
new AxiosError('Network Error', 'ERR_NETWORK')
)
}
beforeEach(() => {
vi.clearAllMocks()
// Cache API isn't in happy-dom by default. Stub a no-op cache so getCached
// always returns null (forces a fetch) and setCache/clearCache resolve.
vi.stubGlobal('caches', {
open: vi.fn(() =>
Promise.resolve({
match: vi.fn(() => Promise.resolve(undefined)),
put: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve(true))
})
)
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('RichComboWidget', () => {
it('mounts, fetches, and renders the items returned from the route', async () => {
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
expect(screen.getByText('Alice')).toBeTruthy()
expect(screen.getByText('Bob')).toBeTruthy()
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('shows the load-failed placeholder on a non-retriable 404 without retrying', async () => {
mockAxiosErrorOnce(404)
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.remoteCombo.loadFailed'
)
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('shows the load-failed placeholder when retries are exhausted', async () => {
// max_retries=1 lets us assert exhaustion without sleeping through the
// exponential backoff (`attempts++` then `attempts >= maxRetries` breaks
// before any setTimeout call).
mockAxiosNetworkErrorOnce()
renderWidget(buildWidget({ max_retries: 1 }))
await waitFor(() =>
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.remoteCombo.loadFailed'
)
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('terminates pagination when the server reports has_more=false', async () => {
mockAxiosResponseOnce({
items: [{ id: 'a', name: 'Alice' }],
has_more: true
})
mockAxiosResponseOnce({
items: [{ id: 'b', name: 'Bob' }],
has_more: false
})
renderWidget(buildWidget({ page_size: 1 }))
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('refetches when the refresh button is clicked', async () => {
mockAxiosResponseOnce([{ id: 'a', name: 'Alice' }])
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('1')
)
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
await userEvent.click(screen.getByLabelText('g.refresh'))
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('clears modelValue to undefined when the selected item is toggled off (B1 regression)', async () => {
mockAxiosResponseOnce([{ id: 'a', name: 'Alice' }])
const { emitted } = renderWidget(buildWidget(), 'a')
expect(await screen.findByTestId('item-a')).toBeTruthy()
await userEvent.click(screen.getByTestId('deselect'))
const updates = emitted('update:modelValue')
expect(updates).toBeTruthy()
expect(updates!.at(-1)).toEqual([undefined])
})
it('preserves a stale modelValue when the fetched items do not contain that id', async () => {
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
const { emitted } = renderWidget(buildWidget(), 'stale-id')
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
// The selection sync watcher only mutates the internal selectedSet — it
// never writes to modelValue, so the stale id round-trips intact when the
// workflow is later saved.
expect(emitted('update:modelValue')).toBeFalsy()
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.uploadSelect.placeholder'
)
})
})

View File

@@ -1,481 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
RemoteComboConfig,
RemoteItemSchema
} from '@/schemas/nodeDefSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
import { AssetKindKey } from './form/dropdown/types'
import {
buildSearchText,
extractItems,
getByPath,
mapToDropdownItem
} from '../utils/itemSchemaUtils'
import { fetchRemoteRoute } from '../utils/fetchRemoteRoute'
import {
buildCacheKey,
getBackoff,
isRetriableError,
summarizeError,
summarizePayload
} from '../utils/richComboHelpers'
const DEFAULT_MAX_RETRIES = 5
const DEFAULT_TIMEOUT = 30000
// --- Persistent cache using browser Cache API (survives page reloads) ---
const CACHE_NAME = 'comfy-remote-widget'
// Mirrors useAuthStore().getAuthHeader()'s priority chain so the cache is
// partitioned by the *active* auth context, not just the firebase user.
// Same firebase user across two workspaces, or across workspace ↔ personal,
// would otherwise share a cache and bleed data.
//
// Returns an opaque, non-secret identifier. The API-key branch deliberately
// returns a constant rather than the key value or a hash of it: hashing is
// async (SubtleCrypto), and grouping all keys on one machine under a single
// scope is an acceptable tradeoff for the rare key-rotation case.
function getAuthScope(): string {
const { flags } = useFeatureFlags()
if (flags.teamWorkspacesEnabled) {
const wsId = useWorkspaceAuthStore().currentWorkspace?.id
if (wsId) return `ws:${wsId}`
}
const uid = useAuthStore().userId
if (uid) return `fb:${uid}`
return useApiKeyAuthStore().getApiKey() ? 'apikey' : 'anon'
}
function cacheKeyFor(config: RemoteComboConfig): string {
// Mirror the original lazy lookup: only resolve the auth scope when it
// actually contributes to the key (use_comfy_api routes).
const authScope = config.use_comfy_api ? getAuthScope() : undefined
return buildCacheKey(config, authScope)
}
async function getCached(config: RemoteComboConfig): Promise<unknown[] | null> {
try {
const cache = await caches.open(CACHE_NAME)
const resp = await cache.match(cacheKeyFor(config))
if (!resp) return null
const entry = await resp.json()
const ttl = config.refresh
if (!ttl || ttl <= 0) return entry.data
if (Date.now() - entry.timestamp < ttl) return entry.data
return null
} catch {
return null
}
}
async function clearCache(config: RemoteComboConfig) {
try {
const cache = await caches.open(CACHE_NAME)
await cache.delete(cacheKeyFor(config))
} catch {
// ignore
}
}
async function setCache(config: RemoteComboConfig, data: unknown[]) {
try {
const cache = await caches.open(CACHE_NAME)
const body = JSON.stringify({ data, timestamp: Date.now() })
await cache.put(cacheKeyFor(config), new Response(body))
} catch {
// Cache API unavailable — widget still works, just no persistence
}
}
const { widget } = defineProps<{
widget: SimplifiedWidget<string | undefined>
}>()
const modelValue = defineModel<string>()
const { t } = useI18n()
const comboSpec = computed(() => {
if (widget.spec && isComboInputSpec(widget.spec)) {
return widget.spec
}
return undefined
})
const remoteConfig = computed<RemoteComboConfig | undefined>(
() => comboSpec.value?.remote_combo
)
const itemSchema = computed<RemoteItemSchema | undefined>(
() => remoteConfig.value?.item_schema
)
// --- Fetch state ---
const rawItems = ref<unknown[]>([])
const loading = ref(false)
const loadingMore = ref(false)
const error = ref<string | null>(null)
let abortController: AbortController | undefined
// --- Auto-select policy ---
// Only sets modelValue when it's empty; never overrides an existing value
// (valid or stale) — user intent and workflow portability are preserved.
// 'first' may fire as soon as items exist (per-page in paginated mode);
// 'last' fires only after terminal success, since the actual last item
// isn't known until all pages have loaded.
function applyAutoSelect(config: RemoteComboConfig) {
if (modelValue.value) return
const list = items.value
if (list.length === 0) return
if (config.auto_select === 'first') {
modelValue.value = list[0].id
} else if (config.auto_select === 'last') {
modelValue.value = list[list.length - 1].id
}
}
// --- Single-page fetch (non-paginated mode) ---
async function fetchAll(config: RemoteComboConfig) {
const controller = abortController!
const maxRetries = config.max_retries ?? DEFAULT_MAX_RETRIES
loading.value = true
error.value = null
let attempts = 0
while (!controller.signal.aborted) {
try {
const res = await fetchRemoteRoute(config.route, {
timeout: config.timeout ?? DEFAULT_TIMEOUT,
signal: controller.signal,
useComfyApi: config.use_comfy_api
})
if (controller.signal.aborted) return
const fetchedItems = extractItems(res.data, config.response_key)
if (fetchedItems === null) {
console.error('RichComboWidget: expected array response', {
route: config.route,
responseKey: config.response_key,
received: summarizePayload(res.data)
})
error.value = t('widgets.remoteCombo.loadFailed')
break
}
await setCache(config, fetchedItems)
if (controller.signal.aborted) return
rawItems.value = fetchedItems
applyAutoSelect(config)
break
} catch (err: unknown) {
if (controller.signal.aborted) return
console.error('RichComboWidget: fetch error', {
route: config.route,
error: summarizeError(err)
})
if (!isRetriableError(err)) {
error.value = t('widgets.remoteCombo.loadFailed')
break
}
attempts++
if (attempts >= maxRetries) {
error.value = t('widgets.remoteCombo.loadFailed')
break
}
const delay = getBackoff(attempts)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
if (!controller.signal.aborted) {
loading.value = false
}
}
// --- Progressive fetch (paginated mode) ---
async function fetchPaginated(config: RemoteComboConfig) {
const controller = abortController!
const pageSize = config.page_size!
const maxRetries = config.max_retries ?? DEFAULT_MAX_RETRIES
let page = 0
let consecutiveErrors = 0
let terminalSuccess = false
// First page shows loading indicator
loading.value = true
error.value = null
while (!controller.signal.aborted) {
try {
const params = {
page: String(page),
page_size: String(pageSize)
}
const res = await fetchRemoteRoute(config.route, {
params,
timeout: config.timeout ?? DEFAULT_TIMEOUT,
signal: controller.signal,
useComfyApi: config.use_comfy_api
})
if (controller.signal.aborted) return
if (
!res.data ||
typeof res.data !== 'object' ||
Array.isArray(res.data)
) {
console.error(
'RichComboWidget: expected { items, has_more } response',
{
route: config.route,
page,
received: summarizePayload(res.data)
}
)
break
}
const pageItems: unknown[] = Array.isArray(res.data.items)
? res.data.items
: []
const hasMore: boolean = res.data.has_more === true
rawItems.value = [...rawItems.value, ...pageItems]
consecutiveErrors = 0
// After first page, switch from "loading" to "loading more"
if (page === 0) {
loading.value = false
loadingMore.value = true
}
// 'first' is known as soon as we have any items; idempotent thereafter.
if (config.auto_select === 'first') applyAutoSelect(config)
if (!hasMore || pageItems.length === 0) {
terminalSuccess = true
break
}
page++
} catch (err: unknown) {
if (controller.signal.aborted) return
if (!isRetriableError(err)) {
console.error(`RichComboWidget: non-retriable error on page ${page}`, {
route: config.route,
error: summarizeError(err)
})
break
}
consecutiveErrors++
if (consecutiveErrors >= maxRetries) {
console.error(
`RichComboWidget: giving up after ${maxRetries} consecutive errors on page ${page}`,
{ route: config.route, error: summarizeError(err) }
)
break
}
// Retry same page after backoff
const delay = getBackoff(consecutiveErrors)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
if (controller.signal.aborted) return
// Cache the accumulated result before releasing loading state; an abort
// during setCache then skips the state reset instead of flickering it.
// Only cache on terminal success — caching partial results would poison the
// next mount with an incomplete list and never re-fetch the missing pages.
if (terminalSuccess && rawItems.value.length > 0) {
await setCache(config, rawItems.value)
}
if (controller.signal.aborted) return
loading.value = false
loadingMore.value = false
if (!terminalSuccess && rawItems.value.length === 0) {
error.value = t('widgets.remoteCombo.loadFailed')
}
if (terminalSuccess) {
applyAutoSelect(config)
}
}
async function fetchItems(bypassCache = false) {
const config = remoteConfig.value
if (!config) return
// Claim the active controller before any async work so the cache-hit
// path can bail out if a later fetchItems supersedes us.
abortController?.abort()
const myController = new AbortController()
abortController = myController
// Check cache first (unless manual refresh)
if (!bypassCache) {
const cached = await getCached(config)
if (myController.signal.aborted) return
if (cached) {
rawItems.value = cached
applyAutoSelect(config)
return
}
}
// Reset items for fresh fetch
rawItems.value = []
if (config.page_size) {
await fetchPaginated(config)
} else {
await fetchAll(config)
}
}
onMounted(() => {
void fetchItems()
})
onUnmounted(() => {
abortController?.abort()
})
// --- Preview type ---
const assetKind = computed(() => itemSchema.value?.preview_type ?? 'image')
provide(AssetKindKey, assetKind)
// --- Item mapping ---
const items = computed<FormDropdownItem[]>(() => {
const schema = itemSchema.value
if (schema) {
return rawItems.value.map((raw) => mapToDropdownItem(raw, schema))
}
return rawItems.value.map((raw) => {
const val = String(raw ?? '')
return { id: val, name: val }
})
})
// --- Search ---
const searchIndex = computed(() => {
const schema = itemSchema.value
const fields = schema?.search_fields
if (!schema || !fields?.length) return new Map<string, string>()
const index = new Map<string, string>()
for (const raw of rawItems.value) {
const id = String(getByPath(raw, schema.value_field) ?? '')
const text = buildSearchText(raw, fields)
if (text) index.set(id, text)
}
return index
})
const layoutMode = ref<LayoutMode>('list')
const selectedSet = ref<Set<string>>(new Set())
async function searcher(query: string, searchItems: FormDropdownItem[]) {
if (!query.trim()) return searchItems
const q = query.toLowerCase()
return searchItems.filter((item) => {
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
return text.includes(q)
})
}
// --- Selection sync ---
watch(
[modelValue, items],
([val]) => {
selectedSet.value.clear()
if (val) {
const item = items.value.find((i) => i.id === val)
if (item) selectedSet.value.add(item.id)
}
},
{ immediate: true }
)
function handleRefresh() {
abortController?.abort()
error.value = null
const config = remoteConfig.value
// Sequence the cache delete before the refetch: otherwise the (very fast)
// setCache from a quickly-resolved network response can land the new entry
// before the still-pending cache.delete removes it, silently dropping the
// freshly-cached data on the next mount.
void (async () => {
if (config) await clearCache(config)
await fetchItems(true)
})()
}
function handleSelection(selected: Set<string>) {
modelValue.value = selected.values().next().value
}
const placeholder = computed(() => {
if (loading.value) return t('widgets.remoteCombo.loading')
if (error.value) return error.value
if (loadingMore.value) {
return t('widgets.remoteCombo.itemsLoaded', {
count: items.value.length
})
}
return t('widgets.uploadSelect.placeholder')
})
</script>
<template>
<div
class="flex w-full min-w-0 items-center gap-1 rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<FormDropdown
v-model:selected="selectedSet"
v-model:layout-mode="layoutMode"
:items="items"
:placeholder="placeholder"
:multiple="false"
:show-sort="false"
:show-layout-switcher="false"
:searcher="searcher"
class="min-w-0 flex-1"
@update:selected="handleSelection"
/>
<button
v-if="remoteConfig?.refresh_button !== false"
type="button"
:aria-label="t('g.refresh')"
:title="t('g.refresh')"
class="text-secondary flex size-7 shrink-0 items-center justify-center rounded-sm hover:bg-component-node-widget-background-hovered"
@click.stop="handleRefresh"
>
<i
:class="
cn(
'icon-[lucide--refresh-cw] size-3.5',
(loading || loadingMore) && 'animate-spin'
)
"
/>
</button>
</div>
</template>

View File

@@ -1,7 +1,6 @@
<template>
<RichComboWidget v-if="hasRemoteCombo" v-model="modelValue" :widget />
<WidgetSelectDropdown
v-else-if="isDropdownUIWidget"
v-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
@@ -25,7 +24,6 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
@@ -55,8 +53,6 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
return undefined
})
const hasRemoteCombo = computed(() => !!comboSpec.value?.remote_combo)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean

View File

@@ -33,8 +33,6 @@ interface Props {
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -61,8 +59,6 @@ const {
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -233,8 +229,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -20,8 +20,6 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -33,8 +31,6 @@ const {
isSelected,
filterOptions,
sortOptions,
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -116,8 +112,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter
@@ -151,7 +145,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:description="item.description"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>

View File

@@ -16,10 +16,8 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const { showSort = true, showLayoutSwitcher = true } = defineProps<{
defineProps<{
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -114,7 +112,6 @@ function toggleBaseModelSelection(item: FilterOption) {
/>
<Button
v-if="showSort"
ref="sortTriggerRef"
variant="textonly"
size="icon"
@@ -133,7 +130,6 @@ function toggleBaseModelSelection(item: FilterOption) {
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<Popover
v-if="showSort"
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
@@ -308,7 +304,6 @@ function toggleBaseModelSelection(item: FilterOption) {
</Popover>
<div
v-if="showLayoutSwitcher"
:class="
cn(
actionButtonStyle,

View File

@@ -1,21 +1,17 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import { AssetKindKey } from './types'
import type { LayoutMode } from './types'
const { t } = useI18n()
interface Props {
index: number
selected: boolean
previewUrl: string
name: string
label?: string
description?: string
layout?: LayoutMode
}
@@ -31,26 +27,11 @@ const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isAudio = computed(() => assetKind?.value === 'audio')
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlayingAudio = ref(false)
function handleClick() {
emit('click', props.index)
}
function toggleAudioPreview(event: Event) {
event.stopPropagation()
const audio = audioRef.value
if (!audio) return
if (audio.paused) {
void audio.play().catch(() => {})
} else {
audio.pause()
}
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
@@ -126,35 +107,6 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<button
v-else-if="previewUrl && isAudio"
type="button"
:aria-label="
isPlayingAudio
? t('widgets.remoteCombo.pauseAudioPreview')
: t('widgets.remoteCombo.playAudioPreview')
"
:aria-pressed="isPlayingAudio"
class="flex size-full cursor-pointer items-center justify-center bg-component-node-widget-background hover:bg-component-node-widget-background-hovered"
@click.stop="toggleAudioPreview"
>
<audio
ref="audioRef"
:src="previewUrl"
preload="none"
@play="isPlayingAudio = true"
@pause="isPlayingAudio = false"
@ended="isPlayingAudio = false"
/>
<i
:class="
cn(
'text-secondary size-5',
isPlayingAudio ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
)
"
/>
</button>
<img
v-else-if="previewUrl"
:src="previewUrl"
@@ -192,13 +144,6 @@ function handleVideoLoad(event: Event) {
>
{{ label ?? name }}
</span>
<!-- Description -->
<span
v-if="description && layout !== 'grid'"
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
>
{{ description }}
</span>
<!-- Meta Data -->
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}

View File

@@ -12,9 +12,7 @@ export interface FormDropdownItem {
name: string
/** Original/alternate label (e.g., original filename) */
label?: string
/** Short description shown below the name in list view */
description?: string
/** Preview image/video/audio URL */
/** Preview image/video URL */
preview_url?: string
/** Whether the item is immutable (public model) - used for ownership filtering */
is_immutable?: boolean

View File

@@ -9,7 +9,7 @@ import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/c
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
const mockCheckState = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const actual = await vi.importActual(
@@ -20,7 +20,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
useWorkflowStore: () => ({
activeWorkflow: {
changeTracker: {
captureCanvasState: mockCaptureCanvasState
checkState: mockCheckState
}
}
})
@@ -48,7 +48,7 @@ function createItems(...names: string[]): FormDropdownItem[] {
describe('useWidgetSelectActions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockCaptureCanvasState.mockClear()
mockCheckState.mockClear()
})
describe('updateSelectedItems', () => {
@@ -71,7 +71,7 @@ describe('useWidgetSelectActions', () => {
updateSelectedItems(new Set(['input-1']))
expect(modelValue.value).toBe('photo_abc.jpg')
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('clears modelValue when empty set', () => {
@@ -93,7 +93,7 @@ describe('useWidgetSelectActions', () => {
updateSelectedItems(new Set())
expect(modelValue.value).toBeUndefined()
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
expect(mockCheckState).toHaveBeenCalledOnce()
})
})
@@ -130,7 +130,7 @@ describe('useWidgetSelectActions', () => {
await handleFilesUpdate([file])
expect(modelValue.value).toBe('uploaded.png')
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('adds uploaded path to widget values array', async () => {

View File

@@ -23,8 +23,8 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
function captureWorkflowState() {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
function checkWorkflowState() {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
}
function updateSelectedItems(selectedItems: Set<string>) {
@@ -36,7 +36,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
: dropdownItems.value.find((item) => item.id === id)?.name
modelValue.value = name
captureWorkflowState()
checkWorkflowState()
}
async function uploadFile(
@@ -109,7 +109,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
widget.callback(uploadedPaths[0])
}
captureWorkflowState()
checkWorkflowState()
}
)

View File

@@ -1,106 +0,0 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchRemoteRoute } from '@/renderer/extensions/vueNodes/widgets/utils/fetchRemoteRoute'
import type { AuthHeader } from '@/types/authTypes'
const COMFY_API_BASE = 'https://api.example.test'
const mockAuth = vi.hoisted(() => ({
authHeader: null as AuthHeader | null
}))
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof axios>()
return {
default: {
...actual,
get: vi.fn()
}
}
})
vi.mock('@/config/comfyApi', () => ({
getComfyApiBaseUrl: () => COMFY_API_BASE
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi.fn(() => Promise.resolve(mockAuth.authHeader))
}))
}))
describe('fetchRemoteRoute', () => {
beforeEach(() => {
vi.mocked(axios.get).mockResolvedValue({ data: [] })
})
afterEach(() => {
mockAuth.authHeader = null
vi.clearAllMocks()
})
it('uses the route as-is when useComfyApi is not set', async () => {
await fetchRemoteRoute('/voices')
const [url] = vi.mocked(axios.get).mock.calls[0]
expect(url).toBe('/voices')
})
it('passes through absolute URLs unchanged when useComfyApi is false', async () => {
await fetchRemoteRoute('https://other.example/voices', {
useComfyApi: false
})
const [url] = vi.mocked(axios.get).mock.calls[0]
expect(url).toBe('https://other.example/voices')
})
it('prepends the comfy api base URL when useComfyApi is true', async () => {
await fetchRemoteRoute('/voices', { useComfyApi: true })
const [url] = vi.mocked(axios.get).mock.calls[0]
expect(url).toBe(`${COMFY_API_BASE}/voices`)
})
it('injects the auth header when useComfyApi is true and one is available', async () => {
mockAuth.authHeader = { Authorization: 'Bearer token-123' }
await fetchRemoteRoute('/voices', { useComfyApi: true })
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.headers).toEqual({ Authorization: 'Bearer token-123' })
})
it('does not set headers when useComfyApi is true but no auth header is available', async () => {
mockAuth.authHeader = null
await fetchRemoteRoute('/voices', { useComfyApi: true })
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.headers).toBeUndefined()
})
it('does not inject auth headers when useComfyApi is false', async () => {
mockAuth.authHeader = { Authorization: 'Bearer token-123' }
await fetchRemoteRoute('/voices', { useComfyApi: false })
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.headers).toBeUndefined()
})
it('forwards params, timeout and signal to axios while stripping useComfyApi', async () => {
const controller = new AbortController()
await fetchRemoteRoute('/voices', {
useComfyApi: true,
params: { page: '2', page_size: '10' },
timeout: 5000,
signal: controller.signal
})
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.params).toEqual({ page: '2', page_size: '10' })
expect(config?.timeout).toBe(5000)
expect(config?.signal).toBe(controller.signal)
expect(
(config as Record<string, unknown> | undefined)?.useComfyApi
).toBeUndefined()
})
it('returns the axios response', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: { items: [1, 2] } })
const res = await fetchRemoteRoute('/voices')
expect(res.data).toEqual({ items: [1, 2] })
})
})

View File

@@ -1,53 +0,0 @@
import axios from 'axios'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useAuthStore } from '@/stores/authStore'
import type { AuthHeader } from '@/types/authTypes'
/**
* Resolve a RemoteComboOptions route to a full URL.
* - useComfyApi=true → prepend getComfyApiBaseUrl()
* - Otherwise → use as-is
*/
function resolveRoute(route: string, useComfyApi?: boolean): string {
if (useComfyApi) {
return getComfyApiBaseUrl() + route
}
return route
}
/**
* Get auth headers for a remote request.
* - useComfyApi=true → inject auth headers (comfy-api requires it)
* - Otherwise → no auth headers injected
*/
async function getRemoteAuthHeaders(
useComfyApi?: boolean
): Promise<{ headers?: AuthHeader }> {
if (useComfyApi) {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
if (authHeader) {
return { headers: authHeader }
}
}
return {}
}
/**
* Convenience: make an authenticated GET request to a remote route.
*/
export async function fetchRemoteRoute(
route: string,
options: {
params?: Record<string, string>
timeout?: number
signal?: AbortSignal
useComfyApi?: boolean
} = {}
) {
const { useComfyApi, ...requestOptions } = options
const url = resolveRoute(route, useComfyApi)
const authHeaders = await getRemoteAuthHeaders(useComfyApi)
return axios.get(url, { ...requestOptions, ...authHeaders })
}

View File

@@ -1,254 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildSearchText,
extractItems,
getByPath,
mapToDropdownItem,
resolveLabel
} from '@/renderer/extensions/vueNodes/widgets/utils/itemSchemaUtils'
describe('getByPath', () => {
it('returns a top-level value for a plain key', () => {
expect(getByPath({ name: 'Alice' }, 'name')).toBe('Alice')
})
it('traverses nested objects via dot-path', () => {
expect(getByPath({ profile: { name: 'Alice' } }, 'profile.name')).toBe(
'Alice'
)
})
it('treats numeric segments as array indices', () => {
expect(getByPath({ items: ['a', 'b', 'c'] }, 'items.1')).toBe('b')
})
it('combines nested objects and array indices', () => {
const obj = { data: { results: [{ id: 'x' }, { id: 'y' }] } }
expect(getByPath(obj, 'data.results.1.id')).toBe('y')
})
it('returns undefined for a missing top-level key', () => {
expect(getByPath({ a: 1 }, 'b')).toBeUndefined()
})
it('returns undefined when traversing past a null segment', () => {
expect(getByPath({ a: null }, 'a.b')).toBeUndefined()
})
it('returns undefined when the root is null', () => {
expect(getByPath(null, 'a')).toBeUndefined()
})
it('returns undefined when the root is undefined', () => {
expect(getByPath(undefined, 'a')).toBeUndefined()
})
it('returns undefined for an out-of-bounds array index', () => {
expect(getByPath({ items: ['a'] }, 'items.5')).toBeUndefined()
})
})
describe('resolveLabel', () => {
it('resolves a plain dot-path to its value', () => {
expect(resolveLabel('name', { name: 'Alice' })).toBe('Alice')
})
it('resolves a nested dot-path without placeholders', () => {
expect(resolveLabel('profile.name', { profile: { name: 'Alice' } })).toBe(
'Alice'
)
})
it('substitutes a single {field} placeholder', () => {
expect(resolveLabel('Name: {name}', { name: 'Alice' })).toBe('Name: Alice')
})
it('substitutes multiple placeholders', () => {
expect(
resolveLabel('{first} {last}', { first: 'Alice', last: 'Liddell' })
).toBe('Alice Liddell')
})
it('substitutes placeholders with dot-paths', () => {
expect(
resolveLabel('{profile.name} ({profile.age})', {
profile: { name: 'Alice', age: 30 }
})
).toBe('Alice (30)')
})
it('replaces missing placeholder fields with an empty string', () => {
expect(resolveLabel('{name} - {missing}', { name: 'Alice' })).toBe(
'Alice - '
)
})
it('returns an empty string when a plain path resolves to undefined', () => {
expect(resolveLabel('missing', { a: 1 })).toBe('')
})
it('coerces numeric values to strings', () => {
expect(resolveLabel('{count}', { count: 5 })).toBe('5')
})
})
describe('mapToDropdownItem', () => {
it('maps required fields to id and name', () => {
const item = mapToDropdownItem(
{ voice_id: 'v1', label: 'Roger' },
{ value_field: 'voice_id', label_field: 'label', preview_type: 'image' }
)
expect(item).toEqual({
id: 'v1',
name: 'Roger',
description: undefined,
preview_url: undefined
})
})
it('includes description when description_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', desc: 'Laid-back American male' },
{
value_field: 'id',
label_field: 'label',
description_field: 'desc',
preview_type: 'image'
}
)
expect(item.description).toBe('Laid-back American male')
})
it('includes preview_url when preview_url_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', sample: 'https://example.com/a.mp3' },
{
value_field: 'id',
label_field: 'label',
preview_url_field: 'sample',
preview_type: 'audio'
}
)
expect(item.preview_url).toBe('https://example.com/a.mp3')
})
it('resolves label_field templates with placeholders', () => {
const item = mapToDropdownItem(
{ id: 'v1', first: 'Alice', last: 'Liddell' },
{
value_field: 'id',
label_field: '{first} {last}',
preview_type: 'image'
}
)
expect(item.name).toBe('Alice Liddell')
})
it('resolves dot-path fields for nested data', () => {
const item = mapToDropdownItem(
{ task_result: { elements: [{ element_id: 'e1', name: 'Elem' }] } },
{
value_field: 'task_result.elements.0.element_id',
label_field: 'task_result.elements.0.name',
preview_type: 'image'
}
)
expect(item.id).toBe('e1')
expect(item.name).toBe('Elem')
})
it('stringifies non-string value_field', () => {
const item = mapToDropdownItem(
{ id: 42, label: 'Answer' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('42')
})
it('returns an empty string id when value_field is missing', () => {
const item = mapToDropdownItem(
{ label: 'Orphan' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('')
})
})
describe('extractItems', () => {
it('returns the full response when responseKey is undefined', () => {
expect(extractItems([1, 2, 3])).toEqual([1, 2, 3])
})
it('extracts items from a top-level key', () => {
expect(
extractItems({ voices: [{ id: 'a' }, { id: 'b' }] }, 'voices')
).toEqual([{ id: 'a' }, { id: 'b' }])
})
it('extracts items via a dot-path', () => {
expect(extractItems({ data: { items: [1, 2] } }, 'data.items')).toEqual([
1, 2
])
})
it('returns an empty array for a valid empty list', () => {
expect(extractItems([])).toEqual([])
})
it('returns null when the path does not exist', () => {
expect(extractItems({ a: 1 }, 'nonexistent')).toBeNull()
})
it('returns null when the path resolves to a non-array', () => {
expect(
extractItems({ data: { items: 'not an array' } }, 'data.items')
).toBeNull()
})
it('returns null when the full response is not an array', () => {
expect(extractItems({ not: 'array' })).toBeNull()
})
it('returns null when response is null', () => {
expect(extractItems(null)).toBeNull()
})
})
describe('buildSearchText', () => {
it('joins multiple fields with a space', () => {
expect(buildSearchText({ a: 'Hello', b: 'World' }, ['a', 'b'])).toBe(
'hello world'
)
})
it('lowercases the result', () => {
expect(buildSearchText({ name: 'ALICE' }, ['name'])).toBe('alice')
})
it('drops missing fields', () => {
expect(buildSearchText({ name: 'Alice' }, ['name', 'missing'])).toBe(
'alice'
)
})
it('supports dot-path fields', () => {
expect(
buildSearchText({ profile: { name: 'Alice', age: 30 } }, [
'profile.name',
'profile.age'
])
).toBe('alice 30')
})
it('returns an empty string when all fields are missing', () => {
expect(buildSearchText({ name: 'Alice' }, ['missing'])).toBe('')
})
})

View File

@@ -1,62 +0,0 @@
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
/** Traverse an object by dot-path, treating numeric segments as array indices */
export function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc == null) return undefined
const idx = Number(key)
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
return (acc as Record<string, unknown>)[key]
}, obj)
}
/** Resolve a label — either dot-path or template with {field.path} placeholders */
export function resolveLabel(template: string, item: unknown): string {
if (!template.includes('{')) {
return String(getByPath(item, template) ?? '')
}
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
String(getByPath(item, path) ?? '')
)
}
/** Map a raw API object to a FormDropdownItem using the item_schema */
export function mapToDropdownItem(
raw: unknown,
schema: RemoteItemSchema
): FormDropdownItem {
return {
id: String(getByPath(raw, schema.value_field) ?? ''),
name: resolveLabel(schema.label_field, raw),
description: schema.description_field
? resolveLabel(schema.description_field, raw)
: undefined,
preview_url: schema.preview_url_field
? String(getByPath(raw, schema.preview_url_field) ?? '')
: undefined
}
}
/**
* Extract items array from a full API response using `responseKey`.
* Returns `null` when the resolved value isn't an array (path missing,
* wrong shape, etc.) so callers can distinguish a malformed response
* from a legitimate empty list.
*/
export function extractItems(
response: unknown,
responseKey?: string
): unknown[] | null {
const data = responseKey ? getByPath(response, responseKey) : response
return Array.isArray(data) ? data : null
}
/** Build search text for an item from the specified search fields */
export function buildSearchText(raw: unknown, searchFields: string[]): string {
return searchFields
.map((field) => String(getByPath(raw, field) ?? ''))
.filter(Boolean)
.join(' ')
.toLowerCase()
}

View File

@@ -1,280 +0,0 @@
import { AxiosError, AxiosHeaders } from 'axios'
import { describe, expect, it } from 'vitest'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
import {
buildCacheKey,
getBackoff,
isRetriableError,
summarizeError,
summarizePayload
} from '@/renderer/extensions/vueNodes/widgets/utils/richComboHelpers'
const baseConfig: RemoteComboConfig = {
route: '/voices',
item_schema: {
value_field: 'id',
label_field: 'name',
preview_type: 'image'
}
}
function parseKey(key: string): URLSearchParams {
return new URL(key).searchParams
}
describe('buildCacheKey', () => {
it('encodes the route, response_key and page_size', () => {
const params = parseKey(
buildCacheKey({
...baseConfig,
route: '/voices',
response_key: 'data.items',
page_size: 50
})
)
expect(params.get('route')).toBe('/voices')
expect(params.get('responseKey')).toBe('data.items')
expect(params.get('pageSize')).toBe('50')
})
it('encodes use_comfy_api as a 0/1 flag', () => {
expect(parseKey(buildCacheKey(baseConfig)).get('useComfyApi')).toBe('0')
expect(
parseKey(buildCacheKey({ ...baseConfig, use_comfy_api: true }, 'u1')).get(
'useComfyApi'
)
).toBe('1')
})
it('partitions by authScope only when use_comfy_api is true', () => {
const comfyA = buildCacheKey(
{ ...baseConfig, use_comfy_api: true },
'ws:team-a'
)
const comfyB = buildCacheKey(
{ ...baseConfig, use_comfy_api: true },
'ws:team-b'
)
expect(comfyA).not.toBe(comfyB)
expect(parseKey(comfyA).get('u')).toBe('ws:team-a')
expect(parseKey(comfyB).get('u')).toBe('ws:team-b')
})
it('shares the cache across auth scopes when use_comfy_api is false', () => {
const a = buildCacheKey(baseConfig, 'fb:user-a')
const b = buildCacheKey(baseConfig, 'fb:user-b')
expect(a).toBe(b)
expect(parseKey(a).has('u')).toBe(false)
})
it('treats workspace, firebase, and api-key scopes as distinct buckets', () => {
const ws = buildCacheKey({ ...baseConfig, use_comfy_api: true }, 'ws:abc')
const fb = buildCacheKey({ ...baseConfig, use_comfy_api: true }, 'fb:abc')
const apikey = buildCacheKey(
{ ...baseConfig, use_comfy_api: true },
'apikey'
)
expect(new Set([ws, fb, apikey]).size).toBe(3)
})
it('falls back to "anon" when use_comfy_api is true and authScope is missing', () => {
expect(
parseKey(buildCacheKey({ ...baseConfig, use_comfy_api: true }, null)).get(
'u'
)
).toBe('anon')
expect(
parseKey(buildCacheKey({ ...baseConfig, use_comfy_api: true })).get('u')
).toBe('anon')
})
it('treats missing optional fields as empty / 0 so the key stays stable', () => {
const params = parseKey(buildCacheKey(baseConfig))
expect(params.get('responseKey')).toBe('')
expect(params.get('pageSize')).toBe('0')
})
})
describe('getBackoff', () => {
it('grows exponentially from 1s', () => {
expect(getBackoff(1)).toBe(2000)
expect(getBackoff(2)).toBe(4000)
expect(getBackoff(3)).toBe(8000)
expect(getBackoff(4)).toBe(16000)
})
it('caps at 16s for higher attempt counts', () => {
expect(getBackoff(5)).toBe(16000)
expect(getBackoff(10)).toBe(16000)
expect(getBackoff(100)).toBe(16000)
})
})
describe('isRetriableError', () => {
function axiosErrorWithStatus(status: number): AxiosError {
return new AxiosError(
`HTTP ${status}`,
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
}
it('retries non-axios errors (e.g. unexpected throws)', () => {
expect(isRetriableError(new Error('boom'))).toBe(true)
expect(isRetriableError('string error')).toBe(true)
expect(isRetriableError(undefined)).toBe(true)
})
it('retries axios errors with no response (network failures)', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(isRetriableError(err)).toBe(true)
})
it('retries 5xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(500))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(502))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(503))).toBe(true)
})
it('retries 408 (request timeout) and 429 (too many requests)', () => {
expect(isRetriableError(axiosErrorWithStatus(408))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(429))).toBe(true)
})
it('does not retry other 4xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(400))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(401))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(403))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(404))).toBe(false)
})
})
describe('summarizeError', () => {
it('extracts message, code and status from an axios error', () => {
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status: 500,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
expect(summarizeError(err)).toEqual({
message: 'Request failed',
code: 'ERR_BAD_RESPONSE',
status: 500
})
})
it('does not include axios config, headers, request or response data', () => {
const authedConfig = {
url: '/voices',
method: 'get',
headers: new AxiosHeaders({ Authorization: 'Bearer SECRET-TOKEN-123' })
}
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
authedConfig,
undefined,
{
status: 500,
statusText: '',
headers: { 'set-cookie': ['session=PRIVATE'] },
config: authedConfig,
data: { user_email: 'private@example.com' }
}
)
const summary = summarizeError(err)
expect(JSON.stringify(summary)).not.toContain('SECRET-TOKEN-123')
expect(JSON.stringify(summary)).not.toContain('PRIVATE')
expect(JSON.stringify(summary)).not.toContain('private@example.com')
expect(summary).not.toHaveProperty('config')
expect(summary).not.toHaveProperty('request')
expect(summary).not.toHaveProperty('response')
})
it('reports an axios network error with no response as undefined status', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(summarizeError(err)).toEqual({
message: 'Network Error',
code: 'ERR_NETWORK',
status: undefined
})
})
it('summarizes a plain Error using its name and message', () => {
expect(summarizeError(new TypeError('boom'))).toEqual({
message: 'boom',
name: 'TypeError'
})
})
it('coerces non-Error throwables to a message string', () => {
expect(summarizeError('oops')).toEqual({ message: 'oops' })
expect(summarizeError(42)).toEqual({ message: '42' })
expect(summarizeError(null)).toEqual({ message: 'null' })
expect(summarizeError(undefined)).toEqual({ message: 'undefined' })
})
})
describe('summarizePayload', () => {
it('reports array length without exposing values', () => {
expect(
summarizePayload([{ secret: 'a' }, { secret: 'b' }, { secret: 'c' }])
).toEqual({
type: 'array',
length: 3
})
})
it('reports object keys without exposing values', () => {
expect(
summarizePayload({ user_email: 'private@example.com', voices: ['x'] })
).toEqual({
type: 'object',
keys: ['user_email', 'voices'],
keyCount: 2
})
})
it('caps the keys sample at 10 but reports the full key count', () => {
const big: Record<string, number> = {}
for (let i = 0; i < 25; i++) big[`k${i}`] = i
const summary = summarizePayload(big) as {
type: string
keys: string[]
keyCount: number
}
expect(summary.type).toBe('object')
expect(summary.keys).toHaveLength(10)
expect(summary.keyCount).toBe(25)
})
it('distinguishes null and undefined', () => {
expect(summarizePayload(null)).toEqual({ type: 'null' })
expect(summarizePayload(undefined)).toEqual({ type: 'undefined' })
})
it('reports primitive types without their value', () => {
expect(summarizePayload('hello')).toEqual({ type: 'string' })
expect(summarizePayload(123)).toEqual({ type: 'number' })
expect(summarizePayload(true)).toEqual({ type: 'boolean' })
})
})

View File

@@ -1,95 +0,0 @@
import axios from 'axios'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
const BACKOFF_BASE_MS = 1000
const BACKOFF_CAP_MS = 16000
/**
* Build a stable cache key for a remote combo configuration.
*
* Non-comfy-api routes intentionally share cache across users on the same
* machine; comfy-api routes are partitioned by `authScope` — an opaque,
* non-secret identifier of the active auth context (workspace id, firebase
* uid, etc.). Resolving the scope is the caller's responsibility, which
* keeps this helper pure and trivially testable.
*/
export function buildCacheKey(
config: RemoteComboConfig,
authScope?: string | null
): string {
const params = new URLSearchParams({
route: config.route,
useComfyApi: config.use_comfy_api ? '1' : '0',
responseKey: config.response_key ?? '',
pageSize: String(config.page_size ?? 0)
})
if (config.use_comfy_api) {
params.set('u', authScope ?? 'anon')
}
return `https://cache.comfy.invalid/?${params}`
}
/**
* Exponential backoff in milliseconds, capped at 16s. `count` is the
* number of failed attempts so far (1-indexed for the first retry).
*/
export function getBackoff(count: number): number {
return Math.min(BACKOFF_BASE_MS * Math.pow(2, count), BACKOFF_CAP_MS)
}
/**
* Distinguish transient errors (worth retrying) from permanent ones.
* 401/403/404 etc. won't fix themselves — retrying wastes time.
* Network-level failures (no response) are treated as retriable.
*/
export function isRetriableError(err: unknown): boolean {
if (!axios.isAxiosError(err)) return true
const status = err.response?.status
if (status == null) return true
if (status >= 500) return true
return status === 408 || status === 429
}
/**
* Build a console-safe summary of an unknown error. Authenticated remote
* routes inject auth headers via fetchRemoteRoute, and AxiosError serializes
* its `config` (including those headers) by default — so logging the raw
* error would leak bearer tokens to devtools and any attached telemetry.
* This summary keeps only the diagnostic essentials.
*/
export function summarizeError(err: unknown): Record<string, unknown> {
if (axios.isAxiosError(err)) {
return {
message: err.message,
code: err.code,
status: err.response?.status
}
}
if (err instanceof Error) {
return { message: err.message, name: err.name }
}
return { message: String(err) }
}
const PAYLOAD_KEY_SAMPLE = 10
/**
* Build a console-safe summary of a remote response payload. Logs the
* structural shape so devs can diagnose schema mismatches without the
* actual values, which for authenticated routes may contain private data.
*/
export function summarizePayload(data: unknown): Record<string, unknown> {
if (data === null) return { type: 'null' }
if (data === undefined) return { type: 'undefined' }
if (Array.isArray(data)) return { type: 'array', length: data.length }
if (typeof data === 'object') {
const keys = Object.keys(data as Record<string, unknown>)
return {
type: 'object',
keys: keys.slice(0, PAYLOAD_KEY_SAMPLE),
keyCount: keys.length
}
}
return { type: typeof data }
}

View File

@@ -5,11 +5,6 @@ import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
/**
* Plain remote combo config — feeds a standard combo dropdown from a remote endpoint.
* Handled by `useRemoteWidget` + `WidgetSelectDropdown`.
*/
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
@@ -20,42 +15,6 @@ const zRemoteWidgetConfig = z.object({
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zRemoteItemSchema = z.object({
value_field: z.string(),
label_field: z.string(),
preview_url_field: z.string().optional(),
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
description_field: z.string().optional(),
search_fields: z.array(z.string()).optional()
})
/**
* Rich remote combo config — feeds `RichComboWidget` with item previews, search, filtering,
* and optional progressive pagination. Requires `item_schema`. Vue-nodes only.
*/
const zRemoteComboConfig = z
.object({
route: z.string().url().or(z.string().startsWith('/')),
item_schema: zRemoteItemSchema,
refresh_button: z.boolean().optional(),
auto_select: z.enum(['first', 'last']).optional(),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
response_key: z.string().optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional(),
use_comfy_api: z.boolean().optional(),
page_size: z.number().gte(1).optional()
})
.superRefine((config, ctx) => {
if (config.use_comfy_api && !config.route.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['route'],
message: 'route must be relative when use_comfy_api is true'
})
}
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
chip: z.boolean().optional()
@@ -137,7 +96,6 @@ export const zComboInputOptions = zBaseInputOptions.extend({
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
remote_combo: zRemoteComboConfig.optional(),
/** Whether the widget is a multi-select widget. */
multi_select: zMultiSelectOption.optional()
})
@@ -394,9 +352,7 @@ export const zMatchTypeOptions = z.object({
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type RemoteComboConfig = z.infer<typeof zRemoteComboConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>

View File

@@ -71,67 +71,4 @@ describe('validateNodeDef', () => {
})
}
)
describe('remote_combo cross-field validation', () => {
const buildNodeDef = (remoteCombo: object): unknown => ({
...EXAMPLE_NODE_DEF,
input: {
required: {
voice: ['COMBO', { remote_combo: remoteCombo }]
}
}
})
const baseRemoteCombo = {
item_schema: { value_field: 'id', label_field: 'name' }
}
it('rejects use_comfy_api=true paired with an absolute route', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'https://api.example.com/voices',
use_comfy_api: true
}),
() => {}
)
).toBeNull()
})
it('accepts use_comfy_api=true paired with a relative route', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: '/voices',
use_comfy_api: true
})
)
).not.toBeNull()
})
it('accepts use_comfy_api=false with an absolute route', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'https://api.example.com/voices',
use_comfy_api: false
})
)
).not.toBeNull()
})
it('accepts an absolute route when use_comfy_api is omitted', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'https://api.example.com/voices'
})
)
).not.toBeNull()
})
})
})

View File

@@ -1,302 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
const mockNodeOutputStore = vi.hoisted(() => ({
snapshotOutputs: vi.fn(() => ({})),
restoreOutputs: vi.fn()
}))
const mockSubgraphNavigationStore = vi.hoisted(() => ({
exportState: vi.fn(() => []),
restoreState: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: null as { changeTracker: unknown } | null,
getWorkflowByPath: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
graph: {},
rootGraph: {
serialize: vi.fn(() => ({
nodes: [],
links: [],
groups: [],
extra: {},
config: {},
version: 0.4,
last_node_id: 0,
last_link_id: 0
}))
},
canvas: {
ds: { scale: 1, offset: [0, 0] }
}
}
}))
vi.mock('@/scripts/api', () => ({
api: {
dispatchCustomEvent: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: vi.fn(() => mockNodeOutputStore)
}))
vi.mock('@/stores/subgraphNavigationStore', () => ({
useSubgraphNavigationStore: vi.fn(() => mockSubgraphNavigationStore)
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
ComfyWorkflow: class {},
useWorkflowStore: vi.fn(() => mockWorkflowStore)
}))
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { ChangeTracker } from '@/scripts/changeTracker'
let nodeIdCounter = 0
function createState(nodeCount = 0): ComfyWorkflowJSON {
const nodes = Array.from({ length: nodeCount }, () => ({
id: ++nodeIdCounter,
type: 'TestNode',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
inputs: [],
outputs: [],
properties: {}
}))
return {
nodes,
links: [],
groups: [],
extra: {},
config: {},
version: 0.4,
last_node_id: nodeIdCounter,
last_link_id: 0
} as unknown as ComfyWorkflowJSON
}
function createTracker(initialState?: ComfyWorkflowJSON): ChangeTracker {
const state = initialState ?? createState()
const workflow = { path: '/test/workflow.json' } as never
const tracker = new ChangeTracker(workflow, state)
mockWorkflowStore.activeWorkflow = { changeTracker: tracker }
return tracker
}
function mockCanvasState(state: ComfyWorkflowJSON) {
vi.mocked(app.rootGraph.serialize).mockReturnValue(state as never)
}
describe('ChangeTracker', () => {
beforeEach(() => {
vi.clearAllMocks()
nodeIdCounter = 0
ChangeTracker.isLoadingGraph = false
mockWorkflowStore.activeWorkflow = null
mockWorkflowStore.getWorkflowByPath.mockReturnValue(null)
})
describe('captureCanvasState', () => {
describe('guards', () => {
it('is a no-op when app.graph is falsy', () => {
const tracker = createTracker()
const original = tracker.activeState
const spy = vi.spyOn(app, 'graph', 'get').mockReturnValue(null as never)
tracker.captureCanvasState()
spy.mockRestore()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(tracker.activeState).toBe(original)
})
it('is a no-op when changeCount > 0', () => {
const tracker = createTracker()
tracker.beforeChange()
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
it('is a no-op when isLoadingGraph is true', () => {
const tracker = createTracker()
ChangeTracker.isLoadingGraph = true
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
it('is a no-op when _restoringState is true', () => {
const tracker = createTracker()
tracker._restoringState = true
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
it('is a no-op and logs error when called on inactive tracker', () => {
const tracker = createTracker()
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
})
describe('state capture', () => {
it('pushes to undoQueue, updates activeState, and calls updateModified', () => {
const initial = createState(1)
const tracker = createTracker(initial)
const changed = createState(2)
mockCanvasState(changed)
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(1)
expect(tracker.undoQueue[0]).toEqual(initial)
expect(tracker.activeState).toEqual(changed)
expect(api.dispatchCustomEvent).toHaveBeenCalledWith(
'graphChanged',
changed
)
})
it('does not push when state is identical', () => {
const state = createState()
const tracker = createTracker(state)
mockCanvasState(state)
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(0)
})
it('clears redoQueue on new change', () => {
const tracker = createTracker(createState(1))
tracker.redoQueue.push(createState(3))
mockCanvasState(createState(2))
tracker.captureCanvasState()
expect(tracker.redoQueue).toHaveLength(0)
})
it('produces a single undo entry for a beforeChange/afterChange transaction', () => {
const tracker = createTracker(createState(1))
const intermediate = createState(2)
const final = createState(3)
tracker.beforeChange()
mockCanvasState(intermediate)
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(0)
mockCanvasState(final)
tracker.afterChange()
expect(tracker.undoQueue).toHaveLength(1)
expect(tracker.activeState).toEqual(final)
})
it('caps undoQueue at MAX_HISTORY', () => {
const tracker = createTracker(createState(1))
for (let i = 0; i < ChangeTracker.MAX_HISTORY; i++) {
tracker.undoQueue.push(createState(1))
}
expect(tracker.undoQueue).toHaveLength(ChangeTracker.MAX_HISTORY)
mockCanvasState(createState(2))
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(ChangeTracker.MAX_HISTORY)
})
})
})
describe('deactivate', () => {
it('captures canvas state then stores viewport/outputs', () => {
const tracker = createTracker(createState(1))
const changed = createState(2)
mockCanvasState(changed)
tracker.deactivate()
expect(tracker.activeState).toEqual(changed)
expect(mockNodeOutputStore.snapshotOutputs).toHaveBeenCalled()
expect(mockSubgraphNavigationStore.exportState).toHaveBeenCalled()
})
it('skips captureCanvasState but still calls store during undo/redo', () => {
const tracker = createTracker(createState(1))
tracker._restoringState = true
tracker.deactivate()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(mockNodeOutputStore.snapshotOutputs).toHaveBeenCalled()
})
it('is a full no-op when called on inactive tracker', () => {
const tracker = createTracker()
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
tracker.deactivate()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(mockNodeOutputStore.snapshotOutputs).not.toHaveBeenCalled()
})
})
describe('prepareForSave', () => {
it('captures canvas state when tracker is active', () => {
const tracker = createTracker(createState(1))
const changed = createState(2)
mockCanvasState(changed)
tracker.prepareForSave()
expect(tracker.activeState).toEqual(changed)
})
it('is a no-op when tracker is inactive', () => {
const tracker = createTracker()
const original = tracker.activeState
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
tracker.prepareForSave()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(tracker.activeState).toBe(original)
})
})
describe('checkState (deprecated)', () => {
it('delegates to captureCanvasState', () => {
const tracker = createTracker(createState(1))
const changed = createState(2)
mockCanvasState(changed)
tracker.checkState()
expect(tracker.activeState).toEqual(changed)
})
})
})

View File

@@ -4,8 +4,10 @@ import log from 'loglevel'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
@@ -24,18 +26,14 @@ const logger = log.getLogger('ChangeTracker')
// Change to debug for more verbose logging
logger.setLevel('info')
function isActiveTracker(tracker: ChangeTracker): boolean {
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
}
export class ChangeTracker {
static MAX_HISTORY = 50
/**
* Guard flag to prevent captureCanvasState from running during loadGraphData.
* Guard flag to prevent checkState from running during loadGraphData.
* Between rootGraph.configure() and afterLoadNewGraph(), the rootGraph
* contains the NEW workflow's data while activeWorkflow still points to
* the OLD workflow. Any captureCanvasState call in that window would
* serialize the wrong graph into the old workflow's activeState, corrupting it.
* the OLD workflow. Any checkState call in that window would serialize
* the wrong graph into the old workflow's activeState, corrupting it.
*/
static isLoadingGraph = false
/**
@@ -93,41 +91,6 @@ export class ChangeTracker {
this.subgraphState = { navigation }
}
/**
* Freeze this tracker's state before the workflow goes inactive.
* Always calls store() to preserve viewport/outputs. Calls
* captureCanvasState() only when not in undo/redo (to avoid
* corrupting undo history with intermediate graph state).
*
* PRECONDITION: must be called while this workflow is still the active one
* (before the activeWorkflow pointer is moved). If called after the pointer
* has already moved, this is a no-op to avoid freezing wrong viewport data.
*
* @internal Not part of the public extension API.
*/
deactivate() {
if (!isActiveTracker(this)) {
logger.warn(
'deactivate() called on inactive tracker for:',
this.workflow.path
)
return
}
if (!this._restoringState) this.captureCanvasState()
this.store()
}
/**
* Ensure activeState is up-to-date for persistence.
* Active workflow: flushes canvas → activeState.
* Inactive workflow: no-op (activeState was frozen by deactivate()).
*
* @internal Not part of the public extension API.
*/
prepareForSave() {
if (isActiveTracker(this)) this.captureCanvasState()
}
restore() {
if (this.ds) {
app.canvas.ds.scale = this.ds.scale
@@ -175,28 +138,8 @@ export class ChangeTracker {
}
}
/**
* Snapshot the current canvas state into activeState and push undo.
* INVARIANT: only the active workflow's tracker may read from the canvas.
* Calling this on an inactive tracker would capture the wrong graph.
*/
captureCanvasState() {
if (
!app.graph ||
this.changeCount ||
this._restoringState ||
ChangeTracker.isLoadingGraph
)
return
if (!isActiveTracker(this)) {
logger.warn(
'captureCanvasState called on inactive tracker for:',
this.workflow.path
)
return
}
checkState() {
if (!app.graph || this.changeCount || ChangeTracker.isLoadingGraph) return
const currentState = clone(app.rootGraph.serialize()) as ComfyWorkflowJSON
if (!this.activeState) {
this.activeState = currentState
@@ -215,19 +158,6 @@ export class ChangeTracker {
}
}
/** @deprecated Use {@link captureCanvasState} instead. */
checkState() {
if (!ChangeTracker._checkStateWarned) {
ChangeTracker._checkStateWarned = true
logger.warn(
'checkState() is deprecated — use captureCanvasState() instead.'
)
}
this.captureCanvasState()
}
private static _checkStateWarned = false
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
const prevState = source.pop()
if (prevState) {
@@ -286,14 +216,14 @@ export class ChangeTracker {
afterChange() {
if (!--this.changeCount) {
this.captureCanvasState()
this.checkState()
}
}
static init() {
const getCurrentChangeTracker = () =>
useWorkflowStore().activeWorkflow?.changeTracker
const captureState = () => getCurrentChangeTracker()?.captureCanvasState()
const checkState = () => getCurrentChangeTracker()?.checkState()
let keyIgnored = false
window.addEventListener(
@@ -337,8 +267,8 @@ export class ChangeTracker {
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(bindInputEl)) return
logger.debug('captureCanvasState on keydown')
changeTracker.captureCanvasState()
logger.debug('checkState on keydown')
changeTracker.checkState()
})
},
true
@@ -347,34 +277,34 @@ export class ChangeTracker {
window.addEventListener('keyup', () => {
if (keyIgnored) {
keyIgnored = false
logger.debug('captureCanvasState on keyup')
captureState()
logger.debug('checkState on keyup')
checkState()
}
})
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener('mouseup', () => {
logger.debug('captureCanvasState on mouseup')
captureState()
logger.debug('checkState on mouseup')
checkState()
})
// Handle prompt queue event for dynamic widget changes
api.addEventListener('promptQueued', () => {
logger.debug('captureCanvasState on promptQueued')
captureState()
logger.debug('checkState on promptQueued')
checkState()
})
api.addEventListener('graphCleared', () => {
logger.debug('captureCanvasState on graphCleared')
captureState()
logger.debug('checkState on graphCleared')
checkState()
})
// Handle litegraph clicks
const processMouseUp = LGraphCanvas.prototype.processMouseUp
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, [e])
logger.debug('captureCanvasState on processMouseUp')
captureState()
logger.debug('checkState on processMouseUp')
checkState()
return v
}
@@ -388,9 +318,9 @@ export class ChangeTracker {
) {
const extendedCallback = (v: string) => {
callback(v)
captureState()
checkState()
}
logger.debug('captureCanvasState on prompt')
logger.debug('checkState on prompt')
return prompt.apply(this, [title, value, extendedCallback, event])
}
@@ -398,8 +328,8 @@ export class ChangeTracker {
const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
const v = close.apply(this, [e])
logger.debug('captureCanvasState on contextMenuClose')
captureState()
logger.debug('checkState on contextMenuClose')
checkState()
return v
}
@@ -451,7 +381,7 @@ export class ChangeTracker {
const htmlElement = activeEl as HTMLElement
if (`on${evt}` in htmlElement) {
const listener = () => {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState?.()
useWorkflowStore().activeWorkflow?.changeTracker?.checkState?.()
htmlElement.removeEventListener(evt, listener)
}
htmlElement.addEventListener(evt, listener)

View File

@@ -1,67 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getWebpMetadata } from './pnginfo'
function buildExifPayload(workflowJson: string): Uint8Array {
const fullStr = `workflow:${workflowJson}\0`
const strBytes = new TextEncoder().encode(fullStr)
const headerSize = 22
const buf = new Uint8Array(headerSize + strBytes.length)
const dv = new DataView(buf.buffer)
buf.set([0x49, 0x49], 0)
dv.setUint16(2, 0x002a, true)
dv.setUint32(4, 8, true)
dv.setUint16(8, 1, true)
dv.setUint16(10, 0, true)
dv.setUint16(12, 2, true)
dv.setUint32(14, strBytes.length, true)
dv.setUint32(18, 22, true)
buf.set(strBytes, 22)
return buf
}
function buildWebp(precedingChunkLength: number, workflowJson: string): File {
const exifPayload = buildExifPayload(workflowJson)
const precedingPadded = precedingChunkLength + (precedingChunkLength % 2)
const totalSize = 12 + (8 + precedingPadded) + (8 + exifPayload.length)
const buffer = new Uint8Array(totalSize)
const dv = new DataView(buffer.buffer)
buffer.set([0x52, 0x49, 0x46, 0x46], 0)
dv.setUint32(4, totalSize - 8, true)
buffer.set([0x57, 0x45, 0x42, 0x50], 8)
buffer.set([0x56, 0x50, 0x38, 0x20], 12)
dv.setUint32(16, precedingChunkLength, true)
const exifStart = 20 + precedingPadded
buffer.set([0x45, 0x58, 0x49, 0x46], exifStart)
dv.setUint32(exifStart + 4, exifPayload.length, true)
buffer.set(exifPayload, exifStart + 8)
return new File([buffer], 'test.webp', { type: 'image/webp' })
}
describe('getWebpMetadata', () => {
it('finds workflow when a preceding chunk has odd length (RIFF padding)', async () => {
const workflow = '{"nodes":[]}'
const file = buildWebp(3, workflow)
const metadata = await getWebpMetadata(file)
expect(metadata.workflow).toBe(workflow)
})
it('finds workflow when preceding chunk has even length (no padding)', async () => {
const workflow = '{"nodes":[1]}'
const file = buildWebp(4, workflow)
const metadata = await getWebpMetadata(file)
expect(metadata.workflow).toBe(workflow)
})
})

View File

@@ -0,0 +1,43 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/app', () => ({
app: { canvas: undefined },
ComfyApp: class {}
}))
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
describe('useLitegraphService().getCanvasCenter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns origin when canvas is not yet initialised', () => {
Reflect.set(app, 'canvas', undefined)
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([0, 0])
})
it('returns origin when canvas exists but ds.visible_area is missing', () => {
Reflect.set(app, 'canvas', { ds: {} })
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([0, 0])
})
it('returns the visible-area centre once the canvas is ready', () => {
Reflect.set(app, 'canvas', {
ds: { visible_area: [10, 20, 200, 100] }
})
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([110, 70])
})
})

View File

@@ -364,29 +364,29 @@ describe('appModeStore', () => {
})
})
it('calls captureCanvasState when input is selected', async () => {
it('calls checkState when input is selected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
await nextTick()
vi.mocked(workflow.changeTracker!.captureCanvasState).mockClear()
vi.mocked(workflow.changeTracker!.checkState).mockClear()
store.selectedInputs.push([42, 'prompt'])
await nextTick()
expect(workflow.changeTracker!.captureCanvasState).toHaveBeenCalled()
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
})
it('calls captureCanvasState when input is deselected', async () => {
it('calls checkState when input is deselected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
store.selectedInputs.push([42, 'prompt'])
await nextTick()
vi.mocked(workflow.changeTracker!.captureCanvasState).mockClear()
vi.mocked(workflow.changeTracker!.checkState).mockClear()
store.selectedInputs.splice(0, 1)
await nextTick()
expect(workflow.changeTracker!.captureCanvasState).toHaveBeenCalled()
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
})
it('reflects input changes in linearData', async () => {

View File

@@ -93,7 +93,7 @@ export const useAppModeStore = defineStore('appMode', () => {
inputs: [...data.inputs],
outputs: [...data.outputs]
}
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
},
{ deep: true }
)

View File

@@ -9,7 +9,6 @@ import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
@@ -144,12 +143,6 @@ export const useSubgraphNavigationStore = defineStore(
if (getActiveGraphId() !== graphId) return
if (!canvas.graph?.nodes?.length) return
useLitegraphService().fitView()
// fitView changes scale/offset, so re-sync slot positions for
// collapsed nodes whose DOM-relative measurement is now stale.
requestAnimationFrame(() => {
if (getActiveGraphId() !== graphId) return
requestSlotLayoutSyncForAllNodes()
})
})
}

View File

@@ -12,13 +12,10 @@ import {
VIEWPORT_CACHE_MAX_SIZE
} from '@/stores/subgraphNavigationStore'
const { mockSetDirty, mockFitView, mockRequestSlotSyncAll } = vi.hoisted(
() => ({
mockSetDirty: vi.fn(),
mockFitView: vi.fn(),
mockRequestSlotSyncAll: vi.fn()
})
)
const { mockSetDirty, mockFitView } = vi.hoisted(() => ({
mockSetDirty: vi.fn(),
mockFitView: vi.fn()
}))
vi.mock('@/scripts/app', () => {
const mockCanvas = {
@@ -69,13 +66,6 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ fitView: mockFitView })
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
() => ({
requestSlotLayoutSyncForAllNodes: mockRequestSlotSyncAll
})
)
const mockCanvas = app.canvas
let rafCallbacks: FrameRequestCallback[] = []
@@ -96,7 +86,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
mockCanvas.ds.state.offset = [0, 0]
mockSetDirty.mockClear()
mockFitView.mockClear()
mockRequestSlotSyncAll.mockClear()
})
afterEach(() => {
@@ -228,53 +217,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockFitView).not.toHaveBeenCalled()
})
it('re-syncs all slot layouts on the frame after fitView', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
mockGraph.nodes = [{ pos: [0, 0], size: [100, 100] }]
mockGraph._nodes = mockGraph.nodes
store.restoreViewport('root')
expect(rafCallbacks).toHaveLength(1)
// Outer RAF runs fitView and schedules the inner RAF
rafCallbacks[0](performance.now())
expect(mockFitView).toHaveBeenCalledOnce()
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
expect(rafCallbacks).toHaveLength(2)
// Inner RAF re-syncs slots after fitView's transform has been applied
rafCallbacks[1](performance.now())
expect(mockRequestSlotSyncAll).toHaveBeenCalledOnce()
mockGraph.nodes = []
mockGraph._nodes = []
})
it('skips slot re-sync if active graph changed between fitView and inner RAF', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
mockGraph.nodes = [{ pos: [0, 0], size: [100, 100] }]
mockGraph._nodes = mockGraph.nodes
store.restoreViewport('root')
rafCallbacks[0](performance.now())
expect(mockFitView).toHaveBeenCalledOnce()
// User navigated away before the inner RAF fired
mockCanvas.subgraph = { id: 'different-graph' } as never
rafCallbacks[1](performance.now())
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
mockGraph.nodes = []
mockGraph._nodes = []
})
it('skips fitView if active graph changed before rAF fires', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')

View File

@@ -256,10 +256,7 @@ export function createMockChangeTracker(
undoQueue: [],
redoQueue: [],
changeCount: 0,
captureCanvasState: vi.fn(),
checkState: vi.fn(),
deactivate: vi.fn(),
prepareForSave: vi.fn(),
reset: vi.fn(),
restore: vi.fn(),
store: vi.fn(),

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
import type { SystemStats } from '@/schemas/apiSchema'
import type { ErrorReportData } from './errorReportUtil'
import { generateErrorReport } from './errorReportUtil'
const baseSystemStats: SystemStats = {
system: {
os: 'linux',
comfyui_version: '1.0.0',
python_version: '3.11',
pytorch_version: '2.0',
embedded_python: false,
argv: ['main.py'],
ram_total: 0,
ram_free: 0
},
devices: []
}
const baseWorkflow = { nodes: [], links: [] } as unknown as ISerialisedGraph
function buildError(serverLogs: unknown): ErrorReportData {
return {
exceptionType: 'RuntimeError',
exceptionMessage: 'boom',
systemStats: baseSystemStats,
serverLogs: serverLogs as string,
workflow: baseWorkflow
}
}
describe('generateErrorReport', () => {
it('embeds string serverLogs verbatim', () => {
const report = generateErrorReport(buildError('line one\nline two'))
expect(report).toContain('line one\nline two')
expect(report).not.toContain('[object Object]')
})
it('stringifies object serverLogs instead of rendering [object Object]', () => {
const report = generateErrorReport(
buildError({ entries: [{ msg: 'hello' }] })
)
expect(report).not.toContain('[object Object]')
expect(report).toContain('"entries"')
expect(report).toContain('"msg": "hello"')
})
})